NashornCompatibilityStrategy.java

/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.apache.maven.cling.invoker.mvnup.goals;

import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Stream;

import eu.maveniverse.domtrip.Document;
import eu.maveniverse.domtrip.Element;
import org.apache.maven.api.cli.mvnup.UpgradeOptions;
import org.apache.maven.api.di.Named;
import org.apache.maven.api.di.Priority;
import org.apache.maven.api.di.Singleton;
import org.apache.maven.cling.invoker.mvnup.UpgradeContext;

import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.ARTIFACT_ID;
import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.BUILD;
import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.DEPENDENCIES;
import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.GROUP_ID;
import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PLUGIN;
import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PLUGINS;
import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PLUGIN_MANAGEMENT;
import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PROFILE;
import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PROFILES;
import static eu.maveniverse.domtrip.maven.MavenPomElements.Plugins.DEFAULT_MAVEN_PLUGIN_GROUP_ID;

/**
 * Strategy for injecting the standalone OpenJDK Nashorn engine as a plugin dependency
 * when {@code maven-antrun-plugin} uses inline JavaScript via {@code <script language="javascript">}.
 *
 * <p>Maven 4 requires JDK 17+, but the built-in Nashorn JavaScript engine was removed in JDK 15.
 * Plugins executing JavaScript in-process (particularly {@code maven-antrun-plugin} with
 * {@code <script language="javascript">}) fail with:
 * <pre>
 * [ERROR] Unable to create javax script engine for javascript
 * </pre>
 *
 * <p>Toolchains cannot help because the script runs in Maven's own JVM, not in a forked process.
 * The standalone OpenJDK Nashorn ({@code org.openjdk.nashorn:nashorn-core}) provides a drop-in
 * replacement that registers via {@code ServiceLoader} and restores JavaScript support.
 *
 * <p>This strategy both <strong>injects</strong> the Nashorn dependency as a quick fix and
 * <strong>warns</strong> that users should consider migrating to GraalVM JavaScript
 * ({@code org.graalvm.js:js-scriptengine}) for long-term support.
 *
 * @see <a href="https://github.com/apache/maven/issues/12988">#12988</a>
 */
@Named
@Singleton
@Priority(19)
public class NashornCompatibilityStrategy extends AbstractUpgradeStrategy {

    static final String ANTRUN_ARTIFACT_ID = "maven-antrun-plugin";
    static final String NASHORN_GROUP_ID = "org.openjdk.nashorn";
    static final String NASHORN_ARTIFACT_ID = "nashorn-core";
    static final String NASHORN_VERSION = "15.7";

    private static final String SCRIPT = "script";
    private static final String LANGUAGE = "language";
    private static final String JAVASCRIPT = "javascript";

    @Override
    public boolean isApplicable(UpgradeContext context) {
        UpgradeOptions options = getOptions(context);

        // Handle --all option (overrides individual options)
        boolean useAll = options.all().orElse(false);
        if (useAll) {
            return true;
        }

        // Apply default behavior: if no specific options are provided, enable by default
        boolean noOptionsSpecified = options.all().isEmpty()
                && options.infer().isEmpty()
                && options.model().isEmpty()
                && options.plugins().isEmpty()
                && options.modelVersion().isEmpty();

        boolean allOptionsDisabled = options.all().map(v -> !v).orElse(false)
                && options.infer().map(v -> !v).orElse(false)
                && options.model().map(v -> !v).orElse(false)
                && options.plugins().map(v -> !v).orElse(false)
                && options.modelVersion().isEmpty();

        if (noOptionsSpecified || allOptionsDisabled) {
            return true;
        }

        // Check if --model is explicitly set (this is a compatibility fix)
        if (options.model().isPresent()) {
            return options.model().get();
        }

        return false;
    }

    @Override
    public String getDescription() {
        return "Injecting standalone Nashorn for antrun JavaScript compatibility";
    }

    @Override
    protected UpgradeResult doApply(UpgradeContext context, Map<Path, Document> pomMap) {
        Set<Path> processedPoms = new HashSet<>();
        Set<Path> modifiedPoms = new HashSet<>();
        Set<Path> errorPoms = new HashSet<>();

        for (Map.Entry<Path, Document> entry : pomMap.entrySet()) {
            Path pomPath = entry.getKey();
            Document pomDocument = entry.getValue();
            processedPoms.add(pomPath);

            context.info(pomPath + " (checking for antrun JavaScript usage)");
            context.indent();

            try {
                boolean modified = injectNashornForAntrunJavaScript(pomDocument, context);

                if (modified) {
                    context.success("Injected standalone Nashorn dependency for antrun JavaScript");
                    modifiedPoms.add(pomPath);
                } else {
                    context.success("No antrun JavaScript usage found");
                }
            } catch (Exception e) {
                context.failure("Failed to process antrun JavaScript compatibility: " + e.getMessage());
                errorPoms.add(pomPath);
            } finally {
                context.unindent();
            }
        }

        return new UpgradeResult(processedPoms, modifiedPoms, errorPoms);
    }

    /**
     * Scans all plugin declarations for maven-antrun-plugin with JavaScript scripts
     * and injects the standalone Nashorn dependency where needed.
     *
     * @return true if any modifications were made
     */
    private boolean injectNashornForAntrunJavaScript(Document pomDocument, UpgradeContext context) {
        Element root = pomDocument.root();
        List<Element> antrunPlugins = findAntrunPluginsWithJavaScript(root);

        boolean modified = false;
        for (Element pluginElement : antrunPlugins) {
            if (!hasNashornDependency(pluginElement)) {
                context.warning("maven-antrun-plugin uses inline JavaScript which requires a standalone"
                        + " script engine on JDK 17+. Adding org.openjdk.nashorn:nashorn-core as a"
                        + " quick fix. Consider migrating to GraalVM JavaScript"
                        + " (org.graalvm.js:js-scriptengine + org.graalvm.js:js) for long-term support.");
                addNashornDependency(pluginElement, context);
                modified = true;
            } else {
                context.detail("Nashorn dependency already present on maven-antrun-plugin");
            }
        }

        return modified;
    }

    /**
     * Finds all maven-antrun-plugin declarations that contain JavaScript script executions.
     * Searches in build/plugins, build/pluginManagement/plugins, and profile builds.
     */
    List<Element> findAntrunPluginsWithJavaScript(Element root) {
        List<Element> result = new ArrayList<>();

        // Collect all plugin containers: build/plugins, build/pluginManagement/plugins,
        // and the same within profiles
        Stream<Element> pluginContainers = Stream.concat(
                root.childElement(BUILD).stream()
                        .flatMap(build -> Stream.concat(
                                build.childElement(PLUGINS).stream(),
                                build.childElement(PLUGIN_MANAGEMENT).stream()
                                        .flatMap(pm -> pm.childElement(PLUGINS).stream()))),
                root.childElement(PROFILES).stream()
                        .flatMap(profiles -> profiles.childElements(PROFILE))
                        .flatMap(profile -> profile.childElement(BUILD).stream())
                        .flatMap(build -> Stream.concat(
                                build.childElement(PLUGINS).stream(),
                                build.childElement(PLUGIN_MANAGEMENT).stream()
                                        .flatMap(pm -> pm.childElement(PLUGINS).stream()))));

        pluginContainers.forEach(pluginsElement -> pluginsElement
                .childElements(PLUGIN)
                .forEach(pluginElement -> {
                    if (isAntrunPlugin(pluginElement) && containsJavaScriptScript(pluginElement)) {
                        result.add(pluginElement);
                    }
                }));

        return result;
    }

    /**
     * Checks if a plugin element is maven-antrun-plugin.
     */
    static boolean isAntrunPlugin(Element pluginElement) {
        String artifactId = pluginElement.childText(ARTIFACT_ID);
        if (!ANTRUN_ARTIFACT_ID.equals(artifactId)) {
            return false;
        }

        String groupId = pluginElement.childText(GROUP_ID);
        // If groupId is absent, Maven defaults to org.apache.maven.plugins
        return groupId == null || DEFAULT_MAVEN_PLUGIN_GROUP_ID.equals(groupId);
    }

    /**
     * Recursively searches a plugin element for {@code <script language="javascript">} elements.
     * The script element can appear at any depth within the plugin's configuration or executions.
     */
    static boolean containsJavaScriptScript(Element element) {
        // Check if this element is a <script language="javascript">
        if (SCRIPT.equals(element.name())) {
            String lang = element.attribute(LANGUAGE);
            if (lang != null && JAVASCRIPT.equalsIgnoreCase(lang)) {
                return true;
            }
        }

        // Recursively check children
        return element.childElements().anyMatch(NashornCompatibilityStrategy::containsJavaScriptScript);
    }

    /**
     * Checks if the antrun plugin already has nashorn-core as a dependency.
     */
    static boolean hasNashornDependency(Element pluginElement) {
        return pluginElement
                .childElement(DEPENDENCIES)
                .map(deps -> deps.childElements("dependency").anyMatch(dep -> {
                    String gid = dep.childText(GROUP_ID);
                    String aid = dep.childText(ARTIFACT_ID);
                    return NASHORN_GROUP_ID.equals(gid) && NASHORN_ARTIFACT_ID.equals(aid);
                }))
                .orElse(false);
    }

    /**
     * Adds the standalone Nashorn dependency to the antrun plugin element.
     */
    private void addNashornDependency(Element pluginElement, UpgradeContext context) {
        Element dependenciesElement = pluginElement
                .childElement(DEPENDENCIES)
                .orElseGet(() -> DomUtils.insertNewElement(DEPENDENCIES, pluginElement));

        DomUtils.createDependency(dependenciesElement, NASHORN_GROUP_ID, NASHORN_ARTIFACT_ID, NASHORN_VERSION);

        context.detail("Added " + NASHORN_GROUP_ID + ":" + NASHORN_ARTIFACT_ID + ":" + NASHORN_VERSION
                + " as plugin dependency of " + ANTRUN_ARTIFACT_ID);
    }
}