001/*
002 * JDrupes Builder
003 * Copyright (C) 2025 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.io.PrintStream;
022import java.nio.file.Path;
023import java.util.EnumSet;
024import java.util.LinkedList;
025import java.util.List;
026import java.util.Map;
027import java.util.Optional;
028import java.util.Properties;
029import java.util.concurrent.CompletableFuture;
030import java.util.concurrent.ConcurrentHashMap;
031import java.util.concurrent.ExecutorService;
032import java.util.concurrent.Executors;
033import java.util.concurrent.Future;
034import java.util.concurrent.atomic.AtomicBoolean;
035import java.util.stream.Collectors;
036import java.util.stream.Stream;
037import org.apache.commons.cli.CommandLine;
038import org.jdrupes.builder.api.BuildContext;
039import org.jdrupes.builder.api.BuildException;
040import org.jdrupes.builder.api.Intent;
041import org.jdrupes.builder.api.Project;
042import org.jdrupes.builder.api.Resource;
043import org.jdrupes.builder.api.ResourceProvider;
044import org.jdrupes.builder.api.ResourceRequest;
045import static org.jdrupes.builder.api.ResourceType.CleanlinessType;
046import org.jdrupes.builder.api.RootProject;
047import org.jdrupes.builder.api.StatusLine;
048import org.jdrupes.builder.core.console.SplitConsole;
049
050/// A context for building.
051///
052@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.TooManyMethods" })
053public class DefaultBuildContext implements BuildContext {
054
055    @SuppressWarnings("PMD.FieldNamingConventions")
056    private static final ScopedValue<AtomicBoolean> providerInvocationAllowed
057        = ScopedValue.newInstance();
058    private final FutureStreamCache cache;
059    private ExecutorService executor
060        = Executors.newVirtualThreadPerTaskExecutor();
061    private final Path buildRoot;
062    private final Properties jdbldProperties;
063    private final CommandLine commandLine;
064    private final AwaitableCounter executingFutureStreams
065        = new AwaitableCounter();
066    private final SplitConsole console;
067    @SuppressWarnings("PMD.FieldNamingConventions")
068    private static final ScopedValue<
069            DefaultBuildContext> scopedBuildContext = ScopedValue.newInstance();
070    private final CompletableFuture<AbstractRootProject> buildProject
071        = new CompletableFuture<>();
072    @SuppressWarnings("PMD.FieldNamingConventions")
073    private static final ScopedValue<CallChainLink> callChainEnd
074        = ScopedValue.newInstance();
075    private final Map<ProviderInvocation<?>, CallChainLink> callChains
076        = new ConcurrentHashMap<>();
077
078    static {
079        ScopedValueContext.add(scopedBuildContext, callChainEnd);
080    }
081
082    /// A link in the call chain.
083    ///
084    /// @param previous the previous
085    /// @param invocation the invocation
086    ///
087    public record CallChainLink(CallChainLink previous,
088            ProviderInvocation<?> invocation) {
089    }
090
091    /// Instantiates a new default build. By default, the build uses
092    /// a virtual thread per task executor.
093    ///
094    /* default */ DefaultBuildContext(Path buildRoot,
095            Properties jdbldProperties, CommandLine commandLine) {
096        this.buildRoot = buildRoot;
097        this.jdbldProperties = jdbldProperties;
098        this.commandLine = commandLine;
099        cache = new FutureStreamCache();
100        console = SplitConsole.open();
101    }
102
103    /// Returns the executor service used by this build to create futures.
104    ///
105    /// @return the executor service
106    ///
107    public ExecutorService executor() {
108        return executor;
109    }
110
111    /// Sets the executor service used by this build to create futures.
112    ///
113    /// @param executor the executor
114    ///
115    public void executor(ExecutorService executor) {
116        this.executor = executor;
117    }
118
119    /// Executing future streams.
120    ///
121    /// @return the awaitable counter
122    ///
123    public AwaitableCounter executingFutureStreams() {
124        return executingFutureStreams;
125    }
126
127    /// Returns the build root.
128    ///
129    /// @return the path
130    ///
131    public Path buildRoot() {
132        return buildRoot;
133    }
134
135    @Override
136    public CommandLine commandLine() {
137        return commandLine;
138    }
139
140    @Override
141    public String property(String name, String defaultValue) {
142        return jdbldProperties.getProperty(name,
143            defaultValue);
144    }
145
146    /// Returns the context.
147    ///
148    /// @return the optional
149    ///
150    public static Optional<DefaultBuildContext> context() {
151        if (scopedBuildContext.isBound()) {
152            return Optional.of(scopedBuildContext.get());
153        }
154        return Optional.empty();
155    }
156
157    /// Return a carrier with this context available from [#context].
158    ///
159    /// @return the carrier
160    ///
161    public ScopedValue.Carrier inScope() {
162        return ScopedValue.where(scopedBuildContext, this);
163    }
164
165    /// Return a carrier with this context available from [#context] and
166    /// the provider invocation allowed flag set.
167    ///
168    /// @param carrier the carrier
169    /// @return the augmented carrier
170    ///
171    /* default */ ScopedValue.Carrier inScopeForProviderCall() {
172        return inScope()
173            .where(providerInvocationAllowed, new AtomicBoolean(true));
174    }
175
176    /* default */ SplitConsole console() {
177        return console;
178    }
179
180    @Override
181    public StatusLine statusLine() {
182        return FutureStream.statusLine.orElse(SplitConsole.nullStatusLine());
183    }
184
185    @Override
186    public PrintStream out() {
187        return console().out();
188    }
189
190    @Override
191    public PrintStream error() {
192        return console().err();
193    }
194
195    @Override
196    public <T extends Resource> Stream<T> resources(ResourceProvider provider,
197            ResourceRequest<T> request) {
198        // Normalize request, non-project providers don't get intends
199        var invocation = new ProviderInvocation<>(provider,
200            provider instanceof Project || request.uses().isEmpty() ? request
201                : request.using(EnumSet.noneOf(Intent.class)));
202        return ScopedValue.where(scopedBuildContext, this)
203            .where(providerInvocationAllowed, new AtomicBoolean(true))
204            .where(callChainEnd, new CallChainLink(
205                callChainEnd.isBound() ? callChainEnd.get()
206                    : new CallChainLink(null, ProviderInvocation.LAUNCH),
207                invocation))
208            .call(() -> inResourcesContext(invocation));
209    }
210
211    @SuppressWarnings({ "PMD.AvoidSynchronizedStatement",
212        "PMD.AvoidInstantiatingObjectsInLoops" })
213    private <T extends Resource> Stream<T> inResourcesContext(
214            ProviderInvocation<T> invocation) {
215        var prev = callChainEnd().previous;
216        while (prev != null) {
217            if (invocation.equals(prev.invocation())) {
218                throw new BuildException().message("Request loop: %s",
219                    callChain().stream().map(ProviderInvocation::toString)
220                        .collect(Collectors.joining(" ≪ ")));
221            }
222            prev = prev.previous;
223        }
224        callChains.put(invocation, callChainEnd());
225        if (invocation.provider() instanceof Project) {
226            // As a project's provide only delegates to other providers
227            // it is inefficient to invoke it asynchronously. Besides, it
228            // leads to recursive invocations of the project's deploy
229            // method too easily and results in a loop detection without
230            // there really being a loop.
231            return ((AbstractProvider) invocation.provider()).toSpi()
232                .provide(invocation.request());
233        }
234        if (!invocation.request().type().equals(CleanlinessType)) {
235            return cache.computeIfAbsent(
236                invocation, k -> new FutureStream<T>(this, k)).stream();
237        }
238
239        // Special handling for cleanliness. Clean one by one...
240        synchronized (executor) {
241            // Await completion of all generating threads
242            try {
243                executingFutureStreams().await(0);
244            } catch (InterruptedException e) {
245                throw new BuildException().cause(e);
246            }
247        }
248        var result = ((AbstractProvider) invocation.provider()).toSpi()
249            .provide(invocation.request());
250        // Purge cached results from provider
251        cache.purge(invocation.provider());
252        return result;
253    }
254
255    /// Returns the end of the current call chain.
256    ///
257    /// @return the call chain link
258    ///
259    /* default */ CallChainLink callChainEnd() {
260        return callChainEnd
261            .orElseThrow(() -> new IllegalStateException("No call chain"));
262    }
263
264    /* default */ List<ProviderInvocation<?>> callChain() {
265        var cur = callChainEnd();
266        List<ProviderInvocation<?>> result = new LinkedList<>();
267        while (cur != null) {
268            result.add(cur.invocation);
269            cur = cur.previous;
270        }
271        return result;
272    }
273
274    /* default */ List<ProviderInvocation<?>>
275            callChain(ProviderInvocation<?> running) {
276        var cur = callChains.get(running);
277        List<ProviderInvocation<?>> result = new LinkedList<>();
278        while (cur != null) {
279            result.add(cur.invocation);
280            cur = cur.previous;
281        }
282        return result;
283    }
284
285    /// Checks if is provider invocation is allowed. Clears the
286    /// allowed flag to also detect nested invocations.
287    ///
288    /// @return true, if is provider invocation allowed
289    ///
290    public static boolean isProviderInvocationAllowed() {
291        return providerInvocationAllowed.isBound()
292            && providerInvocationAllowed.get().getAndSet(false);
293    }
294
295    @Override
296    public void close() {
297        executor.shutdownNow();
298        console.close();
299    }
300
301    /* default */ Future<AbstractRootProject> buildProject() {
302        return buildProject;
303    }
304
305    /// Creates and initializes the root project and the sub projects.
306    /// Adds the sub projects to the root project automatically. This
307    /// method should be used if the launcher detects the sub projects
308    /// e.g. by reflection and the root project does not add its sub
309    /// projects itself.
310    ///
311    /// @param buildRoot the build root
312    /// @param rootProject the root project
313    /// @param subprojects the sub projects
314    /// @param jdbldProps the builder properties
315    /// @param commandLine the command line
316    /// @return the root project
317    ///
318    public static AbstractRootProject createProjects(
319            Path buildRoot, Class<? extends RootProject> rootProject,
320            List<Class<? extends Project>> subprojects,
321            Properties jdbldProps, CommandLine commandLine) {
322        try {
323            return ScopedValue
324                .where(scopedBuildContext,
325                    new DefaultBuildContext(buildRoot, jdbldProps, commandLine))
326                .call(() -> {
327                    var result = (AbstractRootProject) rootProject
328                        .getConstructor().newInstance();
329                    result.unlockProviders();
330                    subprojects.forEach(result::project);
331                    scopedBuildContext.get().buildProject.complete(result);
332                    return result;
333                });
334        } catch (SecurityException | NegativeArraySizeException
335                | IllegalArgumentException | ReflectiveOperationException e) {
336            throw new IllegalArgumentException(e);
337        }
338    }
339}