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.Properties;
025import java.util.function.BiConsumer;
026import java.util.function.Consumer;
027import java.util.stream.Collectors;
028import java.util.stream.Stream;
029import javax.xml.parsers.DocumentBuilderFactory;
030import javax.xml.parsers.ParserConfigurationException;
031import javax.xml.transform.OutputKeys;
032import javax.xml.transform.TransformerException;
033import javax.xml.transform.TransformerFactory;
034import javax.xml.transform.TransformerFactoryConfigurationError;
035import javax.xml.transform.dom.DOMSource;
036import javax.xml.transform.stream.StreamResult;
037import org.jdrupes.builder.api.BuildException;
038import org.jdrupes.builder.api.FileTree;
039import org.jdrupes.builder.api.Intend;
040import org.jdrupes.builder.api.Project;
041import org.jdrupes.builder.api.Resource;
042import org.jdrupes.builder.api.ResourceRequest;
043import org.jdrupes.builder.api.ResourceType;
044import org.jdrupes.builder.core.AbstractGenerator;
045import org.jdrupes.builder.java.ClasspathElement;
046import org.jdrupes.builder.java.JarFile;
047import org.jdrupes.builder.java.JavaCompiler;
048import org.jdrupes.builder.java.JavaProject;
049import org.jdrupes.builder.java.JavaResourceCollector;
050import static org.jdrupes.builder.java.JavaTypes.CompilationResourcesType;
051import org.w3c.dom.Document;
052import org.w3c.dom.Element;
053import org.w3c.dom.Node;
054
055/// The [EclipseConfigurator] provides the resource [EclipseConfiguration].
056/// "The configuration" consists of the Eclipse configuration files
057/// for a given project. The configurator generates the following
058/// files as W3C DOM documents (for XML files) or as [Properties]
059/// for a given project:
060///
061///   * `.project`,
062///   * `.classpath`,
063///   * `.settings/org.eclipse.core.resources.prefs`,
064///   * `.settings/org.eclipse.core.runtime.prefs` and
065///   * `.settings/org.eclipse.jdt.core.prefs`.
066///
067/// Each generated data structure can be post processed by a corresponding
068/// `adapt` method before being written to disk.
069///
070/// Additional resources can be generated by the method
071/// [#adaptConfiguration].  
072///
073public class EclipseConfigurator extends AbstractGenerator {
074
075    /// The Constant GENERATED_BY.
076    public static final String GENERATED_BY = "Generated by JDrupes Builder";
077    private static DocumentBuilderFactory dbf
078        = DocumentBuilderFactory.newInstance();
079    private BiConsumer<Document, Node> classpathAdaptor = (_, _) -> {
080    };
081    private Runnable configurationAdaptor = () -> {
082    };
083    private Consumer<Properties> jdtCorePrefsAdaptor = _ -> {
084    };
085    private Consumer<Properties> resourcesPrefsAdaptor = _ -> {
086    };
087    private Consumer<Properties> runtimePrefsAdaptor = _ -> {
088    };
089    private ProjectConfigurationAdaptor prjConfigAdaptor = (_, _, _) -> {
090    };
091
092    /// Instantiates a new eclipse configurator.
093    ///
094    /// @param project the project
095    ///
096    public EclipseConfigurator(Project project) {
097        super(project);
098    }
099
100    /// Provides an [EclipseConfiguration].
101    ///
102    /// @param <T> the generic type
103    /// @param requested the requested
104    /// @return the stream
105    ///
106    @Override
107    protected <T extends Resource> Stream<T>
108            doProvide(ResourceRequest<T> requested) {
109        if (!requested.includes(new ResourceType<EclipseConfiguration>() {})) {
110            return Stream.empty();
111        }
112
113        // Make sure that the directories exist.
114        project().directory().resolve(".settings").toFile().mkdirs();
115
116        // generate .project
117        generateXmlFile(this::generateProjectConfiguration, ".project");
118
119        // generate .classpath
120        if (project() instanceof JavaProject) {
121            generateXmlFile(this::generateClasspathConfiguration, ".classpath");
122        }
123
124        // Generate preferences
125        generateResourcesPrefs();
126        generateRuntimePrefs();
127        if (project() instanceof JavaProject) {
128            generateJdtCorePrefs();
129        }
130
131        // General overrides
132        configurationAdaptor.run();
133
134        // Create result
135        @SuppressWarnings({ "unchecked", "PMD.UseDiamondOperator" })
136        var result = (Stream<T>) Stream.of(project().newResource(
137            new ResourceType<EclipseConfiguration>() {},
138            project().directory()));
139        return result;
140    }
141
142    private void generateXmlFile(Consumer<Document> generator, String name) {
143        try {
144            var doc = dbf.newDocumentBuilder().newDocument();
145            generator.accept(doc);
146            var transformer = TransformerFactory.newInstance().newTransformer();
147            transformer.setOutputProperty(OutputKeys.INDENT, "yes");
148            transformer.setOutputProperty(
149                "{http://xml.apache.org/xslt}indent-amount", "4");
150            try (var out = Files
151                .newBufferedWriter(project().directory().resolve(name))) {
152                transformer.transform(new DOMSource(doc),
153                    new StreamResult(out));
154            }
155        } catch (ParserConfigurationException | TransformerException
156                | TransformerFactoryConfigurationError | IOException e) {
157            throw new BuildException(e);
158        }
159    }
160
161    /// Generates the content of the `.project` file into the given document.
162    ///
163    /// @param doc the document
164    ///
165    @SuppressWarnings("PMD.AvoidDuplicateLiterals")
166    protected void generateProjectConfiguration(Document doc) {
167        var prjDescr
168            = doc.appendChild(doc.createElement("projectDescription"));
169        prjDescr.appendChild(doc.createElement("name"))
170            .appendChild(doc.createTextNode(project().name()));
171        prjDescr.appendChild(doc.createElement("comment")).appendChild(
172            doc.createTextNode(GENERATED_BY));
173        prjDescr.appendChild(doc.createElement("projects"));
174        var buildSpec
175            = prjDescr.appendChild(doc.createElement("buildSpec"));
176        var natures = prjDescr.appendChild(doc.createElement("natures"));
177        if (project() instanceof JavaProject) {
178            var cmd
179                = buildSpec.appendChild(doc.createElement("buildCommand"));
180            cmd.appendChild(doc.createElement("name"))
181                .appendChild(doc.createTextNode(
182                    "org.eclipse.jdt.core.javabuilder"));
183            cmd.appendChild(doc.createElement("arguments"));
184            natures.appendChild(doc.createElement("nature")).appendChild(
185                doc.createTextNode("org.eclipse.jdt.core.javanature"));
186        }
187
188        // Allow derived class to adapt the project configuration
189        prjConfigAdaptor.accept(doc, buildSpec, natures);
190    }
191
192    /// Allow derived classes to post process the project configuration.
193    ///
194    @FunctionalInterface
195    public interface ProjectConfigurationAdaptor {
196        /// Execute the adaptor.
197        ///
198        /// @param doc the document
199        /// @param buildSpec shortcut to the `buildSpec` element
200        /// @param natures shortcut to the `natures` element
201        ///
202        void accept(Document doc, Node buildSpec,
203                Node natures);
204    }
205
206    /// Adapt project configuration.
207    ///
208    /// @param adaptor the adaptor
209    /// @return the eclipse configurator
210    ///
211    public EclipseConfigurator adaptProjectConfiguration(
212            ProjectConfigurationAdaptor adaptor) {
213        prjConfigAdaptor = adaptor;
214        return this;
215    }
216
217    /// Generates the content of the `.classpath` file into the given
218    /// document.
219    ///
220    /// @param doc the doc
221    ///
222    @SuppressWarnings({ "PMD.AvoidDuplicateLiterals",
223        "PMD.UseDiamondOperator" })
224    protected void generateClasspathConfiguration(Document doc) {
225        var classpath = doc.appendChild(doc.createElement("classpath"));
226        project().providers(Intend.Supply)
227            .filter(p -> p instanceof JavaCompiler).map(p -> (JavaCompiler) p)
228            .findFirst().ifPresent(jc -> {
229                jc.sources().stream().map(FileTree::root)
230                    .map(p -> project().relativize(p)).forEach(p -> {
231                        var entry = (Element) classpath
232                            .appendChild(doc.createElement("classpathentry"));
233                        entry.setAttribute("kind", "src");
234                        entry.setAttribute("path", p.toString());
235                    });
236                var entry = (Element) classpath
237                    .appendChild(doc.createElement("classpathentry"));
238                entry.setAttribute("kind", "output");
239                entry.setAttribute("path",
240                    project().relativize(jc.destination()).toString());
241                jc.optionArgument("-target", "--target", "--release")
242                    .ifPresentOrElse(v -> addSpecificJre(doc, classpath, v),
243                        () -> addInheritedJre(doc, classpath));
244            });
245
246        // Add resources
247        project().providers(Intend.Supply)
248            .filter(p -> p instanceof JavaResourceCollector)
249            .map(p -> (JavaResourceCollector) p)
250            .findFirst().ifPresent(rc -> {
251                rc.resources().stream().map(FileTree::root)
252                    .filter(p -> p.toFile().canRead())
253                    .map(p -> project().relativize(p)).forEach(p -> {
254                        var entry = (Element) classpath
255                            .appendChild(doc.createElement("classpathentry"));
256                        entry.setAttribute("kind", "src");
257                        entry.setAttribute("path", p.toString());
258                    });
259            });
260
261        // Add projects
262        collectContributing(project()).collect(Collectors.toSet()).stream()
263            .forEach(p -> {
264                var entry = (Element) classpath
265                    .appendChild(doc.createElement("classpathentry"));
266                entry.setAttribute("kind", "src");
267                entry.setAttribute("path", "/" + p.name());
268                var attributes
269                    = entry.appendChild(doc.createElement("attributes"));
270                var attribute = (Element) attributes
271                    .appendChild(doc.createElement("attribute"));
272                attribute.setAttribute("without_test_code", "true");
273            });
274
275        // Add jars
276        project().provided(new ResourceRequest<ClasspathElement>(
277            CompilationResourcesType)).filter(p -> p instanceof JarFile)
278            .map(jf -> (JarFile) jf).forEach(jf -> {
279                var entry = (Element) classpath
280                    .appendChild(doc.createElement("classpathentry"));
281                entry.setAttribute("kind", "lib");
282                var jarPathName = jf.path().toString();
283                entry.setAttribute("path", jarPathName);
284
285                // Educated guesses
286                var sourcesJar = new File(
287                    jarPathName.replaceFirst("\\.jar$", "-sources.jar"));
288                if (sourcesJar.canRead()) {
289                    entry.setAttribute("sourcepath",
290                        sourcesJar.getAbsolutePath());
291                }
292                var javadocJar = new File(
293                    jarPathName.replaceFirst("\\.jar$", "-javadoc.jar"));
294                if (javadocJar.canRead()) {
295                    var attr = (Element) entry
296                        .appendChild(doc.createElement("attributes"))
297                        .appendChild(doc.createElement("attribute"));
298                    attr.setAttribute("name", "javadoc_location");
299                    attr.setAttribute("value",
300                        "jar:file:" + javadocJar.getAbsolutePath() + "!/");
301                }
302            });
303
304        // Allow derived class to override
305        classpathAdaptor.accept(doc, classpath);
306    }
307
308    private Stream<Project> collectContributing(Project project) {
309        return project.providers(Intend.Consume, Intend.Forward, Intend.Expose)
310            .filter(p -> p instanceof Project).map(p -> (Project) p)
311            .map(p -> Stream.concat(Stream.of(p), collectContributing(p)))
312            .flatMap(s -> s);
313    }
314
315    private void addSpecificJre(Document doc, Node classpath,
316            String version) {
317        var entry = (Element) classpath
318            .appendChild(doc.createElement("classpathentry"));
319        entry.setAttribute("kind", "con");
320        entry.setAttribute("path",
321            "org.eclipse.jdt.launching.JRE_CONTAINER"
322                + "/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType"
323                + "/JavaSE-" + version);
324        var attributes = entry.appendChild(doc.createElement("attributes"));
325        var attribute
326            = (Element) attributes.appendChild(doc.createElement("attribute"));
327        attribute.setAttribute("name", "module");
328        attribute.setAttribute("value", "true");
329    }
330
331    private void addInheritedJre(Document doc, Node classpath) {
332        var entry = (Element) classpath
333            .appendChild(doc.createElement("classpathentry"));
334        entry.setAttribute("kind", "con");
335        entry.setAttribute("path",
336            "org.eclipse.jdt.launching.JRE_CONTAINER");
337        var attributes = entry.appendChild(doc.createElement("attributes"));
338        var attribute
339            = (Element) attributes.appendChild(doc.createElement("attribute"));
340        attribute.setAttribute("name", "module");
341        attribute.setAttribute("value", "true");
342    }
343
344    /// Allow the user to post process the classpath configuration.
345    /// The node passed to the consumer is the `classpath` element.
346    ///
347    /// @param adaptor the adaptor
348    /// @return the eclipse configurator
349    ///
350    public EclipseConfigurator
351            adaptClasspathConfiguration(BiConsumer<Document, Node> adaptor) {
352        classpathAdaptor = adaptor;
353        return this;
354    }
355
356    /// Generate the properties for the
357    /// `.settings/org.eclipse.core.resources.prefs` file.
358    ///
359    @SuppressWarnings("PMD.PreserveStackTrace")
360    protected void generateResourcesPrefs() {
361        var props = new Properties();
362        props.setProperty("eclipse.preferences.version", "1");
363        props.setProperty("encoding/<project>", "UTF-8");
364        resourcesPrefsAdaptor.accept(props);
365        try (var out = new FixCommentsFilter(Files.newBufferedWriter(
366            project().directory().resolve(
367                ".settings/org.eclipse.core.resources.prefs")),
368            GENERATED_BY)) {
369            props.store(out, "");
370        } catch (IOException e) {
371            throw new BuildException(
372                "Cannot write eclipse settings: " + e.getMessage());
373        }
374    }
375
376    /// Allow the user to adapt the properties for the
377    /// `.settings/org.eclipse.core.resources.prefs` file.
378    ///
379    /// @param adaptor the adaptor
380    /// @return the eclipse configurator
381    ///
382    public EclipseConfigurator
383            adaptResourcePrefs(Consumer<Properties> adaptor) {
384        resourcesPrefsAdaptor = adaptor;
385        return this;
386    }
387
388    /// Generate the properties for the
389    /// `.settings/org.eclipse.core.runtime.prefs` file.
390    ///
391    @SuppressWarnings("PMD.PreserveStackTrace")
392    protected void generateRuntimePrefs() {
393        var props = new Properties();
394        props.setProperty("eclipse.preferences.version", "1");
395        props.setProperty("line.separator", "\n");
396        runtimePrefsAdaptor.accept(props);
397        try (var out = new FixCommentsFilter(Files.newBufferedWriter(
398            project().directory().resolve(
399                ".settings/org.eclipse.core.runtime.prefs")),
400            GENERATED_BY)) {
401            props.store(out, "");
402        } catch (IOException e) {
403            throw new BuildException(
404                "Cannot write eclipse settings: " + e.getMessage());
405        }
406    }
407
408    /// Allow the user to adapt the properties for the
409    /// `.settings/org.eclipse.core.runtime.prefs` file.
410    ///
411    /// @param adaptor the adaptor
412    /// @return the eclipse configurator
413    ///
414    public EclipseConfigurator adaptRuntimePrefs(Consumer<Properties> adaptor) {
415        runtimePrefsAdaptor = adaptor;
416        return this;
417    }
418
419    /// Generate the properties for the
420    /// `.settings/org.eclipse.jdt.core.prefs` file.
421    ///
422    @SuppressWarnings("PMD.PreserveStackTrace")
423    protected void generateJdtCorePrefs() {
424        var props = new Properties();
425        props.setProperty("eclipse.preferences.version", "1");
426        project().providers(Intend.Supply)
427            .filter(p -> p instanceof JavaCompiler).map(p -> (JavaCompiler) p)
428            .findFirst().ifPresent(jc -> {
429                jc.optionArgument("-target", "--target", "--release")
430                    .ifPresent(v -> {
431                        props.setProperty("org.eclipse.jdt.core.compiler"
432                            + ".codegen.targetPlatform", v);
433                    });
434                jc.optionArgument("-source", "--source", "--release")
435                    .ifPresent(v -> {
436                        props.setProperty("org.eclipse.jdt.core.compiler"
437                            + ".source", v);
438                        props.setProperty("org.eclipse.jdt.core.compiler"
439                            + ".compliance", v);
440                    });
441            });
442        jdtCorePrefsAdaptor.accept(props);
443        try (var out = new FixCommentsFilter(Files.newBufferedWriter(
444            project().directory()
445                .resolve(".settings/org.eclipse.jdt.core.prefs")),
446            GENERATED_BY)) {
447            props.store(out, "");
448        } catch (IOException e) {
449            throw new BuildException(
450                "Cannot write eclipse settings: " + e.getMessage());
451        }
452    }
453
454    /// Allow the user to adapt the properties for the
455    /// `.settings/org.eclipse.jdt.core.prefs` file.
456    ///
457    /// @param adaptor the adaptor
458    /// @return the eclipse configurator
459    ///
460    public EclipseConfigurator adaptJdtCorePrefs(Consumer<Properties> adaptor) {
461        jdtCorePrefsAdaptor = adaptor;
462        return this;
463    }
464
465    /// Allow the user to add additional resources.
466    ///
467    /// @param adaptor the adaptor
468    /// @return the eclipse configurator
469    ///
470    public EclipseConfigurator adaptConfiguration(Runnable adaptor) {
471        configurationAdaptor = adaptor;
472        return this;
473    }
474
475}