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.eclipse;
020
021import java.io.File;
022import java.io.IOException;
023import java.nio.file.Files;
024import java.util.HashSet;
025import java.util.Properties;
026import java.util.Set;
027import java.util.function.BiConsumer;
028import java.util.function.Consumer;
029import java.util.function.Supplier;
030import java.util.stream.Collectors;
031import java.util.stream.Stream;
032import javax.xml.parsers.DocumentBuilderFactory;
033import javax.xml.parsers.ParserConfigurationException;
034import javax.xml.transform.OutputKeys;
035import javax.xml.transform.TransformerException;
036import javax.xml.transform.TransformerFactory;
037import javax.xml.transform.TransformerFactoryConfigurationError;
038import javax.xml.transform.dom.DOMSource;
039import javax.xml.transform.stream.StreamResult;
040import org.jdrupes.builder.api.BuildException;
041import org.jdrupes.builder.api.FileTree;
042import static org.jdrupes.builder.api.Intent.*;
043import org.jdrupes.builder.api.MergedTestProject;
044import org.jdrupes.builder.api.Project;
045import org.jdrupes.builder.api.Resource;
046import org.jdrupes.builder.api.ResourceRequest;
047import org.jdrupes.builder.api.ResourceType;
048import org.jdrupes.builder.core.AbstractGenerator;
049import org.jdrupes.builder.java.ClasspathElement;
050import org.jdrupes.builder.java.JavaCompiler;
051import org.jdrupes.builder.java.JavaProject;
052import org.jdrupes.builder.java.JavaResourceTree;
053import static org.jdrupes.builder.java.JavaTypes.*;
054import org.jdrupes.builder.java.LibraryJarFile;
055import org.w3c.dom.Document;
056import org.w3c.dom.Element;
057import org.w3c.dom.Node;
058
059/// The [EclipseConfigurator] provides the resource [EclipseConfiguration].
060/// "The configuration" consists of the Eclipse configuration files
061/// for a given project. The configurator generates the following
062/// files as W3C DOM documents (for XML files) or as [Properties]
063/// for a given project:
064///
065///   * `.project`,
066///   * `.classpath`,
067///   * `.settings/org.eclipse.core.resources.prefs`,
068///   * `.settings/org.eclipse.core.runtime.prefs` and
069///   * `.settings/org.eclipse.jdt.core.prefs`.
070///
071/// Each generated data structure can be post processed by a corresponding
072/// `adapt` method before being written to disk. Additional resources can
073/// be generated by the method [#adaptConfiguration].
074///
075/// Eclipse provides project nesting, but the outer project does not
076/// define a namespace. This can lead to problems if you have multiple
077/// (sub)projects with the same name in the workspace. The configurator
078/// allows you to define an alias for the project name to avoid this
079/// problem. This alias is used as Eclipse project name in all generated
080/// files.
081///
082/// If a project is a [MergedTestProject], the configurator merges the
083/// information from this test project into the configuration files of
084/// its parent project. Resources that the test project depends
085/// on will be added as "test only" class path resources and the folder
086/// with the sources for the java compiler will be added as "test sources".
087/// 
088@SuppressWarnings("PMD.TooManyMethods")
089public class EclipseConfigurator extends AbstractGenerator {
090
091    /// The Constant GENERATED_BY.
092    public static final String GENERATED_BY = "Generated by JDrupes Builder";
093    private static DocumentBuilderFactory dbf
094        = DocumentBuilderFactory.newInstance();
095    private Supplier<String> eclipseAlias = () -> project().name();
096    private BiConsumer<Document, Node> classpathAdaptor = (_, _) -> {
097    };
098    private Runnable configurationAdaptor = () -> {
099    };
100    private Consumer<Properties> jdtCorePrefsAdaptor = _ -> {
101    };
102    private Consumer<Properties> resourcesPrefsAdaptor = _ -> {
103    };
104    private Consumer<Properties> runtimePrefsAdaptor = _ -> {
105    };
106    private ProjectConfigurationAdaptor prjConfigAdaptor = (_, _, _) -> {
107    };
108
109    /// Instantiates a new eclipse configurator.
110    ///
111    /// @param project the project
112    ///
113    public EclipseConfigurator(Project project) {
114        super(project);
115    }
116
117    /// Define the eclipse (alias) project name. 
118    ///
119    /// @param eclipseAlias the eclipse alias
120    /// @return the eclipse configurator
121    ///
122    public EclipseConfigurator eclipseAlias(Supplier<String> eclipseAlias) {
123        this.eclipseAlias = eclipseAlias;
124        return this;
125    }
126
127    /// Define the eclipse (alias) project name. 
128    ///
129    /// @param eclipseAlias the eclipse alias
130    /// @return the eclipse configurator
131    ///
132    public EclipseConfigurator eclipseAlias(String eclipseAlias) {
133        this.eclipseAlias = () -> eclipseAlias;
134        return this;
135    }
136
137    /// Returns the eclipse alias.
138    ///
139    /// @return the string
140    ///
141    public String eclipseAlias() {
142        return eclipseAlias.get();
143    }
144
145    /// Provides an [EclipseConfiguration].
146    ///
147    /// @param <T> the generic type
148    /// @param requested the requested
149    /// @return the stream
150    ///
151    @Override
152    protected <T extends Resource> Stream<T>
153            doProvide(ResourceRequest<T> requested) {
154        if (!requested.accepts(new ResourceType<EclipseConfiguration>() {})) {
155            return Stream.empty();
156        }
157
158        // Generate nothing for test projects.
159        if (project() instanceof MergedTestProject) {
160            return Stream.empty();
161        }
162
163        // Make sure that the directories exist.
164        project().directory().resolve(".settings").toFile().mkdirs();
165
166        // generate .project
167        generateXmlFile(this::generateProjectConfiguration, ".project");
168
169        // generate .classpath
170        if (project() instanceof JavaProject) {
171            generateXmlFile(this::generateClasspathConfiguration, ".classpath");
172        }
173
174        // Generate preferences
175        generateResourcesPrefs();
176        generateRuntimePrefs();
177        if (project() instanceof JavaProject) {
178            generateJdtCorePrefs();
179        }
180
181        // General overrides
182        configurationAdaptor.run();
183
184        // Create result
185        @SuppressWarnings({ "unchecked" })
186        var result = (Stream<T>) Stream.of(
187            EclipseConfiguration.of(project(), eclipseAlias()));
188        return result;
189    }
190
191    private void generateXmlFile(Consumer<Document> generator, String name) {
192        try {
193            var doc = dbf.newDocumentBuilder().newDocument();
194            generator.accept(doc);
195            var transformer = TransformerFactory.newInstance().newTransformer();
196            transformer.setOutputProperty(OutputKeys.INDENT, "yes");
197            transformer.setOutputProperty(
198                "{http://xml.apache.org/xslt}indent-amount", "4");
199            try (var out = Files
200                .newBufferedWriter(project().directory().resolve(name))) {
201                transformer.transform(new DOMSource(doc),
202                    new StreamResult(out));
203            }
204        } catch (ParserConfigurationException | TransformerException
205                | TransformerFactoryConfigurationError | IOException e) {
206            throw new BuildException().from(this).cause(e);
207        }
208    }
209
210    /// Generates the content of the `.project` file into the given document.
211    ///
212    /// @param doc the document
213    ///
214    @SuppressWarnings("PMD.AvoidDuplicateLiterals")
215    protected void generateProjectConfiguration(Document doc) {
216        var prjDescr = doc.appendChild(doc.createElement("projectDescription"));
217        prjDescr.appendChild(doc.createElement("name"))
218            .appendChild(doc.createTextNode(eclipseAlias()));
219        prjDescr.appendChild(doc.createElement("comment")).appendChild(
220            doc.createTextNode(GENERATED_BY));
221        prjDescr.appendChild(doc.createElement("projects"));
222        var buildSpec = prjDescr.appendChild(doc.createElement("buildSpec"));
223        var natures = prjDescr.appendChild(doc.createElement("natures"));
224        if (project() instanceof JavaProject) {
225            var cmd = buildSpec.appendChild(doc.createElement("buildCommand"));
226            cmd.appendChild(doc.createElement("name")).appendChild(
227                doc.createTextNode("org.eclipse.jdt.core.javabuilder"));
228            cmd.appendChild(doc.createElement("arguments"));
229            natures.appendChild(doc.createElement("nature")).appendChild(
230                doc.createTextNode("org.eclipse.jdt.core.javanature"));
231        }
232
233        // Allow derived class to adapt the project configuration
234        prjConfigAdaptor.accept(doc, buildSpec, natures);
235    }
236
237    /// Allow derived classes to post process the project configuration.
238    ///
239    @FunctionalInterface
240    public interface ProjectConfigurationAdaptor {
241        /// Execute the adaptor.
242        ///
243        /// @param doc the document
244        /// @param buildSpec shortcut to the `buildSpec` element
245        /// @param natures shortcut to the `natures` element
246        ///
247        void accept(Document doc, Node buildSpec,
248                Node natures);
249    }
250
251    /// Adapt project configuration.
252    ///
253    /// @param adaptor the adaptor
254    /// @return the eclipse configurator
255    ///
256    public EclipseConfigurator adaptProjectConfiguration(
257            ProjectConfigurationAdaptor adaptor) {
258        prjConfigAdaptor = adaptor;
259        return this;
260    }
261
262    /// Generates the content of the `.classpath` file into the given
263    /// document.
264    ///
265    /// @param doc the doc
266    ///
267    @SuppressWarnings({ "PMD.AvoidDuplicateLiterals" })
268    protected void generateClasspathConfiguration(Document doc) {
269        var classpath = doc.appendChild(doc.createElement("classpath"));
270        addCompilationResources(doc, classpath, project());
271        addJavaResources(doc, classpath, project());
272
273        // Add projects
274        final Set<ClasspathElement> providedByProject = new HashSet<>();
275        final Set<Project> exposed = project().providers().select(Expose)
276            .filter(p -> p instanceof Project).map(Project.class::cast)
277            .collect(Collectors.toSet());
278        project().providers().select(Consume, Reveal, Expose, Forward)
279            .filter(p -> p instanceof Project)
280            .map(Project.class::cast).forEach(p -> {
281                if (p instanceof MergedTestProject) {
282                    if (p.parentProject().get().equals(project())) {
283                        // Test projects contribute their resources to the
284                        // parent
285                        addCompilationResources(doc, classpath, p);
286                        addJavaResources(doc, classpath, p);
287                    }
288                    return;
289                }
290                var entry = (Element) classpath
291                    .appendChild(doc.createElement("classpathentry"));
292                entry.setAttribute("kind", "src");
293                var referenced = p.resources(
294                    of(EclipseConfiguration.class).using(Supply, Expose))
295                    .filter(c -> c.projectName().equals(p.name())).findFirst()
296                    .map(EclipseConfiguration::eclipseAlias).orElse(p.name());
297                entry.setAttribute("path", "/" + referenced);
298                if (exposed.contains(p)) {
299                    entry.setAttribute("exported", "true");
300                }
301                var attributes
302                    = entry.appendChild(doc.createElement("attributes"));
303                var attribute = (Element) attributes
304                    .appendChild(doc.createElement("attribute"));
305                attribute.setAttribute("without_test_code", "true");
306                providedByProject.addAll(
307                    p.resources(of(ClasspathElement.class)).toList());
308            });
309
310        // Add jars
311        final Set<ClasspathElement> exposedByProject = new HashSet<>();
312        exposedByProject.addAll(project()
313            .resources(of(ClasspathElement.class).using(Expose))
314            .toList());
315        project().resources(of(LibraryJarFileType)
316            .using(Consume, Reveal, Expose))
317            .filter(jf -> !providedByProject.contains(jf))
318            .collect(Collectors.toSet()).stream().forEach(jf -> {
319                addJarFileEntry(doc, classpath, jf,
320                    exposedByProject.contains(jf), false);
321            });
322
323        // Allow derived class to override
324        classpathAdaptor.accept(doc, classpath);
325    }
326
327    @SuppressWarnings("PMD.AvoidDuplicateLiterals")
328    private void addJarFileEntry(Document doc, Node classpath,
329            LibraryJarFile jarFile, boolean exported, boolean test) {
330        var entry = (Element) classpath
331            .appendChild(doc.createElement("classpathentry"));
332        entry.setAttribute("kind", "lib");
333        var jarPathName = jarFile.path().toString();
334        entry.setAttribute("path", jarPathName);
335        if (exported) {
336            entry.setAttribute("exported", "true");
337        }
338        if (test) {
339            var attr = (Element) entry
340                .appendChild(doc.createElement("attributes"))
341                .appendChild(doc.createElement("attribute"));
342            attr.setAttribute("name", "test");
343            attr.setAttribute("value", "true");
344        }
345
346        // Educated guesses
347        var sourcesJar
348            = new File(jarPathName.replaceFirst("\\.jar$", "-sources.jar"));
349        if (sourcesJar.canRead()) {
350            entry.setAttribute("sourcepath", sourcesJar.getAbsolutePath());
351        }
352        var javadocJar = new File(
353            jarPathName.replaceFirst("\\.jar$", "-javadoc.jar"));
354        if (javadocJar.canRead()) {
355            var attr = (Element) entry
356                .appendChild(doc.createElement("attributes"))
357                .appendChild(doc.createElement("attribute"));
358            attr.setAttribute("name", "javadoc_location");
359            attr.setAttribute("value",
360                "jar:file:" + javadocJar.getAbsolutePath() + "!/");
361        }
362    }
363
364    private void addJavaResources(Document doc, Node classpath,
365            Project project) {
366        // TODO Generalize. Currently we assume a Java compiler exists
367        // and use it to obtain the output directory for all generators
368        var javaCompiler = project.providers().select(Consume, Reveal, Supply)
369            .filter(p -> p instanceof JavaCompiler)
370            .map(JavaCompiler.class::cast).findFirst();
371        var outputDirectory
372            = javaCompiler.map(jc -> project.relativize(jc.destination()));
373
374        // Add resources
375        project.providers().without(Project.class).resources(
376            of(JavaResourceTree.class).using(Consume, Reveal, Supply))
377            .map(FileTree::root).filter(p -> p.toFile().canRead())
378            .map(project::relativize).forEach(p -> {
379                var entry = (Element) classpath
380                    .appendChild(doc.createElement("classpathentry"));
381                entry.appendChild(doc.createComment("From " + project));
382                entry.setAttribute("kind", "src");
383                entry.setAttribute("path", p.toString());
384                if (project instanceof MergedTestProject) {
385                    outputDirectory.ifPresent(o -> {
386                        entry.setAttribute("output", o.toString());
387                    });
388                    var attr = (Element) entry
389                        .appendChild(doc.createElement("attributes"))
390                        .appendChild(doc.createElement("attribute"));
391                    attr.setAttribute("name", "test");
392                    attr.setAttribute("value", "true");
393                }
394            });
395    }
396
397    private void addCompilationResources(Document doc, Node classpath,
398            Project project) {
399        // TODO Generalize. Currently we assume a Java compiler exists
400        // and use it to obtain the output directory for all generators
401        var javaCompiler = project.providers().select(Consume, Reveal, Supply)
402            .filter(p -> p instanceof JavaCompiler)
403            .map(JavaCompiler.class::cast).findFirst();
404        var outputDirectory
405            = javaCompiler.map(jc -> project.relativize(jc.destination()));
406
407        // Add source trees
408        project.providers().without(Project.class).resources(
409            of(JavaSourceTreeType).using(Consume, Reveal, Supply))
410            .map(FileTree::root).filter(p -> p.toFile().canRead())
411            .map(project::relativize).forEach(p -> {
412                var entry = (Element) classpath
413                    .appendChild(doc.createElement("classpathentry"));
414                entry.appendChild(doc.createComment("From " + project));
415                entry.setAttribute("kind", "src");
416                entry.setAttribute("path", p.toString());
417                if (project instanceof MergedTestProject) {
418                    outputDirectory.ifPresent(o -> {
419                        entry.setAttribute("output", o.toString());
420                    });
421                    var attr = (Element) entry
422                        .appendChild(doc.createElement("attributes"))
423                        .appendChild(doc.createElement("attribute"));
424                    attr.setAttribute("name", "test");
425                    attr.setAttribute("value", "true");
426                }
427            });
428
429        // For merged test project also add compile path resources
430        if (project instanceof MergedTestProject) {
431            project.providers().without(project.parentProject().get()).filter(
432                p -> javaCompiler.map(jc -> !p.equals(jc)).orElse(true))
433                .resources(of(
434                    LibraryJarFile.class).using(Consume, Reveal, Expose))
435                .forEach(jf -> {
436                    addJarFileEntry(doc, classpath, jf, false, true);
437                });
438            return;
439        }
440
441        // For "normal projects" configure default output directory
442        outputDirectory.ifPresent(o -> {
443            var entry = (Element) classpath
444                .appendChild(doc.createElement("classpathentry"));
445            entry.setAttribute("kind", "output");
446            entry.setAttribute("path", o.toString());
447        });
448
449        // Finally Add JRE
450        javaCompiler.ifPresent(jc -> {
451            jc.optionArgument("-target", "--target", "--release")
452                .ifPresentOrElse(v -> addSpecificJre(doc, classpath, v),
453                    () -> addInheritedJre(doc, classpath));
454        });
455    }
456
457    private void addSpecificJre(Document doc, Node classpath,
458            String version) {
459        var entry = (Element) classpath
460            .appendChild(doc.createElement("classpathentry"));
461        entry.setAttribute("kind", "con");
462        entry.setAttribute("path",
463            "org.eclipse.jdt.launching.JRE_CONTAINER"
464                + "/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType"
465                + "/JavaSE-" + version);
466        var attributes = entry.appendChild(doc.createElement("attributes"));
467        var attribute
468            = (Element) attributes.appendChild(doc.createElement("attribute"));
469        attribute.setAttribute("name", "module");
470        attribute.setAttribute("value", "true");
471    }
472
473    private void addInheritedJre(Document doc, Node classpath) {
474        var entry = (Element) classpath
475            .appendChild(doc.createElement("classpathentry"));
476        entry.setAttribute("kind", "con");
477        entry.setAttribute("path",
478            "org.eclipse.jdt.launching.JRE_CONTAINER");
479        var attributes = entry.appendChild(doc.createElement("attributes"));
480        var attribute
481            = (Element) attributes.appendChild(doc.createElement("attribute"));
482        attribute.setAttribute("name", "module");
483        attribute.setAttribute("value", "true");
484    }
485
486    /// Allow the user to post process the classpath configuration.
487    /// The node passed to the consumer is the `classpath` element.
488    ///
489    /// @param adaptor the adaptor
490    /// @return the eclipse configurator
491    ///
492    public EclipseConfigurator
493            adaptClasspathConfiguration(BiConsumer<Document, Node> adaptor) {
494        classpathAdaptor = adaptor;
495        return this;
496    }
497
498    /// Generate the properties for the
499    /// `.settings/org.eclipse.core.resources.prefs` file.
500    ///
501    protected void generateResourcesPrefs() {
502        var props = new Properties();
503        props.setProperty("eclipse.preferences.version", "1");
504        props.setProperty("encoding/<project>", "UTF-8");
505        resourcesPrefsAdaptor.accept(props);
506        try (var out = new FixCommentsFilter(Files.newBufferedWriter(
507            project().directory().resolve(
508                ".settings/org.eclipse.core.resources.prefs")),
509            GENERATED_BY)) {
510            props.store(out, "");
511        } catch (IOException e) {
512            throw new BuildException().from(this).cause(e);
513        }
514    }
515
516    /// Allow the user to adapt the properties for the
517    /// `.settings/org.eclipse.core.resources.prefs` file.
518    ///
519    /// @param adaptor the adaptor
520    /// @return the eclipse configurator
521    ///
522    public EclipseConfigurator
523            adaptResourcePrefs(Consumer<Properties> adaptor) {
524        resourcesPrefsAdaptor = adaptor;
525        return this;
526    }
527
528    /// Generate the properties for the
529    /// `.settings/org.eclipse.core.runtime.prefs` file.
530    ///
531    protected void generateRuntimePrefs() {
532        var props = new Properties();
533        props.setProperty("eclipse.preferences.version", "1");
534        props.setProperty("line.separator", "\n");
535        runtimePrefsAdaptor.accept(props);
536        try (var out = new FixCommentsFilter(Files.newBufferedWriter(
537            project().directory().resolve(
538                ".settings/org.eclipse.core.runtime.prefs")),
539            GENERATED_BY)) {
540            props.store(out, "");
541        } catch (IOException e) {
542            throw new BuildException().from(this).cause(e);
543        }
544    }
545
546    /// Allow the user to adapt the properties for the
547    /// `.settings/org.eclipse.core.runtime.prefs` file.
548    ///
549    /// @param adaptor the adaptor
550    /// @return the eclipse configurator
551    ///
552    public EclipseConfigurator adaptRuntimePrefs(Consumer<Properties> adaptor) {
553        runtimePrefsAdaptor = adaptor;
554        return this;
555    }
556
557    /// Generate the properties for the
558    /// `.settings/org.eclipse.jdt.core.prefs` file.
559    ///
560    protected void generateJdtCorePrefs() {
561        var props = new Properties();
562        props.setProperty("eclipse.preferences.version", "1");
563        project().providers().select(Supply)
564            .filter(p -> p instanceof JavaCompiler).map(p -> (JavaCompiler) p)
565            .findFirst().ifPresent(jc -> {
566                jc.optionArgument("-target", "--target", "--release")
567                    .ifPresent(v -> {
568                        props.setProperty("org.eclipse.jdt.core.compiler"
569                            + ".codegen.targetPlatform", v);
570                    });
571                jc.optionArgument("-source", "--source", "--release")
572                    .ifPresent(v -> {
573                        props.setProperty("org.eclipse.jdt.core.compiler"
574                            + ".source", v);
575                        props.setProperty("org.eclipse.jdt.core.compiler"
576                            + ".compliance", v);
577                    });
578            });
579        jdtCorePrefsAdaptor.accept(props);
580        try (var out = new FixCommentsFilter(Files.newBufferedWriter(
581            project().directory()
582                .resolve(".settings/org.eclipse.jdt.core.prefs")),
583            GENERATED_BY)) {
584            props.store(out, "");
585        } catch (IOException e) {
586            throw new BuildException().from(this).cause(e);
587        }
588    }
589
590    /// Allow the user to adapt the properties for the
591    /// `.settings/org.eclipse.jdt.core.prefs` file.
592    ///
593    /// @param adaptor the adaptor
594    /// @return the eclipse configurator
595    ///
596    public EclipseConfigurator adaptJdtCorePrefs(Consumer<Properties> adaptor) {
597        jdtCorePrefsAdaptor = adaptor;
598        return this;
599    }
600
601    /// Allow the user to add additional resources.
602    ///
603    /// @param adaptor the adaptor
604    /// @return the eclipse configurator
605    ///
606    public EclipseConfigurator adaptConfiguration(Runnable adaptor) {
607        configurationAdaptor = adaptor;
608        return this;
609    }
610
611}