You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by da...@apache.org on 2026/10/17 07:39:40 UTC

(camel) branch fix/CAMEL-25717 created (now c09d54cf8207)

This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a change to branch fix/CAMEL-25717
in repository https://gitbox.apache.org/repos/asf/camel.git


      at c09d54cf8207 CAMEL-25717: camel-jbang - The docs of camel test init list its own options, not those of camel init

This branch includes the following new commits:

     new c09d54cf8207 CAMEL-25717: camel-jbang - The docs of camel test init list its own options, not those of camel init

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(camel) 01/01: CAMEL-25717: camel-jbang - The docs of camel test init list its own options, not those of camel init

Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch fix/CAMEL-25717
in repository https://gitbox.apache.org/repos/asf/camel.git

commit c09d54cf8207b6adec621e6757da1ff4508dbe17
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Sat Oct 17 09:39:27 2026 +0200

    CAMEL-25717: camel-jbang - The docs of camel test init list its own options, not those of camel init
    
    PrepareCamelJBangCommandsMojo found the source of a parent class (and of a mixin type) by its simple name, so TestInit, whose parent is the Init of Citrus, got the options of the Init of camel-jbang-core. A type that the class imports by name is now only taken from a source of that type, or else read from the classpath of the project (by reflection, as picocli is not a dependency of the plugin). A type that is not imported by name is looked up as before, as the package that Roaster re [...]
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <no...@anthropic.com>
    Signed-off-by: Claus Ibsen <cl...@gmail.com>
---
 .../jbang-commands/camel-jbang-test-init.adoc      |   9 +-
 .../camel-jbang-plugin-commands-metadata.json      |   2 +-
 .../packaging/PrepareCamelJBangCommandsMojo.java   | 135 ++++++++---
 .../PrepareCamelJBangCommandsMojoTest.java         | 254 +++++++++++++++++++++
 4 files changed, 359 insertions(+), 41 deletions(-)

diff --git a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-test-init.adoc b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-test-init.adoc
index b8f803053c14..197f4499bcf8 100644
--- a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-test-init.adoc
+++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-test-init.adoc
@@ -21,14 +21,7 @@ camel test init [options]
 [cols="2,5,1,2",options="header"]
 |===
 | Option | Description | Default | Type
-| `--clean-dir,--clean-directory` | Whether to clean directory first (deletes all files in directory) |  | boolean
-| `--dir,--directory` | Directory relative path where the new Camel integration will be saved | . | String
-| `--force` | Overwrite a file that exists already (by default the command fails instead) |  | boolean
-| `--from-kamelet` | To be used when extending an existing Kamelet |  | String
-| `--kamelets-version` | Apache Camel Kamelets version (auto-detected from classpath if not set) |  | String
-| `--list` | List available templates |  | boolean
-| `--pipe` | When creating a yaml file should it be created as a Pipe CR |  | boolean
-| `--repo,--repos` | Additional maven repositories (Use commas to separate multiple repositories) |  | String
+| `--directory` | Directory where the files will be created | . | String
 | `-h,--help` | Display the help and sub-commands |  | boolean
 |===
 
diff --git a/dsl/camel-jbang/camel-jbang-plugin-test/src/generated/resources/META-INF/camel-jbang-plugin-commands-metadata.json b/dsl/camel-jbang/camel-jbang-plugin-test/src/generated/resources/META-INF/camel-jbang-plugin-commands-metadata.json
index 36d3c46b9e14..173749a94911 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-test/src/generated/resources/META-INF/camel-jbang-plugin-commands-metadata.json
+++ b/dsl/camel-jbang/camel-jbang-plugin-test/src/generated/resources/META-INF/camel-jbang-plugin-commands-metadata.json
@@ -1,5 +1,5 @@
 {
   "commands": [
-    { "name": "test", "fullName": "test", "description": "Manage tests for Camel applications", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.test.TestCommand", "plugin": "test", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "init", "fullName": "test init", "description": "Creates a new Citrus test", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.test.T [...]
+    { "name": "test", "fullName": "test", "description": "Manage tests for Camel applications", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.test.TestCommand", "plugin": "test", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "init", "fullName": "test init", "description": "Creates a new Citrus test", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.test.T [...]
   ]
 }
diff --git a/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/PrepareCamelJBangCommandsMojo.java b/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/PrepareCamelJBangCommandsMojo.java
index 6cd3b9952277..69851da63e65 100644
--- a/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/PrepareCamelJBangCommandsMojo.java
+++ b/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/PrepareCamelJBangCommandsMojo.java
@@ -18,6 +18,8 @@ package org.apache.camel.maven.packaging;
 
 import java.io.File;
 import java.io.IOException;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Field;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
@@ -48,6 +50,7 @@ import org.jboss.forge.roaster.Roaster;
 import org.jboss.forge.roaster.model.JavaType;
 import org.jboss.forge.roaster.model.source.AnnotationSource;
 import org.jboss.forge.roaster.model.source.FieldSource;
+import org.jboss.forge.roaster.model.source.Import;
 import org.jboss.forge.roaster.model.source.JavaClassSource;
 import org.jboss.forge.roaster.model.source.JavaSource;
 
@@ -485,26 +488,7 @@ public class PrepareCamelJBangCommandsMojo extends AbstractGeneratorMojo {
         // First, parse options from parent class (if any)
         String superType = clazz.getSuperType();
         if (superType != null && !superType.equals("java.lang.Object") && !superType.equals("Object")) {
-            // Try to find and parse the parent class
-            String parentClassName = superType;
-            if (parentClassName.contains(".")) {
-                parentClassName = parentClassName.substring(parentClassName.lastIndexOf('.') + 1);
-            }
-
-            // Search for parent class file
-            File parentFile = findClassFile(baseDir, parentClassName);
-            if (parentFile != null && parentFile.exists()) {
-                try {
-                    JavaType<?> parsedParent = Roaster.parse(parentFile);
-                    if (parsedParent instanceof JavaClassSource parentClazz) {
-                        parseOptionsFromClassHierarchy(parentClazz, options, parentFile.getParentFile());
-                    } else {
-                        getLog().debug("Skipping options from non-class parent type: " + parentClassName);
-                    }
-                } catch (Exception e) {
-                    getLog().debug("Could not parse parent class: " + parentClassName + " - " + e.getMessage());
-                }
-            }
+            parseOptionsFromType(clazz, superType, options, baseDir, "parent");
         }
 
         // Then parse options from this class (will override parent options with same name)
@@ -534,22 +518,109 @@ public class PrepareCamelJBangCommandsMojo extends AbstractGeneratorMojo {
         // Also parse options from @CommandLine.Mixin fields
         for (FieldSource<JavaClassSource> field : clazz.getFields()) {
             if (hasMixinAnnotation(field)) {
-                String mixinTypeName = field.getType().getName();
-                File mixinFile = findClassFile(baseDir, mixinTypeName);
-                if (mixinFile != null && mixinFile.exists()) {
-                    try {
-                        JavaType<?> parsedMixin = Roaster.parse(mixinFile);
-                        if (parsedMixin instanceof JavaClassSource mixinClazz) {
-                            parseOptionsFromClassHierarchy(mixinClazz, options, mixinFile.getParentFile());
-                        } else {
-                            getLog().debug("Skipping options from non-class mixin type: " + mixinTypeName);
-                        }
-                    } catch (Exception e) {
-                        getLog().debug("Could not parse mixin class: " + mixinTypeName + " - " + e.getMessage());
+                parseOptionsFromType(clazz, field.getType().getName(), options, baseDir, "mixin");
+            }
+        }
+    }
+
+    /**
+     * Parses the options of a parent class or a mixin type of a class: from its source when it is one of the command
+     * classes, or else from its class on the classpath of the project, such as the class of a library that a plugin
+     * command extends. The source is found by the simple name of the type, so for a type that the class imports by name
+     * it is only taken when it is of that type, as a class of a library may have the simple name of a command of
+     * camel-jbang-core. (The package that Roaster resolves for a type that is not imported by name is not certain: with
+     * an import on demand of a class, it takes a class of the same package for a nested class of the imported one.)
+     */
+    private void parseOptionsFromType(JavaClassSource clazz, String type, List<OptionInfo> options, File baseDir, String kind) {
+        String simpleName = type.substring(type.lastIndexOf('.') + 1);
+        String imported = importedByName(clazz, simpleName);
+        File file = findClassFile(baseDir, simpleName);
+        if (file != null && file.exists()) {
+            try {
+                JavaType<?> parsed = Roaster.parse(file);
+                if (imported == null || imported.equals(parsed.getQualifiedName())) {
+                    if (parsed instanceof JavaClassSource source) {
+                        parseOptionsFromClassHierarchy(source, options, file.getParentFile());
+                    } else {
+                        getLog().debug("Skipping options from non-class " + kind + " type: " + simpleName);
                     }
+                    return;
                 }
+            } catch (Exception e) {
+                getLog().debug("Could not parse " + kind + " class: " + simpleName + " - " + e.getMessage());
+                return;
             }
         }
+        if (imported != null) {
+            parseOptionsFromClass(imported, options);
+        }
+    }
+
+    /**
+     * The qualified name of the type with the simple name that the class imports by name, or null when it does not.
+     */
+    private static String importedByName(JavaClassSource clazz, String simpleName) {
+        for (Import imported : clazz.getImports()) {
+            if (!imported.isWildcard() && !imported.isStatic() && imported.getSimpleName().equals(simpleName)) {
+                return imported.getQualifiedName();
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Parses the options of a class that is not one of the command classes from its picocli annotations (and those of
+     * its parent classes and mixins), with the classpath of the project. picocli is not a dependency of this plugin, so
+     * the annotations are read by reflection.
+     */
+    @SuppressWarnings("unchecked")
+    private void parseOptionsFromClass(String className, List<OptionInfo> options) {
+        try {
+            ClassLoader loader = getProjectClassLoader();
+            Class<? extends Annotation> optionType
+                    = (Class<? extends Annotation>) loader.loadClass("picocli.CommandLine$Option");
+            Class<? extends Annotation> mixinType
+                    = (Class<? extends Annotation>) loader.loadClass("picocli.CommandLine$Mixin");
+            parseOptionsFromClass(loader.loadClass(className), optionType, mixinType, options);
+        } catch (Exception | LinkageError e) {
+            getLog().debug("Could not read the options of class: " + className + " - " + e.getMessage());
+        }
+    }
+
+    private void parseOptionsFromClass(
+            Class<?> clazz, Class<? extends Annotation> optionType, Class<? extends Annotation> mixinType,
+            List<OptionInfo> options)
+            throws ReflectiveOperationException {
+        if (clazz.getSuperclass() != null && clazz.getSuperclass() != Object.class) {
+            parseOptionsFromClass(clazz.getSuperclass(), optionType, mixinType, options);
+        }
+        for (Field field : clazz.getDeclaredFields()) {
+            Annotation optionAnn = field.getAnnotation(optionType);
+            if (optionAnn != null) {
+                OptionInfo option = new OptionInfo();
+                option.names = String.join(",", (String[]) attribute(optionAnn, "names"));
+                option.description = sanitizeDescription(String.join(" ", (String[]) attribute(optionAnn, "description")));
+                String defaultValue = (String) attribute(optionAnn, "defaultValue");
+                // how picocli marks an option without a default value
+                if (!"__no_default_value__".equals(defaultValue)) {
+                    option.defaultValue = sanitizeDescription(defaultValue);
+                }
+                option.required = (Boolean) attribute(optionAnn, "required");
+                option.hidden = (Boolean) attribute(optionAnn, "hidden");
+                String paramLabel = (String) attribute(optionAnn, "paramLabel");
+                option.paramLabel = paramLabel.isEmpty() ? null : paramLabel;
+                option.javaType = field.getType().getTypeName();
+                option.deprecated = field.isAnnotationPresent(Deprecated.class);
+                options.removeIf(o -> o.names.equals(option.names));
+                options.add(option);
+            } else if (field.isAnnotationPresent(mixinType)) {
+                parseOptionsFromClass(field.getType(), optionType, mixinType, options);
+            }
+        }
+    }
+
+    private static Object attribute(Annotation annotation, String name) throws ReflectiveOperationException {
+        return annotation.annotationType().getMethod(name).invoke(annotation);
     }
 
     private File findClassFile(File dir, String className) {
diff --git a/tooling/maven/camel-package-maven-plugin/src/test/java/org/apache/camel/maven/packaging/PrepareCamelJBangCommandsMojoTest.java b/tooling/maven/camel-package-maven-plugin/src/test/java/org/apache/camel/maven/packaging/PrepareCamelJBangCommandsMojoTest.java
index 582e47054fa1..e0eec502e084 100644
--- a/tooling/maven/camel-package-maven-plugin/src/test/java/org/apache/camel/maven/packaging/PrepareCamelJBangCommandsMojoTest.java
+++ b/tooling/maven/camel-package-maven-plugin/src/test/java/org/apache/camel/maven/packaging/PrepareCamelJBangCommandsMojoTest.java
@@ -17,11 +17,20 @@
 package org.apache.camel.maven.packaging;
 
 import java.io.File;
+import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.stream.Stream;
 
+import javax.tools.JavaCompiler;
+import javax.tools.StandardJavaFileManager;
+import javax.tools.ToolProvider;
+
+import org.apache.camel.tooling.model.JBangCommandModel.JBangCommand;
+import org.apache.camel.tooling.model.JBangCommandModel.JBangCommandOption;
+import org.apache.camel.tooling.model.JsonMapper;
 import org.apache.maven.model.Build;
 import org.apache.maven.plugin.logging.Log;
 import org.apache.maven.plugin.logging.SystemStreamLog;
@@ -204,6 +213,251 @@ class PrepareCamelJBangCommandsMojoTest {
                 .isEmpty();
     }
 
+    /**
+     * CAMEL-25717: a plugin command whose parent class is of a library does not get the options of the command of
+     * camel-jbang-core with the simple name of that class, as camel test init (whose parent is the Init of Citrus) got
+     * the options of camel init.
+     */
+    @Test
+    void testPluginCommandDoesNotGetTheOptionsOfTheCoreCommandWithTheNameOfItsParent() throws Exception {
+        JBangCommand init = generatePluginWithLibraryParent();
+
+        assertThat(init.getOptions()).extracting(JBangCommandOption::getNames).doesNotContain("--from-kamelet");
+    }
+
+    /**
+     * CAMEL-25717: a plugin command whose parent class is of a library gets the options of that class, and of its
+     * parent classes, from the classpath of the project.
+     */
+    @Test
+    void testPluginCommandGetsTheOptionsOfTheLibraryClassItExtends() throws Exception {
+        compileLibrary();
+
+        JBangCommand init = generatePluginWithLibraryParent();
+
+        assertThat(init.getOptions()).extracting(JBangCommandOption::getNames).containsExactly("--directory", "-h,--help");
+        JBangCommandOption directory = init.getOptions().get(0);
+        assertThat(directory.getDescription()).isEqualTo("Directory where the files will be created");
+        assertThat(directory.getDefaultValue()).isEqualTo(".");
+        assertThat(directory.getJavaType()).isEqualTo("java.lang.String");
+    }
+
+    /**
+     * A parent class of the same package is found when the class has a static import on demand, for which Roaster
+     * resolves the parent class as a nested class of the imported one (as camel export, whose ExportBaseCommand has
+     * {@code import static ...CamelJBangConstants.*}).
+     */
+    @Test
+    void testParentOfTheSamePackageWithAStaticImportOnDemand() throws Exception {
+        Path commands = tempDir.resolve("commands");
+        write(commands.resolve("BaseCommand.java"), """
+                package org.apache.camel.dsl.jbang.core.commands;
+
+                import picocli.CommandLine.Option;
+
+                public abstract class BaseCommand {
+
+                    @Option(names = "--verbose", description = "Verbose output")
+                    boolean verbose;
+                }
+                """);
+        write(commands.resolve("Export.java"), """
+                package org.apache.camel.dsl.jbang.core.commands;
+
+                import picocli.CommandLine.Command;
+                import picocli.CommandLine.Option;
+
+                import static org.apache.camel.dsl.jbang.core.common.CamelJBangConstants.*;
+
+                @Command(name = "export", description = "Export to other runtimes")
+                public class Export extends BaseCommand {
+
+                    @Option(names = "--dir", description = "Directory where the project will be exported")
+                    String dir;
+                }
+                """);
+        write(commands.resolve("CamelJBangMain.java"), """
+                package org.apache.camel.dsl.jbang.core.commands;
+
+                import picocli.CommandLine;
+                import picocli.CommandLine.Command;
+
+                @Command(name = "camel")
+                public class CamelJBangMain {
+                    public static void main(String[] args) {
+                        CommandLine commandLine;
+                        commandLine = new CommandLine(new CamelJBangMain())
+                                .addSubcommand("export", new CommandLine(new Export()));
+                    }
+                }
+                """);
+
+        PrepareCamelJBangCommandsMojo mojo = new PrepareCamelJBangCommandsMojo(null, null);
+        mojo.commandsDir = commands.toFile();
+        mojo.outFolder = tempDir.resolve("generated-resources").toFile();
+        mojo.project = projectWithOutputDir(tempDir.resolve("classes").toFile());
+
+        mojo.execute();
+
+        String json = Files.readString(
+                tempDir.resolve("generated-resources").resolve(PrepareCamelJBangCommandsMojo.CORE_METADATA_FILE));
+        JBangCommand export = JsonMapper.generateJBangCommandModel(json).getCommands().get(0);
+        assertThat(export.getOptions()).extracting(JBangCommandOption::getNames).containsExactly("--dir", "--verbose");
+    }
+
+    /**
+     * A plugin as camel-jbang-plugin-test: camel test init, whose parent class Init is of a library, while
+     * camel-jbang-core has a command class Init too. Returns the generated test init command.
+     */
+    private JBangCommand generatePluginWithLibraryParent() throws Exception {
+        Path core = tempDir.resolve("core/commands");
+        write(core.resolve("Init.java"), """
+                package org.apache.camel.dsl.jbang.core.commands;
+
+                import picocli.CommandLine.Command;
+                import picocli.CommandLine.Option;
+
+                @Command(name = "init", description = "Creates a new Camel integration")
+                public class Init {
+
+                    @Option(names = "--from-kamelet", description = "To be used when extending an existing Kamelet")
+                    String fromKamelet;
+                }
+                """);
+        Path plugin = tempDir.resolve("plugin/commands");
+        write(plugin.resolve("test/TestPlugin.java"), """
+                package org.apache.camel.dsl.jbang.core.commands.test;
+
+                import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+                import org.apache.camel.dsl.jbang.core.common.CamelJBangPlugin;
+                import org.apache.camel.dsl.jbang.core.common.Plugin;
+                import picocli.CommandLine;
+
+                @CamelJBangPlugin(name = "camel-jbang-plugin-test", firstVersion = "4.14.0")
+                public class TestPlugin implements Plugin {
+
+                    @Override
+                    public void customize(CommandLine commandLine, CamelJBangMain main) {
+                        var cmd = new CommandLine(new TestCommand(main))
+                                .addSubcommand("init", new CommandLine(new TestInit(main)));
+
+                        commandLine.addSubcommand("test", cmd);
+                    }
+                }
+                """);
+        write(plugin.resolve("test/TestCommand.java"), """
+                package org.apache.camel.dsl.jbang.core.commands.test;
+
+                import picocli.CommandLine.Command;
+
+                @Command(name = "test", description = "Manage tests")
+                public class TestCommand {
+                }
+                """);
+        write(plugin.resolve("test/TestInit.java"), """
+                package org.apache.camel.dsl.jbang.core.commands.test;
+
+                import org.example.cli.Init;
+                import picocli.CommandLine.Command;
+
+                @Command(name = "init", description = "Creates a new test")
+                public class TestInit extends Init {
+                }
+                """);
+
+        PrepareCamelJBangCommandsMojo mojo = new PrepareCamelJBangCommandsMojo(null, null);
+        mojo.commandsDir = plugin.toFile();
+        mojo.coreCommandsDir = core.toFile();
+        mojo.outFolder = tempDir.resolve("generated-resources").toFile();
+        mojo.project = projectWithOutputDir(tempDir.resolve("classes").toFile());
+
+        mojo.execute();
+
+        String json = Files.readString(
+                tempDir.resolve("generated-resources").resolve(PrepareCamelJBangCommandsMojo.PLUGIN_METADATA_FILE));
+        JBangCommand test = JsonMapper.generateJBangCommandModel(json).getCommands().get(0);
+        assertThat(test.getSubcommands()).extracting(JBangCommand::getFullName).containsExactly("test init");
+        return test.getSubcommands().get(0);
+    }
+
+    /**
+     * Compiles the library of the command classes into the classes of the project. picocli is not a dependency of this
+     * plugin, so the library has the annotations of picocli that the plugin reads.
+     */
+    private void compileLibrary() throws IOException {
+        Path sources = tempDir.resolve("library");
+        write(sources.resolve("picocli/CommandLine.java"), """
+                package picocli;
+
+                import java.lang.annotation.ElementType;
+                import java.lang.annotation.Retention;
+                import java.lang.annotation.RetentionPolicy;
+                import java.lang.annotation.Target;
+
+                public class CommandLine {
+
+                    @Retention(RetentionPolicy.RUNTIME)
+                    @Target(ElementType.FIELD)
+                    public @interface Option {
+                        String[] names();
+
+                        String[] description() default {};
+
+                        String defaultValue() default "__no_default_value__";
+
+                        boolean required() default false;
+
+                        boolean hidden() default false;
+
+                        String paramLabel() default "";
+                    }
+
+                    @Retention(RetentionPolicy.RUNTIME)
+                    @Target(ElementType.FIELD)
+                    public @interface Mixin {
+                    }
+                }
+                """);
+        write(sources.resolve("org/example/cli/LibraryCommand.java"), """
+                package org.example.cli;
+
+                import picocli.CommandLine;
+
+                public abstract class LibraryCommand {
+
+                    @CommandLine.Option(names = { "-h", "--help" }, description = "Display the help and sub-commands")
+                    boolean help;
+                }
+                """);
+        write(sources.resolve("org/example/cli/Init.java"), """
+                package org.example.cli;
+
+                import picocli.CommandLine;
+
+                public class Init extends LibraryCommand {
+
+                    @CommandLine.Option(names = "--directory", description = "Directory where the files will be created",
+                                        defaultValue = ".")
+                    String directory;
+                }
+                """);
+        Path classes = tempDir.resolve("classes");
+        Files.createDirectories(classes);
+        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
+        try (StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null);
+             Stream<Path> files = Files.walk(sources)) {
+            List<File> javaFiles = files.filter(f -> f.toString().endsWith(".java")).map(Path::toFile).toList();
+            Boolean compiled = compiler.getTask(null, fileManager, null, List.of("-d", classes.toString()), null,
+                    fileManager.getJavaFileObjectsFromFiles(javaFiles)).call();
+            assertThat(compiled).isTrue();
+        }
+    }
+
+    private static void write(Path file, String content) throws IOException {
+        Files.createDirectories(file.getParent());
+        Files.writeString(file, content);
+    }
+
     private static MavenProject projectWithOutputDir(File outputDir) {
         outputDir.mkdirs();
         Build build = new Build();