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}