001/*
002 * JDrupes Builder
003 * Copyright (C) 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.ext.nodejs;
020
021import com.google.common.flogger.FluentLogger;
022import java.io.File;
023import java.io.IOException;
024import java.io.InputStream;
025import java.io.OutputStream;
026import java.lang.ProcessBuilder.Redirect;
027import java.nio.file.Path;
028import java.time.Instant;
029import java.util.ArrayList;
030import java.util.Arrays;
031import java.util.List;
032import java.util.Objects;
033import java.util.function.Function;
034import java.util.stream.Collectors;
035import java.util.stream.Stream;
036import org.jdrupes.builder.api.BuildException;
037import org.jdrupes.builder.api.Cleanliness;
038import org.jdrupes.builder.api.ExecResult;
039import org.jdrupes.builder.api.FileResource;
040import org.jdrupes.builder.api.FileTree;
041import org.jdrupes.builder.api.Project;
042import org.jdrupes.builder.api.Renamable;
043import org.jdrupes.builder.api.Resource;
044import org.jdrupes.builder.api.ResourceProvider;
045import org.jdrupes.builder.api.ResourceRequest;
046import org.jdrupes.builder.api.ResourceType;
047import static org.jdrupes.builder.api.ResourceType.*;
048import org.jdrupes.builder.api.Resources;
049import org.jdrupes.builder.core.AbstractProvider;
050import org.jdrupes.builder.core.StreamCollector;
051
052/// A provider for [execution results][ExecResult] from invoking npm.
053/// The provider generates resources in response to requests for
054/// [ExecResult] where the request's [ResourceRequest#name()] matches
055/// this [provider's name][ResourceProvider#name()].
056/// 
057///   * The provider first checks if a file `package.json` exists, else it
058///     fails. If no directory `node_modules` exists or `package.json`
059///     is newer than `node_modules/.package-lock.json` it invokes `npm init`.
060/// 
061///   * Then, the provider retrieves all resources added by [#required]. While
062///     the provider itself does not process these resources, it is assumed
063///     that they are processed by the `npm` command and therefore need to be
064///     available.
065/// 
066///   * If no arguments were specified, the provider returns an [ExecResult]
067///     that indicates successful invocation. The date of the result is set
068///     to the date of `node_modules/.package-lock.json`.
069/// 
070///   * The provider invokes the function configured with [#generated] and
071///     collects all resources. If the generated resources exist and no
072///     resource from `required` is newer then the generated resources found,
073///     the provider returns a result that indicates successful invocation.
074///     The date of the result is set to the newest date from the generated
075///     resources and the (existing) resources are attached.  
076/// 
077///   * Else, the provider invokes npm, calls the function set with
078///     `provided` again and adds the result to the [ExecResult] that
079///     it returns.
080///
081/// The provider also uses the function set with [#generated] to determine
082/// the resources to be removed when it is invoked with a request for
083/// [Cleanliness].
084/// 
085/// The generated resources can also be provided directly in response
086/// to a request, see [#provideResources(ResourceRequest)].
087/// 
088/// This provider is made available as an extension.
089/// [![org.jdrupes:jdbld-ext-nodejs:](
090/// https://img.shields.io/maven-central/v/org.jdrupes/jdbld-ext-nodejs?label=org.jdrupes:jdbld-ext-nodejs%3A)
091/// ](https://mvnrepository.com/artifact/org.jdrupes/jdbld-ext-nodejs)
092///
093public class NpmExecutor extends AbstractProvider implements Renamable {
094
095    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
096    private final Project project;
097    private final List<String> arguments = new ArrayList<>();
098    private final StreamCollector<Resource> requiredResources
099        = new StreamCollector<>(false);
100    private Function<Project, Stream<Resource>> getProvided
101        = _ -> Stream.empty();
102    private String nodeJsVersion;
103    private NodeJsDownloader nodeJsDownloader;
104    private ResourceRequest<?> requestForGenerated;
105
106    /// Initializes a new NPM executor.
107    ///
108    /// @param project the project
109    ///
110    public NpmExecutor(Project project) {
111        this.project = project;
112        rename(NpmExecutor.class.getSimpleName() + " in " + project);
113    }
114
115    /// Returns the project that this provider belongs to.
116    ///
117    /// @return the project
118    ///
119    public Project project() {
120        return project;
121    }
122
123    /// Name.
124    ///
125    /// @param name the name
126    /// @return the npm executor
127    ///
128    @Override
129    public NpmExecutor name(String name) {
130        rename(name);
131        return this;
132    }
133
134    /// Sets the node.js version to use. Setting a version is mandatory.
135    ///
136    /// @param version the version
137    /// @return the npm executor
138    ///
139    public NpmExecutor nodeJsVersion(String version) {
140        nodeJsVersion = version;
141        return this;
142    }
143
144    /// Add the given arguments.
145    ///
146    /// @param args the arguments
147    /// @return the npm executor
148    ///
149    public NpmExecutor args(String... args) {
150        arguments.addAll(Arrays.asList(args));
151        return this;
152    }
153
154    /// Add the given [Stream] of resources to the required resources.
155    ///
156    /// @param resources the resources
157    /// @return the npm executor
158    ///
159    public NpmExecutor required(Stream<Resource> resources) {
160        requiredResources.add(resources);
161        return this;
162    }
163
164    /// Convenience method to add a [FileTree] to the required resources.
165    /// If `root` is a relative path, it is resolved against the project's
166    /// directory. 
167    ///
168    /// @param root the root
169    /// @param pattern the pattern
170    /// @return the npm executor
171    ///
172    public NpmExecutor required(Path root, String pattern) {
173        requiredResources
174            .add(Stream.of(FileTree.of(project, root, pattern)));
175        return this;
176    }
177
178    /// Convenience method to add a [FileResource] to the required resources.
179    /// If `path` is relative, it is resolved against the project's directory. 
180    ///
181    /// @param root the root
182    /// @return the npm executor
183    ///
184    public NpmExecutor required(Path root) {
185        requiredResources.add(
186            Stream.of(FileResource.of(project.directory().resolve(root))));
187        return this;
188    }
189
190    /// Sets the function used to determine the resources generated by this
191    /// provider.
192    ///
193    /// @param resources the resources
194    /// @return the npm executor
195    ///
196    public NpmExecutor generated(
197            Function<Project, Stream<Resource>> resources) {
198        this.getProvided = resources;
199        return this;
200    }
201
202    /// Provide the generated resources in response to a request like
203    /// the given one.
204    ///
205    /// @param proto defines the kind of request that the npm executor
206    /// should respond to with the generated resources
207    /// @return the npm executor
208    ///
209    public NpmExecutor provideResources(ResourceRequest<?> proto) {
210        requestForGenerated = proto;
211        return this;
212    }
213
214    @Override
215    @SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.CognitiveComplexity",
216        "PMD.NcssCount" })
217    protected <T extends Resource> Stream<T>
218            doProvide(ResourceRequest<T> request) {
219        if (request.accepts(CleanlinessType)) {
220            getProvided.apply(project).forEach(Resource::cleanup);
221            return Stream.empty();
222        }
223
224        // Handle request for generated resources
225        if (requestForGenerated != null
226            && request.accepts(requestForGenerated.type())
227            && (requestForGenerated.name().isEmpty()
228                || Objects.equals(requestForGenerated.name().get(),
229                    request.name().orElse(null)))) {
230            // Always evaluate for most special type
231            if (!request.equals(requestForGenerated)) {
232                @SuppressWarnings({ "unchecked", "PMD.AvoidDuplicateLiterals" })
233                var result = (Stream<T>) resources(requestForGenerated);
234                return result;
235            }
236            return provideGenerated();
237        }
238
239        // Check for and handle request for execution result
240        if (!request.accepts(ExecResultType)
241            || !name().equals(request.name().orElse(null))) {
242            return Stream.empty();
243        }
244        // Always evaluate for most special type
245        if (!request.type().equals(ExecResultType)) {
246            @SuppressWarnings("unchecked")
247            var result = (Stream<T>) resources(of(ExecResultType)
248                .withName(name()));
249            return result;
250        }
251
252        // Check prerequisites
253        if (nodeJsVersion == null) {
254            throw new BuildException().from(this)
255                .message("No node.js version specified");
256        }
257        nodeJsDownloader = new NodeJsDownloader(this, context()
258            .commonCacheDirectory().resolve(getClass().getPackageName()));
259        File packageJson = project.directory().resolve("package.json").toFile();
260        if (!packageJson.canRead()) {
261            throw new BuildException().from(this)
262                .message("No package.json in %s", project);
263        }
264        File dotPackageLock = project.directory()
265            .resolve("node_modules/.package-lock.json").toFile();
266        if (!project.directory().resolve("node_modules").toFile().exists()
267            || !dotPackageLock.exists()
268            || packageJson.lastModified() > dotPackageLock.lastModified()) {
269            logger.atConfig().log("Updating node_modules in %s", project);
270            runNpm(project, List.of("install"));
271        }
272
273        // Make sure that the required resources are retrieved and exist
274        var required = Resources.of(new ResourceType<Resources<Resource>>() {});
275        required.addAll(requiredResources.stream());
276        if (arguments.isEmpty()) {
277            @SuppressWarnings("unchecked")
278            var result = (T) ExecResult
279                .of(this, "npm init", 0, Stream.empty())
280                .asOf(Instant.ofEpochMilli(dotPackageLock.lastModified()));
281            return Stream.of(result);
282        }
283
284        // Get (previously) provided and check if up-to-date
285        var provided = Resources.of(new ResourceType<Resources<Resource>>() {});
286        provided.addAll(getProvided.apply(project));
287        if (required.asOf().isPresent() && provided.asOf().isPresent()
288            && !required.asOf().get().isAfter(provided.asOf().get())) {
289            logger.atFine().log("Output from %s is up to date", this);
290            @SuppressWarnings("unchecked")
291            var result = (T) ExecResult.of(this,
292                "existing " + provided.stream().map(Resource::toString)
293                    .collect(Collectors.joining(", ")),
294                0, provided.stream()).asOf(provided.asOf().get());
295            return Stream.of(result);
296        }
297        return runNpm(project, arguments);
298    }
299
300    private <T extends Resource> Stream<T> runNpm(
301            Project project, List<String> arguments) {
302        var nodeJsExecutable = nodeJsDownloader.npmExecutable(nodeJsVersion);
303        logger.atFine().log("Running %s with %s", this, nodeJsExecutable);
304        List<String> command
305            = new ArrayList<>(List.of(nodeJsExecutable.toString()));
306        command.addAll(arguments);
307        ProcessBuilder processBuilder = new ProcessBuilder(command)
308            .directory(project.directory().toFile())
309            .redirectInput(Redirect.INHERIT);
310        try {
311            Process process = processBuilder.start();
312            copyData(process.getInputStream(), context().out());
313            copyData(process.getErrorStream(), context().error());
314            @SuppressWarnings("unchecked")
315            var result = (Stream<T>) Stream.of(ExecResult.of(this,
316                "[" + project.name() + "]$ npm "
317                    + arguments.stream().collect(Collectors.joining(" ")),
318                process.waitFor(), getProvided.apply(project))
319                .asOf(Instant.now()));
320            return result;
321        } catch (IOException | InterruptedException e) {
322            throw new BuildException().from(this).cause(e);
323        }
324    }
325
326    private void copyData(InputStream source, OutputStream sink) {
327        Thread.startVirtualThread(() -> {
328            try (source) {
329                source.transferTo(sink);
330            } catch (IOException e) { // NOPMD
331            }
332        });
333    }
334
335    private <T extends Resource> Stream<T> provideGenerated() {
336        // Request execution result
337        var execResult = resources(of(ExecResultType).withName(name()));
338        @SuppressWarnings("unchecked")
339
340        // Extract generated
341        var generated = execResult.map(r -> (Stream<T>) r.resources())
342            .flatMap(s -> s);
343        return generated;
344    }
345}