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", "PMD.UseDiamondOperator" })
186        var result = (Stream<T>) Stream.of(project().newResource(
187            new ResourceType<EclipseConfiguration>() {},
188            project().name(), eclipseAlias()));
189        return result;
190    }
191
192    private void generateXmlFile(Consumer<Document> generator, String name) {
193        try {
194            var doc = dbf.newDocumentBuilder().newDocument();
195            generator.accept(doc);
196            var transformer = TransformerFactory.newInstance().newTransformer();
197            transformer.setOutputProperty(OutputKeys.INDENT, "yes");
198            transformer.setOutputProperty(
199                "{http://xml.apache.org/xslt}indent-amount", "4");
200            try (var out = Files
201                .newBufferedWriter(project().directory().resolve(name))) {
202                transformer.transform(new DOMSource(doc),
203                    new StreamResult(out));
204            }
205        } catch (ParserConfigurationException | TransformerException
206                | TransformerFactoryConfigurationError | IOException e) {
207            throw new BuildException().from(this).cause(e);
208        }
209    }
210
211    /// Generates the content of the `.project` file into the given document.
212    ///
213    /// @param doc the document
214    ///
215    @SuppressWarnings("PMD.AvoidDuplicateLiterals")
216    protected void generateProjectConfiguration(Document doc) {
217        var prjDescr = doc.appendChild(doc.createElement("projectDescription"));
218        prjDescr.appendChild(doc.createElement("name"))
219            .appendChild(doc.createTextNode(eclipseAlias()));
220        prjDescr.appendChild(doc.createElement("comment")).appendChild(
221            doc.createTextNode(GENERATED_BY));
222        prjDescr.appendChild(doc.createElement("projects"));
223        var buildSpec = prjDescr.appendChild(doc.createElement("buildSpec"));
224        var natures = prjDescr.appendChild(doc.createElement("natures"));
225        if (project() instanceof JavaProject) {
226            var cmd = buildSpec.appendChild(doc.createElement("buildCommand"));
227            cmd.appendChild(doc.createElement("name")).appendChild(
228                doc.createTextNode("org.eclipse.jdt.core.javabuilder"));
229            cmd.appendChild(doc.createElement("arguments"));
230            natures.appendChild(doc.createElement("nature")).appendChild(
231                doc.createTextNode("org.eclipse.jdt.core.javanature"));
232        }
233
234        // Allow derived class to adapt the project configuration
235        prjConfigAdaptor.accept(doc, buildSpec, natures);
236    }
237
238    /// Allow derived classes to post process the project configuration.
239    ///
240    @FunctionalInterface
241    public interface ProjectConfigurationAdaptor {
242        /// Execute the adaptor.
243        ///
244        /// @param doc the document
245        /// @param buildSpec shortcut to the `buildSpec` element
246        /// @param natures shortcut to the `natures` element
247        ///
248        void accept(Document doc, Node buildSpec,
249                Node natures);
250    }
251
252    /// Adapt project configuration.
253    ///
254    /// @param adaptor the adaptor
255    /// @return the eclipse configurator
256    ///
257    public EclipseConfigurator adaptProjectConfiguration(
258            ProjectConfigurationAdaptor adaptor) {
259        prjConfigAdaptor = adaptor;
260        return this;
261    }
262
263    /// Generates the content of the `.classpath` file into the given
264    /// document.
265    ///
266    /// @param doc the doc
267    ///
268    @SuppressWarnings({ "PMD.AvoidDuplicateLiterals" })
269    protected void generateClasspathConfiguration(Document doc) {
270        var classpath = doc.appendChild(doc.createElement("classpath"));
271        addCompilationResources(doc, classpath, project());
272        addJavaResources(doc, classpath, project());
273
274        // Add projects
275        final Set<ClasspathElement> providedByProject = new HashSet<>();
276        final Set<Project> exposed = project().providers().select(Expose)
277            .filter(p -> p instanceof Project).map(Project.class::cast)
278            .collect(Collectors.toSet());
279        project().providers().select(Consume, Reveal, Expose, Forward)
280            .filter(p -> p instanceof Project)
281            .map(Project.class::cast).forEach(p -> {
282                if (p instanceof MergedTestProject) {
283                    if (p.parentProject().get().equals(project())) {
284                        // Test projects contribute their resources to the
285                        // parent
286                        addCompilationResources(doc, classpath, p);
287                        addJavaResources(doc, classpath, p);
288                    }
289                    return;
290                }
291                var entry = (Element) classpath
292                    .appendChild(doc.createElement("classpathentry"));
293                entry.setAttribute("kind", "src");
294                var referenced = p.resources(
295                    of(EclipseConfiguration.class).using(Supply, Expose))
296                    .filter(c -> c.projectName().equals(p.name())).findFirst()
297                    .map(EclipseConfiguration::eclipseAlias).orElse(p.name());
298                entry.setAttribute("path", "/" + referenced);
299                if (exposed.contains(p)) {
300                    entry.setAttribute("exported", "true");
301                }
302                var attributes
303                    = entry.appendChild(doc.createElement("attributes"));
304                var attribute = (Element) attributes
305                    .appendChild(doc.createElement("attribute"));
306                attribute.setAttribute("without_test_code", "true");
307                providedByProject.addAll(
308                    p.resources(of(ClasspathElement.class)).toList());
309            });
310
311        // Add jars
312        final Set<ClasspathElement> exposedByProject = new HashSet<>();
313        exposedByProject.addAll(project()
314            .resources(of(ClasspathElement.class).using(Expose))
315            .toList());
316        project().resources(of(LibraryJarFileType)
317            .using(Consume, Reveal, Expose))
318            .filter(jf -> !providedByProject.contains(jf))
319            .collect(Collectors.toSet()).stream().forEach(jf -> {
320                addJarFileEntry(doc, classpath, jf,
321                    exposedByProject.contains(jf), false);
322            });
323
324        // Allow derived class to override
325        classpathAdaptor.accept(doc, classpath);
326    }
327
328    @SuppressWarnings("PMD.AvoidDuplicateLiterals")
329    private void addJarFileEntry(Document doc, Node classpath,
330            LibraryJarFile jarFile, boolean exported, boolean test) {
331        var entry = (Element) classpath
332            .appendChild(doc.createElement("classpathentry"));
333        entry.setAttribute("kind", "lib");
334        var jarPathName = jarFile.path().toString();
335        entry.setAttribute("path", jarPathName);
336        if (exported) {
337            entry.setAttribute("exported", "true");
338        }
339        if (test) {
340            var attr = (Element) entry
341                .appendChild(doc.createElement("attributes"))
342                .appendChild(doc.createElement("attribute"));
343            attr.setAttribute("name", "test");
344            attr.setAttribute("value", "true");
345        }
346
347        // Educated guesses
348        var sourcesJar
349            = new File(jarPathName.replaceFirst("\\.jar$", "-sources.jar"));
350        if (sourcesJar.canRead()) {
351            entry.setAttribute("sourcepath", sourcesJar.getAbsolutePath());
352        }
353        var javadocJar = new File(
354            jarPathName.replaceFirst("\\.jar$", "-javadoc.jar"));
355        if (javadocJar.canRead()) {
356            var attr = (Element) entry
357                .appendChild(doc.createElement("attributes"))
358                .appendChild(doc.createElement("attribute"));
359            attr.setAttribute("name", "javadoc_location");
360            attr.setAttribute("value",
361                "jar:file:" + javadocJar.getAbsolutePath() + "!/");
362        }
363    }
364
365    private void addJavaResources(Document doc, Node classpath,
366            Project project) {
367        // TODO Generalize. Currently we assume a Java compiler exists
368        // and use it to obtain the output directory for all generators
369        var javaCompiler = project.providers().select(Consume, Reveal, Supply)
370            .filter(p -> p instanceof JavaCompiler)
371            .map(JavaCompiler.class::cast).findFirst();
372        var outputDirectory
373            = javaCompiler.map(jc -> project.relativize(jc.destination()));
374
375        // Add resources
376        project.providers().without(Project.class).resources(
377            of(JavaResourceTree.class).using(Consume, Reveal, Supply))
378            .map(FileTree::root).filter(p -> p.toFile().canRead())
379            .map(project::relativize).forEach(p -> {
380                var entry = (Element) classpath
381                    .appendChild(doc.createElement("classpathentry"));
382                entry.appendChild(doc.createComment("From " + project));
383                entry.setAttribute("kind", "src");
384                entry.setAttribute("path", p.toString());
385                if (project instanceof MergedTestProject) {
386                    outputDirectory.ifPresent(o -> {
387                        entry.setAttribute("output", o.toString());
388                    });
389                    var attr = (Element) entry
390                        .appendChild(doc.createElement("attributes"))
391                        .appendChild(doc.createElement("attribute"));
392                    attr.setAttribute("name", "test");
393                    attr.setAttribute("value", "true");
394                }
395            });
396    }
397
398    private void addCompilationResources(Document doc, Node classpath,
399            Project project) {
400        // TODO Generalize. Currently we assume a Java compiler exists
401        // and use it to obtain the output directory for all generators
402        var javaCompiler = project.providers().select(Consume, Reveal, Supply)
403            .filter(p -> p instanceof JavaCompiler)
404            .map(JavaCompiler.class::cast).findFirst();
405        var outputDirectory
406            = javaCompiler.map(jc -> project.relativize(jc.destination()));
407
408        // Add source trees
409        project.providers().without(Project.class).resources(
410            of(JavaSourceTreeType).using(Consume, Reveal, Supply))
411            .map(FileTree::root).filter(p -> p.toFile().canRead())
412            .map(project::relativize).forEach(p -> {
413                var entry = (Element) classpath
414                    .appendChild(doc.createElement("classpathentry"));
415                entry.appendChild(doc.createComment("From " + project));
416                entry.setAttribute("kind", "src");
417                entry.setAttribute("path", p.toString());
418                if (project instanceof MergedTestProject) {
419                    outputDirectory.ifPresent(o -> {
420                        entry.setAttribute("output", o.toString());
421                    });
422                    var attr = (Element) entry
423                        .appendChild(doc.createElement("attributes"))
424                        .appendChild(doc.createElement("attribute"));
425                    attr.setAttribute("name", "test");
426                    attr.setAttribute("value", "true");
427                }
428            });
429
430        // For merged test project also add compile path resources
431        if (project instanceof MergedTestProject) {
432            project.providers().without(project.parentProject().get()).filter(
433                p -> javaCompiler.map(jc -> !p.equals(jc)).orElse(true))
434                .resources(of(
435                    LibraryJarFile.class).using(Consume, Reveal, Expose))
436                .forEach(jf -> {
437                    addJarFileEntry(doc, classpath, jf, false, true);
438                });
439            return;
440        }
441
442        // For "normal projects" configure default output directory
443        outputDirectory.ifPresent(o -> {
444            var entry = (Element) classpath
445                .appendChild(doc.createElement("classpathentry"));
446            entry.setAttribute("kind", "output");
447            entry.setAttribute("path", o.toString());
448        });
449
450        // Finally Add JRE
451        javaCompiler.ifPresent(jc -> {
452            jc.optionArgument("-target", "--target", "--release")
453                .ifPresentOrElse(v -> addSpecificJre(doc, classpath, v),
454                    () -> addInheritedJre(doc, classpath));
455        });
456    }
457
458    private void addSpecificJre(Document doc, Node classpath,
459            String version) {
460        var entry = (Element) classpath
461            .appendChild(doc.createElement("classpathentry"));
462        entry.setAttribute("kind", "con");
463        entry.setAttribute("path",
464            "org.eclipse.jdt.launching.JRE_CONTAINER"
465                + "/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType"
466                + "/JavaSE-" + version);
467        var attributes = entry.appendChild(doc.createElement("attributes"));
468        var attribute
469            = (Element) attributes.appendChild(doc.createElement("attribute"));
470        attribute.setAttribute("name", "module");
471        attribute.setAttribute("value", "true");
472    }
473
474    private void addInheritedJre(Document doc, Node classpath) {
475        var entry = (Element) classpath
476            .appendChild(doc.createElement("classpathentry"));
477        entry.setAttribute("kind", "con");
478        entry.setAttribute("path",
479            "org.eclipse.jdt.launching.JRE_CONTAINER");
480        var attributes = entry.appendChild(doc.createElement("attributes"));
481        var attribute
482            = (Element) attributes.appendChild(doc.createElement("attribute"));
483        attribute.setAttribute("name", "module");
484        attribute.setAttribute("value", "true");
485    }
486
487    /// Allow the user to post process the classpath configuration.
488    /// The node passed to the consumer is the `classpath` element.
489    ///
490    /// @param adaptor the adaptor
491    /// @return the eclipse configurator
492    ///
493    public EclipseConfigurator
494            adaptClasspathConfiguration(BiConsumer<Document, Node> adaptor) {
495        classpathAdaptor = adaptor;
496        return this;
497    }
498
499    /// Generate the properties for the
500    /// `.settings/org.eclipse.core.resources.prefs` file.
501    ///
502    protected void generateResourcesPrefs() {
503        var props = new Properties();
504        props.setProperty("eclipse.preferences.version", "1");
505        props.setProperty("encoding/<project>", "UTF-8");
506        resourcesPrefsAdaptor.accept(props);
507        try (var out = new FixCommentsFilter(Files.newBufferedWriter(
508            project().directory().resolve(
509                ".settings/org.eclipse.core.resources.prefs")),
510            GENERATED_BY)) {
511            props.store(out, "");
512        } catch (IOException e) {
513            throw new BuildException("Cannot write eclipse settings: %s", e)
514                .from(this).cause(e);
515        }
516    }
517
518    /// Allow the user to adapt the properties for the
519    /// `.settings/org.eclipse.core.resources.prefs` file.
520    ///
521    /// @param adaptor the adaptor
522    /// @return the eclipse configurator
523    ///
524    public EclipseConfigurator
525            adaptResourcePrefs(Consumer<Properties> adaptor) {
526        resourcesPrefsAdaptor = adaptor;
527        return this;
528    }
529
530    /// Generate the properties for the
531    /// `.settings/org.eclipse.core.runtime.prefs` file.
532    ///
533    protected void generateRuntimePrefs() {
534        var props = new Properties();
535        props.setProperty("eclipse.preferences.version", "1");
536        props.setProperty("line.separator", "\n");
537        runtimePrefsAdaptor.accept(props);
538        try (var out = new FixCommentsFilter(Files.newBufferedWriter(
539            project().directory().resolve(
540                ".settings/org.eclipse.core.runtime.prefs")),
541            GENERATED_BY)) {
542            props.store(out, "");
543        } catch (IOException e) {
544            throw new BuildException("Cannot write eclipse settings: %s", e)
545                .from(this).cause(e);
546        }
547    }
548
549    /// Allow the user to adapt the properties for the
550    /// `.settings/org.eclipse.core.runtime.prefs` file.
551    ///
552    /// @param adaptor the adaptor
553    /// @return the eclipse configurator
554    ///
555    public EclipseConfigurator adaptRuntimePrefs(Consumer<Properties> adaptor) {
556        runtimePrefsAdaptor = adaptor;
557        return this;
558    }
559
560    /// Generate the properties for the
561    /// `.settings/org.eclipse.jdt.core.prefs` file.
562    ///
563    protected void generateJdtCorePrefs() {
564        var props = new Properties();
565        props.setProperty("eclipse.preferences.version", "1");
566        project().providers().select(Supply)
567            .filter(p -> p instanceof JavaCompiler).map(p -> (JavaCompiler) p)
568            .findFirst().ifPresent(jc -> {
569                jc.optionArgument("-target", "--target", "--release")
570                    .ifPresent(v -> {
571                        props.setProperty("org.eclipse.jdt.core.compiler"
572                            + ".codegen.targetPlatform", v);
573                    });
574                jc.optionArgument("-source", "--source", "--release")
575                    .ifPresent(v -> {
576                        props.setProperty("org.eclipse.jdt.core.compiler"
577                            + ".source", v);
578                        props.setProperty("org.eclipse.jdt.core.compiler"
579                            + ".compliance", v);
580                    });
581            });
582        jdtCorePrefsAdaptor.accept(props);
583        try (var out = new FixCommentsFilter(Files.newBufferedWriter(
584            project().directory()
585                .resolve(".settings/org.eclipse.jdt.core.prefs")),
586            GENERATED_BY)) {
587            props.store(out, "");
588        } catch (IOException e) {
589            throw new BuildException("Cannot write eclipse settings: %s", e)
590                .from(this).cause(e);
591        }
592    }
593
594    /// Allow the user to adapt the properties for the
595    /// `.settings/org.eclipse.jdt.core.prefs` file.
596    ///
597    /// @param adaptor the adaptor
598    /// @return the eclipse configurator
599    ///
600    public EclipseConfigurator adaptJdtCorePrefs(Consumer<Properties> adaptor) {
601        jdtCorePrefsAdaptor = adaptor;
602        return this;
603    }
604
605    /// Allow the user to add additional resources.
606    ///
607    /// @param adaptor the adaptor
608    /// @return the eclipse configurator
609    ///
610    public EclipseConfigurator adaptConfiguration(Runnable adaptor) {
611        configurationAdaptor = adaptor;
612        return this;
613    }
614
615}