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 <[email protected]> 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) <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../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();
