From 00e4649f18d3cfa1ec4228c6c389f5db7ebdd7ae Mon Sep 17 00:00:00 2001 From: Alex Turc Date: Tue, 25 Jun 2024 13:15:57 -0600 Subject: [PATCH] Regex based name transformations for options in Mixins to allow for Mixin reuse in the same command. Addresses issue https://github.com/remkop/picocli/issues/2310 # Please enter the commit message for your changes. Lines starting --- docs/index.adoc | 66 +++++++++++++++++++ .../annotation/processing/MixinInfo.java | 2 + .../annotation/processing/TypedMember.java | 4 ++ src/main/java/picocli/CommandLine.java | 44 ++++++++++++- src/test/java/picocli/MixinTest.java | 20 ++++++ src/test/java/picocli/ModelArgSpecTest.java | 1 + 6 files changed, 136 insertions(+), 1 deletion(-) diff --git a/docs/index.adoc b/docs/index.adoc index eba28c9d3..42539c23a 100644 --- a/docs/index.adoc +++ b/docs/index.adoc @@ -9595,6 +9595,72 @@ class MyApp : Runnable { } ---- +==== Reusing a Mixin within the same command + +Here is an example on how a Mixin can be reused in the same command. +Option names within the mixin can be transformed using regular expressions to create a command line without +name collisions. + +.Java +[source,java,role="primary"] +---- +public class DbParams { + + @Option(names={"--url"}) + private String url; + + @Option(names={"--user"}) + private String user; + + @Option(names={"--pass"}) + private String pass; +} + +@Command(name = "compare", description = "Example reuse with @Mixin annotation.") +public class MyCommand { + + // Adds the options defined in DbParams to this command with names transformed. + // The options exposed by this Mixin will be --firstDb.url, --firstDb.user, --firstDb.pass + @Mixin(optionNameTransformations = {"^--(.*)$", "--firstDb.$1"}) + private DbParams firstDb; + + // Adds the options defined in DbParams to this command with names transformed. + // The options exposed by this Mixin will be --secondDb.url, --secondDb.user, --secondDb.pass + @Mixin(optionNameTransformations = {"^--(.*)$", "--secondDb.$1"}) + private DbParams secondDb; +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +public class DbParams { + + @Option(names = ["--url"]) + private url: String + + @Option(names = ["--user"]) + private user: String + + @Option(names = ["--pass"]) + private pass: String +} + +@Command(name = "compare", description = ["Example reuse with @Mixin annotation."]) +public class MyCommand { + + // Adds the options defined in DbParams to this command with names transformed. + // The options exposed by this Mixin will be --firstDb.url, --firstDb.user, --firstDb.pass + @Mixin(optionNameTransformations = ["^--(.*)$", "--firstDb.$1"]) + var firstDb: DbParams + + // Adds the options defined in DbParams to this command with name transformed. + // The options exposed by this Mixin will be --secondDb.url, --secondDb.user, --secondDb.pass + @Mixin(optionNameTransformations = ["^--(.*)$", "--secondDb.$1"]) + var secondDb: DbParams +} +---- + ==== Accessing the Mixee from a Mixin Sometimes you need to write a Mixin class that accesses the mixee (the command where it is mixed in). diff --git a/picocli-codegen/src/main/java/picocli/codegen/annotation/processing/MixinInfo.java b/picocli-codegen/src/main/java/picocli/codegen/annotation/processing/MixinInfo.java index 76b469c61..020e625c7 100644 --- a/picocli-codegen/src/main/java/picocli/codegen/annotation/processing/MixinInfo.java +++ b/picocli-codegen/src/main/java/picocli/codegen/annotation/processing/MixinInfo.java @@ -16,6 +16,7 @@ */ class MixinInfo { private final String mixinName; + private final String[] optionNameTransformations; private final IAnnotatedElement annotatedElement; private final VariableElement element; private final CommandSpec mixin; @@ -29,6 +30,7 @@ public MixinInfo(VariableElement element, CommandSpec mixin) { name = element.getSimpleName().toString(); } this.mixinName = name; + this.optionNameTransformations = element.getAnnotation(CommandLine.Mixin.class).optionNameTransformations(); Element targetType = element.getEnclosingElement(); int position = -1; if (EnumSet.of(ElementKind.METHOD, ElementKind.CONSTRUCTOR).contains(targetType.getKind())) { diff --git a/picocli-codegen/src/main/java/picocli/codegen/annotation/processing/TypedMember.java b/picocli-codegen/src/main/java/picocli/codegen/annotation/processing/TypedMember.java index 84f2d3231..e928b3b45 100644 --- a/picocli-codegen/src/main/java/picocli/codegen/annotation/processing/TypedMember.java +++ b/picocli-codegen/src/main/java/picocli/codegen/annotation/processing/TypedMember.java @@ -114,6 +114,10 @@ public String getMixinName() { return empty(annotationName) ? getName() : annotationName; } + public String[] getOptionNameTransformations() { + return getAnnotation(CommandLine.Mixin.class).optionNameTransformations(); + } + static String propertyName(String methodName) { if (methodName.length() > 3 && (methodName.startsWith("get") || methodName.startsWith("set"))) { return decapitalize(methodName.substring(3)); } return decapitalize(methodName); diff --git a/src/main/java/picocli/CommandLine.java b/src/main/java/picocli/CommandLine.java index 87de0855b..f9fbf222e 100644 --- a/src/main/java/picocli/CommandLine.java +++ b/src/main/java/picocli/CommandLine.java @@ -38,8 +38,10 @@ import java.util.*; import java.util.concurrent.Callable; import java.util.concurrent.atomic.AtomicInteger; +import java.util.regex.MatchResult; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Collectors; import picocli.CommandLine.Help.Ansi.IStyle; import picocli.CommandLine.Help.Ansi.Style; @@ -4467,6 +4469,12 @@ public enum ScopeType { * If not specified the name of the annotated field is used. * @return a String to register the mixin object with, or an empty String if the name of the annotated field should be used */ String name() default ""; + /** Optionally specify regex based transformations for all options in a mixin. + * The array contains sequences of regex expression followed by a replacement expression. + * @return an array of transformations {@code {regex, replacement, regex, replacement, ...}}. + * @see java.util.regex.Pattern + * @see java.util.regex.Matcher#replaceFirst(String) */ + String[] optionNameTransformations() default {}; } /** * Fields annotated with {@code @Spec} will be initialized with the {@code CommandSpec} for the command the field is part of. Example usage: @@ -7427,6 +7435,36 @@ public CommandSpec mixinStandardHelpOptions(boolean newValue) { * @return this CommandSpec for method chaining */ public CommandSpec withToString(String newValue) { this.toString = newValue; return this; } + public CommandSpec withOptionNameTransformations(String[] transformations) { + if (!options.isEmpty() && transformations.length > 0) { + List optionsClone = new ArrayList<>(options); + Pattern[] patterns = new Pattern[transformations.length / 2]; + String[] replacements = new String[transformations.length / 2]; + for (int i = 0; i < transformations.length; i += 2) { + patterns[i] = Pattern.compile(transformations[i]); + replacements[i] = transformations[i + 1]; + } + optionsClone.forEach( + option -> { + remove(option); + String[] transformedNames = new String[option.names.length]; + for (int i = 0; i < option.names.length; i++) { + transformedNames[i] = option.names[i]; + for (int j = 0; j < patterns.length; j++) { + Matcher matcher = patterns[j].matcher(option.names[i]); + if (matcher.matches()) { + transformedNames[i] = matcher.replaceFirst(replacements[j]); + break; + } + } + } + addOption(option.toBuilder().names(transformedNames).build()); + } + ); + } + return this; + } + /** * Updates the following attributes from the specified {@code @Command} annotation: * aliases, {@link ParserSpec#separator() parser separator}, command name, version, help command, @@ -11347,6 +11385,7 @@ public interface IAnnotatedElement { T getAnnotation(Class annotationClass); String getName(); String getMixinName(); + String[] getOptionNameTransformations(); boolean isArgSpec(); boolean isOption(); boolean isParameter(); @@ -11539,6 +11578,9 @@ public String getMixinName() { String annotationName = getAnnotation(Mixin.class).name(); return empty(annotationName) ? getName() : annotationName; } + public String[] getOptionNameTransformations() { + return getAnnotation(Mixin.class).optionNameTransformations(); + } static String propertyName(String methodName) { if (methodName.length() > 3 && (methodName.startsWith("get") || methodName.startsWith("set"))) { return decapitalize(methodName.substring(3)); } return decapitalize(methodName); @@ -12058,7 +12100,7 @@ private static CommandSpec buildMixinForMember(IAnnotatedElement member, IFactor member.setter().set(userObject); } CommandSpec result = CommandSpec.forAnnotatedObject(userObject, factory); - return result.withToString(member.getToString()); + return result.withOptionNameTransformations(member.getOptionNameTransformations()).withToString(member.getToString()); } catch (InitializationException ex) { throw ex; } catch (Exception ex) { diff --git a/src/test/java/picocli/MixinTest.java b/src/test/java/picocli/MixinTest.java index 9a4e44e2d..f85f9c236 100644 --- a/src/test/java/picocli/MixinTest.java +++ b/src/test/java/picocli/MixinTest.java @@ -27,6 +27,7 @@ import java.io.File; import java.io.PrintStream; import java.io.UnsupportedEncodingException; +import java.util.List; import java.util.Map; import static org.junit.Assert.*; @@ -1100,4 +1101,23 @@ public void testIssue1836CommandAliasOnMixin() { Help help = new Help(new App_Issue1836()); assertEquals("list, ls", help.commandList().trim()); } + + @Test + public void testOptionNameTransformations() { + class ReusableMixin { + @Option(names = "--url") + String url; + } + class Application { + @Mixin(optionNameTransformations = {"^--(.*)$", "--firstDb.$1"}) + ReusableMixin firstDb; + @Mixin(optionNameTransformations = {"^--(.*)$", "--secondDb.$1"}) + ReusableMixin secondDb; + } + Application application = new Application(); + CommandLine commandLine = new CommandLine(application, new InnerClassFactory(this)); + List options = commandLine.getCommandSpec().options(); + assertArrayEquals(options.get(0).names(), new String[]{"--firstDb.url"}); + assertArrayEquals(options.get(1).names(), new String[]{"--secondDb.url"}); + } } diff --git a/src/test/java/picocli/ModelArgSpecTest.java b/src/test/java/picocli/ModelArgSpecTest.java index 58988cd33..e33881abd 100644 --- a/src/test/java/picocli/ModelArgSpecTest.java +++ b/src/test/java/picocli/ModelArgSpecTest.java @@ -243,6 +243,7 @@ private static class AnnotatedImpl implements CommandLine.Model.IAnnotatedElemen public T getAnnotation(Class annotationClass) { return null;} public String getName() {return name;} public String getMixinName() {return null;} + public String[] getOptionNameTransformations() {return null;} public boolean isArgSpec() {return false;} public boolean isOption() {return false;} public boolean isParameter() {return false;}