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.eclipse;
020
021import java.io.IOException;
022import java.nio.file.Files;
023import java.nio.file.Path;
024import java.util.Collection;
025import java.util.Collections;
026import java.util.HashSet;
027import java.util.List;
028import java.util.Set;
029import javax.xml.parsers.ParserConfigurationException;
030import org.eclipse.aether.repository.RemoteRepository;
031import org.jdrupes.builder.api.BuildException;
032import org.jdrupes.builder.api.ConfigurationException;
033import static org.jdrupes.builder.api.Intent.Consume;
034import static org.jdrupes.builder.api.Intent.Expose;
035import static org.jdrupes.builder.api.Intent.Forward;
036import static org.jdrupes.builder.api.Intent.Reveal;
037import static org.jdrupes.builder.api.Intent.Supply;
038import org.jdrupes.builder.api.MergedTestProject;
039import org.jdrupes.builder.api.Project;
040import org.jdrupes.builder.api.Resource;
041import org.jdrupes.builder.api.ResourceRequest;
042import org.jdrupes.builder.api.ResourceType;
043import org.jdrupes.builder.api.Resources;
044import org.jdrupes.builder.java.ClassTree;
045import org.jdrupes.builder.java.JavaCompiler;
046import static org.jdrupes.builder.java.JavaTypes.CodeContributionType;
047import static org.jdrupes.builder.java.JavaTypes.LibraryJarFileType;
048import org.jdrupes.builder.mvnrepo.MvnRepoJarFile;
049import org.jdrupes.builder.mvnrepo.MvnRepoLibraryJarFile;
050import org.jdrupes.builder.mvnrepo.MvnRepoLookup;
051import static org.jdrupes.builder.mvnrepo.MvnRepoTypes.MvnRepoResourceType;
052import org.w3c.dom.Document;
053import org.w3c.dom.Element;
054import org.w3c.dom.Node;
055import org.xml.sax.SAXException;
056
057/// The Class LaunchGenerator.
058///
059public class LaunchGenerator {
060
061    private final EclipseConfigurator configurator;
062
063    /// Initializes a new launch generator.
064    ///
065    /// @param configurator the eclipse configurator
066    ///
067    public LaunchGenerator(EclipseConfigurator configurator) {
068        this.configurator = configurator;
069    }
070
071    @SuppressWarnings("PMD.ShortMethodName")
072    private <T extends Resource> ResourceRequest<T>
073            of(ResourceType<? extends T> type) {
074        return configurator.of(type);
075    }
076
077    private Project project() {
078        return configurator.project();
079    }
080
081    /// Provide the launch configuration.
082    ///
083    /// @param <T> the generic type
084    /// @param requested the requested
085    /// @return the collection
086    ///
087    @SuppressWarnings({ "unchecked", "PMD.ConfusingTernary" })
088    public <T extends Resource> Collection<T> provideLauncher(
089            ResourceRequest<T> requested) {
090        // Generate nothing for test projects.
091        if (project() instanceof MergedTestProject
092            || !(requested instanceof EclipseLauncherRequest)) {
093            return Collections.emptyList();
094        }
095
096        var launcherName = requested.name().orElseThrow(
097            () -> new ConfigurationException().message(
098                "EclipseLauncherRequest must have a name"));
099
100        @SuppressWarnings("PMD.AvoidDuplicateLiterals")
101        var launchFile
102            = project().directory().resolve(launcherName + ".launch");
103        var request = (EclipseLauncherRequest) requested;
104
105        if (!Files.exists(launchFile)) {
106            configurator.generateXmlFile(
107                doc -> generateLaunchConfiguration(doc, request),
108                launcherName + ".launch");
109        } else {
110            configurator.generateXmlFile(doc -> {
111                var existing = parseExisting(launchFile);
112                generateLaunchConfiguration(doc, request);
113                mergeSettings(existing, doc);
114                return existing;
115            }, launcherName + ".launch");
116        }
117
118        // Create result
119        return (Collection<T>) List.of(EclipseLauncher.of(
120            project().directory().resolve(launcherName + ".launch")));
121    }
122
123    private Document parseExisting(Path path) {
124        try {
125            return configurator.documentBuilderFactory().newDocumentBuilder()
126                .parse(path.toFile());
127        } catch (ParserConfigurationException | SAXException | IOException e) {
128            throw new BuildException().from(configurator).cause(e);
129        }
130    }
131
132    /// Generates the launch configuration XML into the given document.
133    ///
134    /// @param doc an empty document as starting point
135    /// @param request the request
136    /// @return the generated document
137    ///
138    @SuppressWarnings("PMD.AvoidDuplicateLiterals")
139    protected Document generateLaunchConfiguration(Document doc,
140            EclipseLauncherRequest request) {
141        var mainClass = request.mainClass();
142        if (mainClass == null) {
143            throw new ConfigurationException().from(configurator)
144                .message("mainClass is required for %s", request.name().get());
145        }
146        var launchConfig = (Element) doc.appendChild(
147            doc.createElement("launchConfiguration"));
148        launchConfig.setAttribute("type",
149            "org.eclipse.jdt.launching.localJavaApplication");
150
151        // Debug core attributes
152        addStringAttribute(doc, launchConfig,
153            "org.eclipse.debug.core.source_locator_id",
154            "org.eclipse.jdt.launching"
155                + ".sourceLocator.JavaAdvancedSourceLookupDirector");
156        addBooleanAttribute(doc, launchConfig,
157            "org.eclipse.debug.core.ATTR_FORCE_SYSTEM_CONSOLE_ENCODING",
158            "false");
159
160        // JDT launching attributes
161        addBooleanAttribute(doc, launchConfig,
162            "org.eclipse.jdt.launching.ATTR_ATTR_USE_ARGFILE", "false");
163        addBooleanAttribute(doc, launchConfig,
164            "org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE", "true");
165        addBooleanAttribute(doc, launchConfig,
166            "org.eclipse.jdt.launching.ATTR_SHOW_CODEDETAILS"
167                + "_IN_EXCEPTION_MESSAGES",
168            "true");
169        addBooleanAttribute(doc, launchConfig,
170            "org.eclipse.jdt.launching.ATTR_USE_CLASSPATH_ONLY_JAR", "false");
171
172        // Classpath
173        generateLaunchClasspath(doc, launchConfig);
174        addBooleanAttribute(doc, launchConfig,
175            "org.eclipse.jdt.launching.DEFAULT_CLASSPATH", "false");
176
177        // JRE
178        addJreToLaunch(doc, launchConfig);
179
180        // Main class
181        addStringAttribute(doc, launchConfig,
182            "org.eclipse.jdt.launching.MAIN_TYPE", mainClass);
183
184        // Module path
185        addModulePath(doc, launchConfig);
186
187        // Program arguments
188        if (request.arguments() != null && !request.arguments().isBlank()) {
189            addStringAttribute(doc, launchConfig,
190                "org.eclipse.jdt.launching.PROGRAM_ARGUMENTS",
191                request.arguments());
192        }
193
194        // Project attribute
195        addStringAttribute(doc, launchConfig,
196            "org.eclipse.jdt.launching.PROJECT_ATTR",
197            configurator.eclipseAlias());
198
199        // VM arguments
200        if (request.vmArguments() != null
201            && !request.vmArguments().isBlank()) {
202            addStringAttribute(doc, launchConfig,
203                "org.eclipse.jdt.launching.VM_ARGUMENTS",
204                request.vmArguments());
205        }
206
207        // Working directory
208        addStringAttribute(doc, launchConfig,
209            "org.eclipse.jdt.launching.WORKING_DIRECTORY",
210            "${workspace_loc:" + configurator.eclipseAlias() + "}");
211
212        // Return updated document
213        return doc;
214    }
215
216    @SuppressWarnings("PMD.AvoidDuplicateLiterals")
217    private void generateLaunchClasspath(Document doc, Node parent) {
218        var classpathAttr = (Element) parent
219            .appendChild(doc.createElement("listAttribute"));
220        classpathAttr.setAttribute("key",
221            "org.eclipse.jdt.launching.CLASSPATH");
222
223        // Add JRE container (type 4)
224        var javaCompiler = project().providers()
225            .select(Consume, Reveal, Supply)
226            .filter(p -> p instanceof JavaCompiler)
227            .map(JavaCompiler.class::cast).findFirst();
228        var version = javaCompiler
229            .map(jc -> jc.optionArgument("-target", "--target", "--release"))
230            .map(opt -> opt.orElse("25"))
231            .orElse("25");
232        addListEntry(doc, classpathAttr,
233            "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n"
234                + "<runtimeClasspathEntry "
235                + "containerPath=\"org.eclipse.jdt.launching.JRE_CONTAINER"
236                + "/org.eclipse.jdt.internal.debug.ui.launcher"
237                + ".StandardVMType/JavaSE-" + version + "\""
238                + " path=\"5\" type=\"4\"/>");
239
240        // Add project dependencies
241        addListEntry(doc, classpathAttr, "<?xml version=\"1.0\""
242            + " encoding=\"UTF-8\" standalone=\"no\"?>\n"
243            + "<runtimeClasspathEntry projectName=\""
244            + configurator.refName(project()) + "\" "
245            + "path=\"5\" type=\"1\"/>");
246        project().providers().filter(p -> p instanceof Project)
247            .select(Consume, Reveal, Expose, Forward)
248            .toList().stream().map(Project.class::cast).forEach(p -> {
249                addListEntry(doc, classpathAttr, "<?xml version=\"1.0\""
250                    + " encoding=\"UTF-8\" standalone=\"no\"?>\n"
251                    + "<runtimeClasspathEntry "
252                    + "projectName=\"" + configurator.refName(p) + "\" "
253                    + "path=\"5\" type=\"1\"/>");
254            });
255
256        // Add library jars
257        var cpes = Resources.with(LibraryJarFileType);
258        Set<RemoteRepository> repos = new HashSet<>();
259        var repoRefs = Resources.with(MvnRepoResourceType);
260        project().resources(of(LibraryJarFileType)
261            .usingAll()).forEach(r -> {
262                if (r instanceof MvnRepoJarFile repoJar) {
263                    repoRefs.add(repoJar.reference());
264                    repos.addAll(repoJar.repositories());
265                } else {
266                    cpes.add(r);
267                }
268            });
269        // Jar files from maven repositories must be resolved before
270        // they can be added to the application to avoid duplicates.
271        var lookup = new MvnRepoLookup().name(configurator.name())
272            .addRepositories(repos.toArray(RemoteRepository[]::new));
273        lookup.resolve(repoRefs.stream());
274        project().context()
275            .resources(lookup, of(CodeContributionType)
276                .using(Consume, Reveal, Supply, Expose))
277            .forEach(cpe -> {
278                if (cpe instanceof MvnRepoLibraryJarFile jarFile) {
279                    cpes.add(jarFile);
280                }
281            });
282
283        cpes.stream().forEach(jf -> {
284            var jarPath = jf.path().toString();
285            addListEntry(doc, classpathAttr, "<?xml version=\"1.0\""
286                + " encoding=\"UTF-8\" standalone=\"no\"?>\n"
287                + "<runtimeClasspathEntry "
288                + "externalArchive=\"" + escapeXml(jarPath) + "\" "
289                + "path=\"5\" type=\"2\"/>");
290        });
291    }
292
293    private void addJreToLaunch(Document doc, Node parent) {
294        var javaCompiler = project().providers()
295            .select(Consume, Reveal, Supply)
296            .filter(p -> p instanceof JavaCompiler)
297            .map(JavaCompiler.class::cast).findFirst();
298        var version = javaCompiler
299            .map(jc -> jc.optionArgument("-target", "--target", "--release"))
300            .map(opt -> opt.orElse("25"))
301            .orElse("25");
302        addStringAttribute(doc, parent,
303            "org.eclipse.jdt.launching.JRE_CONTAINER",
304            "org.eclipse.jdt.launching.JRE_CONTAINER"
305                + "/org.eclipse.jdt.internal.debug.ui.launcher"
306                + ".StandardVMType/JavaSE-" + version);
307    }
308
309    private void addModulePath(Document doc, Node parent) {
310        var modulePath = (Element) parent
311            .appendChild(doc.createElement("listAttribute"));
312        modulePath.setAttribute("key",
313            "org.eclipse.jdt.launching.MODULEPATH");
314
315        var isModular = project()
316            .resources(of(CodeContributionType).using(Supply))
317            .filter(cc -> cc instanceof ClassTree).map(cc -> (ClassTree) cc)
318            .filter(cc -> cc.isModular()).findAny().isPresent();
319
320        if (isModular) {
321            // Check for modular project dependencies
322            project().providers().filter(p -> p instanceof Project)
323                .select(Consume, Reveal, Expose, Forward)
324                .toList().stream().map(Project.class::cast).forEach(p -> {
325                    if (p
326                        .resources(
327                            of(CodeContributionType).using(Supply))
328                        .filter(cc -> cc instanceof ClassTree)
329                        .map(cc -> (ClassTree) cc)
330                        .filter(cc -> cc.isModular()).findAny().isPresent()) {
331                        addListEntry(doc, modulePath,
332                            "<runtimeClasspathEntry "
333                                + "path=\"5\" projectName=\""
334                                + configurator.refName(p)
335                                + "\" type=\"1\"/>");
336                    }
337                });
338
339            // Set module name
340            var moduleName = project()
341                .resources(of(CodeContributionType)
342                    .using(Supply))
343                .filter(cc -> cc instanceof ClassTree)
344                .map(cc -> (ClassTree) cc)
345                .filter(cc -> cc.isModular()).findAny()
346                .flatMap(ClassTree::moduleName).orElse(null);
347            if (moduleName != null) {
348                addStringAttribute(doc, parent,
349                    "org.eclipse.jdt.launching.MODULE_NAME", moduleName);
350            }
351        }
352    }
353
354    @SuppressWarnings("PMD.AvoidDuplicateLiterals")
355    private void addStringAttribute(Document doc, Node parent,
356            String key, String value) {
357        var attr = (Element) parent
358            .appendChild(doc.createElement("stringAttribute"));
359        attr.setAttribute("key", key);
360        attr.setAttribute("value", value);
361    }
362
363    private void addBooleanAttribute(Document doc, Node parent,
364            String key, String value) {
365        var attr = (Element) parent
366            .appendChild(doc.createElement("booleanAttribute"));
367        attr.setAttribute("key", key);
368        attr.setAttribute("value", value);
369    }
370
371    private void addListEntry(Document doc, Node parent, String value) {
372        var entry = (Element) parent
373            .appendChild(doc.createElement("listEntry"));
374        entry.setAttribute("value", value);
375    }
376
377    private String escapeXml(String str) {
378        return str.replace("&", "&amp;")
379            .replace("<", "&lt;")
380            .replace(">", "&gt;")
381            .replace("\"", "&quot;")
382            .replace("'", "&apos;");
383    }
384
385    /// Updates the classpath and modulepath in the target document
386    /// from a freshly generated source document.
387    ///
388    /// @param target the existing document to update
389    /// @param source the freshly generated document
390    ///
391    protected void mergeSettings(Document target, Document source) {
392        replaceListAttribute(target, source,
393            "org.eclipse.jdt.launching.CLASSPATH");
394        replaceListAttribute(target, source,
395            "org.eclipse.jdt.launching.MODULEPATH");
396        replaceStringAttribute(target, source,
397            "org.eclipse.jdt.launching.PROGRAM_ARGUMENTS");
398        replaceStringAttribute(target, source,
399            "org.eclipse.jdt.launching.VM_ARGUMENTS");
400    }
401
402    private void replaceListAttribute(Document target, Document source,
403            String key) {
404        var targetNode = findListAttribute(target, key);
405        var sourceNode = findListAttribute(source, key);
406        if (sourceNode == null) {
407            return;
408        }
409        if (targetNode != null) {
410            var imported = target.importNode(sourceNode, true);
411            targetNode.getParentNode().replaceChild(imported, targetNode);
412        } else {
413            target.getDocumentElement().appendChild(
414                target.importNode(sourceNode, true));
415        }
416    }
417
418    private void replaceStringAttribute(Document target, Document source,
419            String key) {
420        var targetNode = findStringAttribute(target, key);
421        var sourceNode = findStringAttribute(source, key);
422        if (sourceNode == null) {
423            return;
424        }
425        if (targetNode != null) {
426            ((Element) targetNode).setAttribute("value",
427                ((Element) sourceNode).getAttribute("value"));
428        } else {
429            target.getDocumentElement().appendChild(
430                target.importNode(sourceNode, true));
431        }
432    }
433
434    private Node findListAttribute(Document doc, String key) {
435        var root = doc.getDocumentElement();
436        var children = root.getChildNodes();
437        for (int i = 0; i < children.getLength(); i++) {
438            var child = children.item(i);
439            if (child instanceof Element
440                && "listAttribute".equals(child.getNodeName())
441                && key.equals(((Element) child).getAttribute("key"))) {
442                return child;
443            }
444        }
445        return null;
446    }
447
448    private Node findStringAttribute(Document doc, String key) {
449        var root = doc.getDocumentElement();
450        var children = root.getChildNodes();
451        for (int i = 0; i < children.getLength(); i++) {
452            var child = children.item(i);
453            if (child instanceof Element
454                && "stringAttribute".equals(child.getNodeName())
455                && key.equals(((Element) child).getAttribute("key"))) {
456                return child;
457            }
458        }
459        return null;
460    }
461
462}