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 /// Creates an instance of [NamedParameter] with name "directory" 118 /// and value `context().jdbldDirectory()`. 119 /// 120 /// Simply using `directory(context().jdbldDirectory())` is not possible 121 /// because referring to an instance method while explicitly invoking 122 /// a constructor is not possible. 123 /// 124 /// @return the named parameter 125 /// 126 protected static NamedParameter<Path> jdbldDirectory() { 127 return new NamedParameter<>("directory", jdbldDirectory); 128 } 129 130 /// Base class constructor for all projects. The behavior depends 131 /// on whether the project is a root project (implements [RootProject]) 132 /// or a subproject and on whether the project specifies a parent project. 133 /// 134 /// [RootProject]s must invoke this constructor with a null parent project 135 /// class. 136 /// 137 /// A sub project that wants to specify a parent project must invoke this 138 /// constructor with the parent project's class. If a sub project does not 139 /// specify a parent project, the root project is used as parent. In both 140 /// cases, the constructor adds a [Intent#Forward] dependency between the 141 /// parent project and the new project. This can then be overridden in the 142 /// sub project's constructor. 143 /// 144 /// @param params the named parameters 145 /// * parent - the class of the parent project 146 /// * name - the name of the project. If not provided the name is 147 /// set to the (simple) class name 148 /// * directory - the directory of the project. If not provided, 149 /// the directory is set to the name with uppercase letters 150 /// converted to lowercase for subprojects. 151 /// 152 /// If a project implements [MergedTestProject] and does not 153 /// specify a directory, its directory is set to the parent 154 /// project's directory. 155 /// 156 /// For root projects the directory is always set to the current 157 /// working directory. 158 /// 159 @SuppressWarnings({ "PMD.ConstructorCallsOverridableMethod", 160 "PMD.AvoidCatchingGenericException", "PMD.CognitiveComplexity", 161 "PMD.AvoidDeeplyNestedIfStmts", "PMD.CyclomaticComplexity", 162 "PMD.UseLocaleWithCaseConversions", "PMD.CollapsibleIfStatements" }) 163 protected AbstractProject(NamedParameter<?>... params) { 164 // Evaluate name 165 projectName = NamedParameter.<String> get(params, "name", 166 () -> getClass().getSimpleName()); 167 168 // Evaluate parent project 169 var parentProject = NamedParameter.< 170 Class<? extends Project>> get(params, "parent", null); 171 if (parentProject == null) { 172 if (AbstractRootProject.scopedRootProject.isBound()) { 173 parent = AbstractRootProject.scopedRootProject.get(); 174 } else { 175 parent = null; 176 } 177 if (this instanceof RootProject) { 178 if (parent != null) { 179 throw new ConfigurationException().from(this).message( 180 "Root project of type %s cannot be a sub project", 181 getClass().getSimpleName()); 182 } 183 } 184 } else { 185 parent = (AbstractProject) project(parentProject); 186 } 187 188 // Set directory, add fallback dependency 189 var directory = NamedParameter.<Path> get(params, "directory", null); 190 if (directory == jdbldDirectory) { // NOPMD 191 directory = context().jdbldDirectory(); 192 } 193 194 // Evaluate the project's directory and add to hierarchy 195 if (this instanceof MergedTestProject) { 196 // Special handling 197 if (directory != null || parentProject == null) { 198 throw new ConfigurationException().from(this).message( 199 "Merged test projects must specify a parent project" 200 + " and must not specify a directory."); 201 } 202 projectDirectory = parent.directory(); 203 parent.dependency(Forward, this); 204 } else if (parent == null) { 205 if (directory != null) { 206 throw new ConfigurationException().from(this).message( 207 "Root project of type %s cannot specify a directory.", 208 getClass().getSimpleName()); 209 } 210 projectDirectory = context().buildRoot(); 211 } else { 212 if (directory == null) { 213 directory = Path.of(projectName.toLowerCase()); 214 } 215 projectDirectory = parent.directory().resolve(directory); 216 // Fallback, will be replaced when the parent explicitly adds a 217 // dependency. 218 parent.dependency(Forward, this); 219 } 220 try { 221 rootProject().prepareProject(this); 222 } catch (Exception e) { 223 throw new BuildException().from(this).cause(e); 224 } 225 } 226 227 @Override 228 public RootProject rootProject() { 229 // The method may be called (indirectly) from the constructor 230 // of a subproject, that specifies its parent project class, to 231 // get the parent project instance. In this case, the new 232 // project's parent attribute has not been set yet and we have 233 // to use the fallback. 234 return Optional.ofNullable(parent) 235 .orElseGet(AbstractRootProject.scopedRootProject::get) 236 .rootProject(); 237 } 238 239 @Override 240 public Project project(Class<? extends Project> prjCls) { 241 if (this.getClass().equals(prjCls)) { 242 return this; 243 } 244 return rootProject().project(prjCls); 245 } 246 247 @Override 248 public Optional<Project> parentProject() { 249 return Optional.ofNullable(parent); 250 } 251 252 @Override 253 @SuppressWarnings("checkstyle:OverloadMethodsDeclarationOrder") 254 public String name() { 255 return projectName; 256 } 257 258 @Override 259 @SuppressWarnings("checkstyle:OverloadMethodsDeclarationOrder") 260 public Path directory() { 261 return projectDirectory; 262 } 263 264 @Override 265 public Project generator(Generator provider) { 266 if (this instanceof MergedTestProject) { 267 providers.put(provider, Consume); 268 } else { 269 providers.put(provider, Supply); 270 } 271 return this; 272 } 273 274 @Override 275 public Project dependency(Intent intent, ResourceProvider provider) { 276 providers.put(provider, intent); 277 return this; 278 } 279 280 /// Unlock access to providers. 281 /// 282 /* default */ void unlockProviders() { 283 providersUnlocked = true; 284 } 285 286 /* default */ Stream<ResourceProvider> dependencies(Set<Intent> intents) { 287 // Ordered evaluation 288 return List.of(Consume, Reveal, Supply, Expose, Forward).stream() 289 .filter(intents::contains).map(this::providersWithIntent) 290 .flatMap(s -> s); 291 } 292 293 private Stream<ResourceProvider> providersWithIntent(Intent intent) { 294 if (!providersUnlocked) { 295 throw new BuildException().message( 296 "Attempt to access dependencies of %s while" 297 + " invoking its constructor.", 298 this); 299 } 300 return providers.entrySet().stream() 301 .filter(e -> e.getValue() == intent).map(Entry::getKey); 302 } 303 304 @Override 305 public DefaultProviderSelection providers() { 306 return new DefaultProviderSelection(this); 307 } 308 309 @Override 310 public ProviderSelection providers(Set<Intent> intends) { 311 return new DefaultProviderSelection(this, intends); 312 } 313 314 @Override 315 @SuppressWarnings("unchecked") 316 public <T> T get(PropertyKey<T> property) { 317 return (T) Optional.ofNullable(properties.get(property)) 318 .orElseGet(() -> { 319 if (parent != null) { 320 return parent.get(property); 321 } 322 return property.defaultValue(); 323 }); 324 } 325 326 @Override 327 public <T> AbstractProject set(PropertyKey<T> property, T value) { 328 properties.put(property, value); 329 return this; 330 } 331 332 @Override 333 protected <T extends Resource> Collection<T> 334 doProvide(ResourceRequest<T> request) { 335 return providers().resources(request).toList(); 336 } 337 338 @SuppressWarnings("PMD.CommentRequired") 339 private static final class ProjectTreeSpliterator 340 extends AbstractSpliterator<Project> { 341 342 private Project next; 343 @SuppressWarnings("PMD.LooseCoupling") 344 private final Stack<Iterator<Project>> stack = new Stack<>(); 345 private final Set<Project> seen = new HashSet<>(); 346 347 /// Initializes a new project tree spliterator. 348 /// 349 /// @param root the root 350 /// 351 private ProjectTreeSpliterator(Project root) { 352 super(Long.MAX_VALUE, ORDERED | DISTINCT | IMMUTABLE | NONNULL); 353 this.next = root; 354 } 355 356 private Iterator<Project> children(Project project) { 357 return project.providers().select(EnumSet.allOf(Intent.class)) 358 .filter(p -> p instanceof Project).map(Project.class::cast) 359 .filter(p -> !seen.contains(p)) 360 .iterator(); 361 } 362 363 @Override 364 public boolean tryAdvance(Consumer<? super Project> action) { 365 if (next == null) { 366 return false; 367 } 368 action.accept(next); 369 seen.add(next); 370 var children = children(next); 371 if (children.hasNext()) { 372 next = children.next(); 373 stack.push(children); 374 return true; 375 } 376 while (!stack.isEmpty()) { 377 if (stack.peek().hasNext()) { 378 next = stack.peek().next(); 379 return true; 380 } 381 stack.pop(); 382 } 383 next = null; 384 return true; 385 } 386 } 387 388 /// Provide the projects matching the given ant-style path patterns. 389 /// 390 /// @param patterns the patterns 391 /// @param without the without 392 /// @return the stream 393 /// @see RootProject#projects(String[], String[]) 394 /// 395 @SuppressWarnings("PMD.UseVarargs") 396 public Stream<Project> projects(String[] patterns, String[] without) { 397 return StreamSupport.stream(new ProjectTreeSpliterator(this), false) 398 .filter(prj -> Arrays.stream(patterns) 399 .anyMatch(pattern -> pathMatcher.isMatch(pattern, 400 rootProject().directory().relativize(prj.directory()) 401 .toString()))) 402 .filter(prj -> !Arrays.stream(without) 403 .anyMatch(wo -> pathMatcher.isMatch(wo, 404 rootProject().directory().relativize(prj.directory()) 405 .toString()))); 406 } 407 408 @Override 409 public int hashCode() { 410 return Objects.hash(projectDirectory, projectName); 411 } 412 413 @Override 414 public boolean equals(Object obj) { 415 if (this == obj) { 416 return true; 417 } 418 if (obj == null) { 419 return false; 420 } 421 if (getClass() != obj.getClass()) { 422 return false; 423 } 424 AbstractProject other = (AbstractProject) obj; 425 return Objects.equals(projectDirectory, other.projectDirectory) 426 && Objects.equals(projectName, other.projectName); 427 } 428 429 @Override 430 public String toString() { 431 return "Project " + nameWithDirectory(); 432 } 433 434}