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