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