From b60c8e220855519c731806ce4735155c6453a160 Mon Sep 17 00:00:00 2001 From: Toshiaki Maki Date: Tue, 25 Aug 2026 13:36:01 +0900 Subject: [PATCH 1/2] Detect existing compiler arguments regardless of their element name The items of maven-compiler-plugin's compilerArgs are a plain list, so any element name is accepted and is commonly used instead of . The plugin only inspected the children named "arg", so an ErrorProne argument declared as was not found and a second -Xplugin:ErrorProne argument was added, which makes javac fail with "plug-in not found: ErrorProne". Co-Authored-By: Claude Opus 5 (1M context) --- src/it/errorprone-arg-element-name/pom.xml | 51 +++++++++++++++++++ .../src/main/java/com/example/Greeter.java | 18 +++++++ .../errorprone-arg-element-name/verify.groovy | 4 ++ .../maven/nullability/CompilerConfigurer.java | 13 ++++- .../nullability/CompilerConfigurerTest.java | 32 ++++++++++++ 5 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 src/it/errorprone-arg-element-name/pom.xml create mode 100644 src/it/errorprone-arg-element-name/src/main/java/com/example/Greeter.java create mode 100644 src/it/errorprone-arg-element-name/verify.groovy diff --git a/src/it/errorprone-arg-element-name/pom.xml b/src/it/errorprone-arg-element-name/pom.xml new file mode 100644 index 0000000..604eec3 --- /dev/null +++ b/src/it/errorprone-arg-element-name/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + com.example + errorprone-arg-element-name + 1.0-SNAPSHOT + + + 17 + UTF-8 + + + + + org.jspecify + jspecify + 1.0.0 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.15.0 + + + + -Xplugin:ErrorProne -Xep:MissingOverride:ERROR + + + + + am.ik.maven + nullability-maven-plugin + @project.version@ + true + + + + configure + + + + + + + diff --git a/src/it/errorprone-arg-element-name/src/main/java/com/example/Greeter.java b/src/it/errorprone-arg-element-name/src/main/java/com/example/Greeter.java new file mode 100644 index 0000000..a69b3ab --- /dev/null +++ b/src/it/errorprone-arg-element-name/src/main/java/com/example/Greeter.java @@ -0,0 +1,18 @@ +package com.example; + +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class Greeter { + + private final String name; + + public Greeter(String name) { + this.name = name; + } + + public String greet() { + return "Hello, " + this.name + "!"; + } + +} diff --git a/src/it/errorprone-arg-element-name/verify.groovy b/src/it/errorprone-arg-element-name/verify.groovy new file mode 100644 index 0000000..1d7c5aa --- /dev/null +++ b/src/it/errorprone-arg-element-name/verify.groovy @@ -0,0 +1,4 @@ +def buildLog = new File(basedir, "build.log").text +assert buildLog.contains("[nullability] Configuring ErrorProne") : "Plugin should log configuration message" +// A duplicated -Xplugin:ErrorProne argument makes javac fail with "plug-in not found: ErrorProne" +assert buildLog.contains("BUILD SUCCESS") : "Build should succeed with an ErrorProne argument declared as " diff --git a/src/main/java/am/ik/maven/nullability/CompilerConfigurer.java b/src/main/java/am/ik/maven/nullability/CompilerConfigurer.java index 941174f..4b7279a 100644 --- a/src/main/java/am/ik/maven/nullability/CompilerConfigurer.java +++ b/src/main/java/am/ik/maven/nullability/CompilerConfigurer.java @@ -171,8 +171,17 @@ static String extractOptionPrefix(String option) { return option; } + /** + * Finds an existing compiler argument starting with the given prefix. Every child + * element is inspected because {@code compilerArgs} is a plain list: the + * {@code maven-compiler-plugin} accepts any element name (commonly {@code }, but + * {@code } is used as well) for its items. + * @param compilerArgs the {@code compilerArgs} element + * @param prefix the argument prefix to look for + * @return the matching element, or {@code null} if there is none + */ private static Xpp3Dom findArgByPrefix(Xpp3Dom compilerArgs, String prefix) { - for (Xpp3Dom child : compilerArgs.getChildren("arg")) { + for (Xpp3Dom child : compilerArgs.getChildren()) { if (child.getValue() != null && child.getValue().startsWith(prefix)) { return child; } @@ -283,7 +292,7 @@ private static Xpp3Dom getOrCreateChild(Xpp3Dom parent, String name) { } private static void addArgIfAbsent(Xpp3Dom compilerArgs, String value) { - for (Xpp3Dom child : compilerArgs.getChildren("arg")) { + for (Xpp3Dom child : compilerArgs.getChildren()) { if (value.equals(child.getValue())) { return; } diff --git a/src/test/java/am/ik/maven/nullability/CompilerConfigurerTest.java b/src/test/java/am/ik/maven/nullability/CompilerConfigurerTest.java index 1d6c81a..2fd657a 100644 --- a/src/test/java/am/ik/maven/nullability/CompilerConfigurerTest.java +++ b/src/test/java/am/ik/maven/nullability/CompilerConfigurerTest.java @@ -375,6 +375,38 @@ void doesNotOverrideExistingNullAwayOptions() throws Exception { assertThat(mergedArg).contains("-XepOpt:NullAway:CheckContracts=true"); } + @Test + void mergesIntoErrorProneArgDeclaredWithAnotherElementName() throws Exception { + MavenProject project = new MavenProject(); + project.setBuild(new Build()); + + Plugin compilerPlugin = new Plugin(); + compilerPlugin.setGroupId("org.apache.maven.plugins"); + compilerPlugin.setArtifactId("maven-compiler-plugin"); + Xpp3Dom config = new Xpp3Dom("configuration"); + Xpp3Dom compilerArgs = new Xpp3Dom("compilerArgs"); + // maven-compiler-plugin accepts any element name for the items of compilerArgs + Xpp3Dom existingArg = new Xpp3Dom("compilerArg"); + existingArg.setValue("-Xplugin:ErrorProne -XepOpt:NullAway:KnownInitializers=com.example.Service.init"); + compilerArgs.addChild(existingArg); + Xpp3Dom existingPolicyArg = new Xpp3Dom("compilerArg"); + existingPolicyArg.setValue("-XDcompilePolicy=simple"); + compilerArgs.addChild(existingPolicyArg); + config.addChild(compilerArgs); + compilerPlugin.setConfiguration(config); + project.getBuild().addPlugin(compilerPlugin); + + CompilerConfigurer.configure(project, NullabilityConfiguration.defaults()); + + Xpp3Dom updatedArgs = ((Xpp3Dom) compilerPlugin.getConfiguration()).getChild("compilerArgs"); + String[] argValues = Arrays.stream(updatedArgs.getChildren()).map(Xpp3Dom::getValue).toArray(String[]::new); + // A second -Xplugin:ErrorProne argument makes javac fail with "plug-in not found" + assertThat(argValues).filteredOn(arg -> arg.startsWith("-Xplugin:ErrorProne")).hasSize(1); + assertThat(argValues).filteredOn("-XDcompilePolicy=simple"::equals).hasSize(1); + assertThat(existingArg.getValue()).contains("-XepOpt:NullAway:KnownInitializers=com.example.Service.init") + .contains("-XepOpt:NullAway:CheckContracts=true"); + } + @Test void extractOptionPrefixForEqualsOption() { assertThat(CompilerConfigurer.extractOptionPrefix("-XepOpt:NullAway:OnlyNullMarked=true")) From 5d245591fa4687c66991cc394bb47069675d99bb Mon Sep 17 00:00:00 2001 From: Toshiaki Maki Date: Tue, 25 Aug 2026 13:36:07 +0900 Subject: [PATCH 2/2] Add nullAwayOptions to configure arbitrary NullAway options NullAway has many options that are not exposed as dedicated parameters, and adding -XepOpt:NullAway:... to maven-compiler-plugin by hand does not work: javac rejects it as an invalid flag unless it is part of the -Xplugin:ErrorProne argument. Options can now be set by name, without the -XepOpt:NullAway: prefix: com.example.SomeClass.init or as nullability.nullAwayOptions. Maven properties. An entry overrides the option of the same name derived from the other parameters, so no duplicated option is emitted. Option names and values must not contain whitespace because they are appended to a single -Xplugin:ErrorProne argument. Closes gh-55 Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 43 +++++++ src/it/nullaway-options/pom.xml | 50 ++++++++ .../src/main/java/com/example/Service.java | 20 +++ src/it/nullaway-options/verify.groovy | 3 + .../ik/maven/nullability/ConfigureMojo.java | 14 +++ .../nullability/NullAwayArgsBuilder.java | 50 ++++++-- .../nullability/NullabilityConfiguration.java | 45 ++++++- .../NullabilityLifecycleParticipant.java | 66 +++++++++- .../nullability/NullAwayArgsBuilderTest.java | 43 +++++++ .../NullabilityConfigurationTest.java | 17 +++ .../NullabilityLifecycleParticipantTest.java | 118 ++++++++++++++++++ 11 files changed, 453 insertions(+), 16 deletions(-) create mode 100644 src/it/nullaway-options/pom.xml create mode 100644 src/it/nullaway-options/src/main/java/com/example/Service.java create mode 100644 src/it/nullaway-options/verify.groovy create mode 100644 src/test/java/am/ik/maven/nullability/NullabilityLifecycleParticipantTest.java diff --git a/README.md b/README.md index 224f0e0..595e2ef 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ All configuration parameters can be set either in the plugin `` b | `nullAwaySeverity` | `nullability.nullAwaySeverity` | `error` | Severity for the NullAway check: `error`, `warn`, or `off` | | `requireExplicitNullMarkingSeverity` | `nullability.requireExplicitNullMarkingSeverity` | `error` | Severity for the `RequireExplicitNullMarking` check: `error`, `warn`, or `off` | | `addTypeAnnotationsToSymbol` | `nullability.addTypeAnnotationsToSymbol` | `true` | Add `-XDaddTypeAnnotationsToSymbol=true` to javac when JSpecify mode is on. Set to `false` if your JDK does not support this flag (e.g., Oracle JDK) | +| `nullAwayOptions` | `nullability.nullAwayOptions.` | | Additional NullAway options, passed as `-XepOpt:NullAway:=` (see [Setting arbitrary NullAway options](#setting-arbitrary-nullaway-options)) | | `skip` | `nullability.skip` | `false` | Skip the plugin | Since NullAway 0.12.11, any annotation with the simple name `@Contract` is automatically recognized regardless of package (e.g. `org.springframework.lang.Contract`, `org.assertj.core.internal.annotation.Contract`). The `customContractAnnotations` parameter is only needed when: @@ -132,6 +133,48 @@ Since NullAway 0.12.11, any annotation with the simple name `@Contract` is autom - Using a contract annotation whose simple name is not `Contract` - Using an older NullAway version (pre-0.12.11) that requires explicit registration +### Setting arbitrary NullAway options + +NullAway has many [options](https://github.com/uber/NullAway/wiki/Configuration) that this plugin does not expose as dedicated parameters. Any of them can be set with `nullAwayOptions`, using the option name without the `-XepOpt:NullAway:` prefix as the element name: + +```xml + + am.ik.maven + nullability-maven-plugin + 0.4.3 + true + + + com.example.api.SomeClass.init + true + + + + + + configure + + + + +``` + +The options above are appended to the ErrorProne argument as `-XepOpt:NullAway:KnownInitializers=com.example.api.SomeClass.init -XepOpt:NullAway:TreatGeneratedAsUnannotated=true`, for both main and test compilation. + +The same options can be set as Maven properties by prefixing the option name with `nullability.nullAwayOptions.`: + +```xml + + com.example.api.SomeClass.init + +``` + +An entry in the plugin `` wins over the property with the same option name, and both win over the option that the plugin derives from the other parameters (for example `` overrides `customContractAnnotations`). + +Option names and values must not contain whitespace: all options are appended to a single `-Xplugin:ErrorProne` argument. The build fails with an explicit message if they do. + +Note that adding `-XepOpt:NullAway:...` to the `` of `maven-compiler-plugin` does not work: javac rejects it as an invalid flag unless it is part of the `-Xplugin:ErrorProne` argument. + ### `generate-package-info` goal configuration The `generate-package-info` goal accepts the following additional parameters: diff --git a/src/it/nullaway-options/pom.xml b/src/it/nullaway-options/pom.xml new file mode 100644 index 0000000..040aedc --- /dev/null +++ b/src/it/nullaway-options/pom.xml @@ -0,0 +1,50 @@ + + + 4.0.0 + com.example + nullaway-options + 1.0-SNAPSHOT + + + 17 + UTF-8 + + + + + org.jspecify + jspecify + 1.0.0 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.15.0 + + + am.ik.maven + nullability-maven-plugin + @project.version@ + true + + + com.example.Service.init + + + + + + configure + + + + + + + diff --git a/src/it/nullaway-options/src/main/java/com/example/Service.java b/src/it/nullaway-options/src/main/java/com/example/Service.java new file mode 100644 index 0000000..e7d7018 --- /dev/null +++ b/src/it/nullaway-options/src/main/java/com/example/Service.java @@ -0,0 +1,20 @@ +package com.example; + +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class Service { + + private String greeting; + + // Without -XepOpt:NullAway:KnownInitializers, NullAway reports that the field + // 'greeting' is not initialized. + public void init() { + this.greeting = "Hello"; + } + + public String greet(String name) { + return this.greeting + ", " + name + "!"; + } + +} diff --git a/src/it/nullaway-options/verify.groovy b/src/it/nullaway-options/verify.groovy new file mode 100644 index 0000000..b164126 --- /dev/null +++ b/src/it/nullaway-options/verify.groovy @@ -0,0 +1,3 @@ +def buildLog = new File(basedir, "build.log").text +assert buildLog.contains("[nullability] Configuring ErrorProne") : "Plugin should log configuration message" +assert buildLog.contains("BUILD SUCCESS") : "KnownInitializers passed via nullAwayOptions should make the build succeed" diff --git a/src/main/java/am/ik/maven/nullability/ConfigureMojo.java b/src/main/java/am/ik/maven/nullability/ConfigureMojo.java index 752732a..d05cd8b 100644 --- a/src/main/java/am/ik/maven/nullability/ConfigureMojo.java +++ b/src/main/java/am/ik/maven/nullability/ConfigureMojo.java @@ -15,6 +15,8 @@ */ package am.ik.maven.nullability; +import java.util.Map; + import org.apache.maven.plugin.AbstractMojo; import org.apache.maven.plugins.annotations.LifecyclePhase; import org.apache.maven.plugins.annotations.Mojo; @@ -114,6 +116,18 @@ public ConfigureMojo() { @Parameter(property = "nullability.requireExplicitNullMarkingSeverity", defaultValue = "error") private String requireExplicitNullMarkingSeverity; + /** + * Additional NullAway options keyed by option name. Each entry is passed to + * ErrorProne as {@code -XepOpt:NullAway:=}, so that any NullAway option + * can be set without configuring the {@code maven-compiler-plugin} by hand. An entry + * overrides the option of the same name derived from the other parameters. Option + * names and values must not contain whitespace. + * + * @since 0.5.0 + */ + @Parameter + private Map nullAwayOptions; + /** * Whether to skip the plugin execution. */ diff --git a/src/main/java/am/ik/maven/nullability/NullAwayArgsBuilder.java b/src/main/java/am/ik/maven/nullability/NullAwayArgsBuilder.java index 31c68db..3c7f5e9 100644 --- a/src/main/java/am/ik/maven/nullability/NullAwayArgsBuilder.java +++ b/src/main/java/am/ik/maven/nullability/NullAwayArgsBuilder.java @@ -16,13 +16,20 @@ package am.ik.maven.nullability; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; /** * Builds the {@code -Xplugin:ErrorProne} argument string for main or test compilation. */ public final class NullAwayArgsBuilder { + /** + * Prefix of a NullAway option passed to ErrorProne. + */ + static final String NULLAWAY_OPTION_PREFIX = "-XepOpt:NullAway:"; + private NullAwayArgsBuilder() { } @@ -53,19 +60,8 @@ public static String build(boolean forTests, NullabilityConfiguration config) { */ static List buildNullAwayOptions(boolean forTests, NullabilityConfiguration config) { List options = new ArrayList<>(); - options.add("-XepOpt:NullAway:OnlyNullMarked=true"); - options.add("-XepOpt:NullAway:CheckContracts=true"); - if (config.jspecifyMode()) { - options.add("-XepOpt:NullAway:JSpecifyMode=true"); - } - - if (config.customContractAnnotations() != null && !config.customContractAnnotations().isEmpty()) { - options.add("-XepOpt:NullAway:CustomContractAnnotations=" + config.customContractAnnotations()); - } - - if (forTests) { - options.add("-XepOpt:NullAway:HandleTestAssertionLibraries=true"); - } + buildNullAwayOptionMap(forTests, config) + .forEach((name, value) -> options.add(NULLAWAY_OPTION_PREFIX + name + "=" + value)); options.add("-Xep:NullAway:" + config.nullAwaySeverity().name()); @@ -81,6 +77,34 @@ static List buildNullAwayOptions(boolean forTests, NullabilityConfigurat return options; } + /** + * Builds the {@code -XepOpt:NullAway:*} options keyed by option name. The options + * configured via {@link NullabilityConfiguration#nullAwayOptions()} are applied last + * so that they override the ones derived from the other parameters. + * @param forTests whether this is for test compilation + * @param config the nullability configuration + * @return the NullAway options keyed by option name, in emission order + */ + private static Map buildNullAwayOptionMap(boolean forTests, NullabilityConfiguration config) { + Map options = new LinkedHashMap<>(); + options.put("OnlyNullMarked", "true"); + options.put("CheckContracts", "true"); + if (config.jspecifyMode()) { + options.put("JSpecifyMode", "true"); + } + + if (config.customContractAnnotations() != null && !config.customContractAnnotations().isEmpty()) { + options.put("CustomContractAnnotations", config.customContractAnnotations()); + } + + if (forTests) { + options.put("HandleTestAssertionLibraries", "true"); + } + + options.putAll(config.nullAwayOptions()); + return options; + } + static String buildExcludedPaths(boolean forTests, NullabilityConfiguration config) { List patterns = new ArrayList<>(); if (!forTests) { diff --git a/src/main/java/am/ik/maven/nullability/NullabilityConfiguration.java b/src/main/java/am/ik/maven/nullability/NullabilityConfiguration.java index 4759aa6..a665d1f 100644 --- a/src/main/java/am/ik/maven/nullability/NullabilityConfiguration.java +++ b/src/main/java/am/ik/maven/nullability/NullabilityConfiguration.java @@ -18,6 +18,9 @@ import java.io.IOException; import java.io.InputStream; import java.io.UncheckedIOException; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.Properties; /** @@ -41,11 +44,22 @@ * @param addTypeAnnotationsToSymbol whether to add the * {@code -XDaddTypeAnnotationsToSymbol=true} javac argument when JSpecify mode is enabled * (since 0.4.0) + * @param nullAwayOptions additional NullAway options keyed by option name, emitted as + * {@code -XepOpt:NullAway:=}. Entries override the options derived from the + * other parameters (since 0.5.0) */ public record NullabilityConfiguration(String errorProneVersion, String nullAwayVersion, Checking checking, boolean requireExplicitNullMarking, String customContractAnnotations, boolean jspecifyMode, String excludedPaths, Severity nullAwaySeverity, Severity requireExplicitNullMarkingSeverity, - boolean addTypeAnnotationsToSymbol) { + boolean addTypeAnnotationsToSymbol, Map nullAwayOptions) { + + /** + * Canonical constructor that defensively copies {@code nullAwayOptions}. + */ + public NullabilityConfiguration { + nullAwayOptions = (nullAwayOptions == null) ? Map.of() + : Collections.unmodifiableMap(new LinkedHashMap<>(nullAwayOptions)); + } private static final Properties DEFAULTS = loadDefaults(); @@ -120,6 +134,8 @@ public static final class Builder { private boolean addTypeAnnotationsToSymbol = true; + private final Map nullAwayOptions = new LinkedHashMap<>(); + private Builder() { } @@ -228,6 +244,31 @@ public Builder addTypeAnnotationsToSymbol(boolean addTypeAnnotationsToSymbol) { return this; } + /** + * Adds additional NullAway options keyed by option name. Each entry is emitted as + * {@code -XepOpt:NullAway:=} and overrides the option of the same + * name derived from the other parameters. + * @param nullAwayOptions additional NullAway options + * @return this builder + * @since 0.5.0 + */ + public Builder nullAwayOptions(Map nullAwayOptions) { + this.nullAwayOptions.putAll(nullAwayOptions); + return this; + } + + /** + * Adds a single additional NullAway option. + * @param name the option name without the {@code -XepOpt:NullAway:} prefix + * @param value the option value + * @return this builder + * @since 0.5.0 + */ + public Builder nullAwayOption(String name, String value) { + this.nullAwayOptions.put(name, value); + return this; + } + /** * Builds a new {@link NullabilityConfiguration} from the current builder state. * @return a new {@link NullabilityConfiguration} @@ -236,7 +277,7 @@ public NullabilityConfiguration build() { return new NullabilityConfiguration(this.errorProneVersion, this.nullAwayVersion, this.checking, this.requireExplicitNullMarking, this.customContractAnnotations, this.jspecifyMode, this.excludedPaths, this.nullAwaySeverity, this.requireExplicitNullMarkingSeverity, - this.addTypeAnnotationsToSymbol); + this.addTypeAnnotationsToSymbol, this.nullAwayOptions); } } diff --git a/src/main/java/am/ik/maven/nullability/NullabilityLifecycleParticipant.java b/src/main/java/am/ik/maven/nullability/NullabilityLifecycleParticipant.java index 57e63de..bc7e262 100644 --- a/src/main/java/am/ik/maven/nullability/NullabilityLifecycleParticipant.java +++ b/src/main/java/am/ik/maven/nullability/NullabilityLifecycleParticipant.java @@ -15,7 +15,9 @@ */ package am.ik.maven.nullability; +import java.util.LinkedHashMap; import java.util.Locale; +import java.util.Map; import javax.inject.Named; import javax.inject.Singleton; @@ -49,6 +51,8 @@ public NullabilityLifecycleParticipant() { private static final String PLUGIN_ARTIFACT_ID = "nullability-maven-plugin"; + private static final String NULLAWAY_OPTIONS_PROPERTY_PREFIX = "nullability.nullAwayOptions."; + private final Logger logger = LoggerFactory.getLogger(NullabilityLifecycleParticipant.class); @Override @@ -90,7 +94,7 @@ private Plugin findNullabilityPlugin(MavenProject project) { return null; } - private NullabilityConfiguration parseConfiguration(Plugin plugin, MavenProject project) { + NullabilityConfiguration parseConfiguration(Plugin plugin, MavenProject project) throws MavenExecutionException { Xpp3Dom config = (Xpp3Dom) plugin.getConfiguration(); if (getBooleanValue(config, "skip", resolveProperty(project, "nullability.skip", "false"))) { @@ -125,9 +129,69 @@ private NullabilityConfiguration parseConfiguration(Plugin plugin, MavenProject .toUpperCase(Locale.ROOT))) .addTypeAnnotationsToSymbol(getBooleanValue(config, "addTypeAnnotationsToSymbol", resolveProperty(project, "nullability.addTypeAnnotationsToSymbol", "true"))) + .nullAwayOptions(parseNullAwayOptions(config, project)) .build(); } + /** + * Collects the additional NullAway options from the {@code } + * configuration element and from the {@code nullability.nullAwayOptions.*} project + * properties. The configuration element wins over the property of the same option + * name. + * @param config the plugin configuration, may be {@code null} + * @param project the Maven project + * @return the additional NullAway options keyed by option name + * @throws MavenExecutionException if an option name or value is not usable as an + * ErrorProne option + */ + private Map parseNullAwayOptions(Xpp3Dom config, MavenProject project) + throws MavenExecutionException { + Map options = new LinkedHashMap<>(); + for (String propertyName : project.getProperties().stringPropertyNames()) { + if (propertyName.startsWith(NULLAWAY_OPTIONS_PROPERTY_PREFIX)) { + options.put(propertyName.substring(NULLAWAY_OPTIONS_PROPERTY_PREFIX.length()), + project.getProperties().getProperty(propertyName)); + } + } + Xpp3Dom optionsConfig = (config != null) ? config.getChild("nullAwayOptions") : null; + if (optionsConfig != null) { + for (Xpp3Dom option : optionsConfig.getChildren()) { + options.put(option.getName(), option.getValue()); + } + } + Map validated = new LinkedHashMap<>(); + for (Map.Entry entry : options.entrySet()) { + String name = trimToEmpty(entry.getKey()); + String value = trimToEmpty(entry.getValue()); + validateNullAwayOption(name, value, project); + validated.put(name, value); + } + return validated; + } + + private void validateNullAwayOption(String name, String value, MavenProject project) + throws MavenExecutionException { + if (name.isEmpty() || value.isEmpty()) { + throw new MavenExecutionException("[nullability] A nullAwayOptions entry must have a name and a value" + + " but was '" + name + "'='" + value + "'.", project.getFile()); + } + if (containsWhitespace(name) || containsWhitespace(value) || name.indexOf('=') >= 0) { + throw new MavenExecutionException( + "[nullability] The nullAwayOptions entry '" + name + "'='" + value + + "' cannot be passed to ErrorProne because the option name or value contains" + + " whitespace or '='. Options are appended to a single -Xplugin:ErrorProne argument.", + project.getFile()); + } + } + + private static boolean containsWhitespace(String value) { + return value.chars().anyMatch(Character::isWhitespace); + } + + private static String trimToEmpty(String value) { + return (value != null) ? value.trim() : ""; + } + private String resolveProperty(MavenProject project, String propertyName, String defaultValue) { String value = project.getProperties().getProperty(propertyName); return (value != null) ? value : defaultValue; diff --git a/src/test/java/am/ik/maven/nullability/NullAwayArgsBuilderTest.java b/src/test/java/am/ik/maven/nullability/NullAwayArgsBuilderTest.java index e956fc2..4b4f70c 100644 --- a/src/test/java/am/ik/maven/nullability/NullAwayArgsBuilderTest.java +++ b/src/test/java/am/ik/maven/nullability/NullAwayArgsBuilderTest.java @@ -15,7 +15,9 @@ */ package am.ik.maven.nullability; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; @@ -219,6 +221,47 @@ void requireExplicitNullMarkingSeverityWarn() { assertThat(result).doesNotContain("-Xep:RequireExplicitNullMarking:ERROR"); } + @Test + void nullAwayOptionIsAppended() { + NullabilityConfiguration config = NullabilityConfiguration.builder() + .nullAwayOption("KnownInitializers", "com.example.Service.init") + .build(); + String result = NullAwayArgsBuilder.build(false, config); + assertThat(result).contains("-XepOpt:NullAway:KnownInitializers=com.example.Service.init"); + } + + @Test + void nullAwayOptionsArePassedToTestCompilation() { + NullabilityConfiguration config = NullabilityConfiguration.builder() + .nullAwayOption("TreatGeneratedAsUnannotated", "true") + .build(); + String result = NullAwayArgsBuilder.build(true, config); + assertThat(result).contains("-XepOpt:NullAway:TreatGeneratedAsUnannotated=true"); + assertThat(result).contains("-XepOpt:NullAway:HandleTestAssertionLibraries=true"); + } + + @Test + void nullAwayOptionOverridesDerivedOption() { + NullabilityConfiguration config = NullabilityConfiguration.builder() + .customContractAnnotations("com.example.MyContract") + .nullAwayOption("CustomContractAnnotations", "com.example.OtherContract") + .build(); + List options = NullAwayArgsBuilder.buildNullAwayOptions(false, config); + assertThat(options).contains("-XepOpt:NullAway:CustomContractAnnotations=com.example.OtherContract") + .doesNotContain("-XepOpt:NullAway:CustomContractAnnotations=com.example.MyContract"); + } + + @Test + void nullAwayOptionsKeepTheConfiguredOrder() { + Map options = new LinkedHashMap<>(); + options.put("AcknowledgeRestrictiveAnnotations", "true"); + options.put("TreatGeneratedAsUnannotated", "true"); + NullabilityConfiguration config = NullabilityConfiguration.builder().nullAwayOptions(options).build(); + String result = NullAwayArgsBuilder.build(false, config); + assertThat(result).contains("-XepOpt:NullAway:AcknowledgeRestrictiveAnnotations=true" + + " -XepOpt:NullAway:TreatGeneratedAsUnannotated=true"); + } + @Test void requireExplicitNullMarkingSeverityIgnoredWhenDisabled() { NullabilityConfiguration config = NullabilityConfiguration.builder() diff --git a/src/test/java/am/ik/maven/nullability/NullabilityConfigurationTest.java b/src/test/java/am/ik/maven/nullability/NullabilityConfigurationTest.java index cd41370..d9debe3 100644 --- a/src/test/java/am/ik/maven/nullability/NullabilityConfigurationTest.java +++ b/src/test/java/am/ik/maven/nullability/NullabilityConfigurationTest.java @@ -15,9 +15,14 @@ */ package am.ik.maven.nullability; +import java.util.LinkedHashMap; +import java.util.Map; + import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.entry; class NullabilityConfigurationTest { @@ -32,6 +37,7 @@ void defaultsHaveExpectedValues() { assertThat(config.jspecifyMode()).isTrue(); assertThat(config.excludedPaths()).isEqualTo(".*/target/generated-sources/.*"); assertThat(config.addTypeAnnotationsToSymbol()).isTrue(); + assertThat(config.nullAwayOptions()).isEmpty(); } @Test @@ -56,4 +62,15 @@ void customValuesArePreserved() { assertThat(config.addTypeAnnotationsToSymbol()).isFalse(); } + @Test + void nullAwayOptionsAreCopiedAndUnmodifiable() { + Map options = new LinkedHashMap<>(); + options.put("KnownInitializers", "com.example.Service.init"); + NullabilityConfiguration config = NullabilityConfiguration.builder().nullAwayOptions(options).build(); + options.put("TreatGeneratedAsUnannotated", "true"); + assertThat(config.nullAwayOptions()).containsExactly(entry("KnownInitializers", "com.example.Service.init")); + assertThatThrownBy(() -> config.nullAwayOptions().put("JSpecifyMode", "false")) + .isInstanceOf(UnsupportedOperationException.class); + } + } diff --git a/src/test/java/am/ik/maven/nullability/NullabilityLifecycleParticipantTest.java b/src/test/java/am/ik/maven/nullability/NullabilityLifecycleParticipantTest.java new file mode 100644 index 0000000..ad68a06 --- /dev/null +++ b/src/test/java/am/ik/maven/nullability/NullabilityLifecycleParticipantTest.java @@ -0,0 +1,118 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package am.ik.maven.nullability; + +import org.apache.maven.MavenExecutionException; +import org.apache.maven.model.Plugin; +import org.apache.maven.project.MavenProject; +import org.codehaus.plexus.util.xml.Xpp3Dom; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.entry; + +class NullabilityLifecycleParticipantTest { + + private final NullabilityLifecycleParticipant participant = new NullabilityLifecycleParticipant(); + + @Test + void noNullAwayOptionsByDefault() throws Exception { + NullabilityConfiguration config = this.participant.parseConfiguration(new Plugin(), new MavenProject()); + assertThat(config.nullAwayOptions()).isEmpty(); + } + + @Test + void nullAwayOptionsFromConfiguration() throws Exception { + Plugin plugin = pluginWithNullAwayOptions( + new String[][] { { "KnownInitializers", "com.example.Service.init,com.example.Other.setUp" }, + { "TreatGeneratedAsUnannotated", "true" } }); + + NullabilityConfiguration config = this.participant.parseConfiguration(plugin, new MavenProject()); + + assertThat(config.nullAwayOptions()).containsExactly( + entry("KnownInitializers", "com.example.Service.init,com.example.Other.setUp"), + entry("TreatGeneratedAsUnannotated", "true")); + } + + @Test + void nullAwayOptionValuesAreTrimmed() throws Exception { + Plugin plugin = pluginWithNullAwayOptions( + new String[][] { { "KnownInitializers", "\n\tcom.example.Service.init\n" } }); + + NullabilityConfiguration config = this.participant.parseConfiguration(plugin, new MavenProject()); + + assertThat(config.nullAwayOptions()).containsExactly(entry("KnownInitializers", "com.example.Service.init")); + } + + @Test + void nullAwayOptionsFromProjectProperties() throws Exception { + MavenProject project = new MavenProject(); + project.getProperties() + .setProperty("nullability.nullAwayOptions.KnownInitializers", "com.example.Service.init"); + + NullabilityConfiguration config = this.participant.parseConfiguration(new Plugin(), project); + + assertThat(config.nullAwayOptions()).containsExactly(entry("KnownInitializers", "com.example.Service.init")); + } + + @Test + void configurationOverridesProjectProperty() throws Exception { + MavenProject project = new MavenProject(); + project.getProperties().setProperty("nullability.nullAwayOptions.KnownInitializers", "com.example.Other.setUp"); + Plugin plugin = pluginWithNullAwayOptions( + new String[][] { { "KnownInitializers", "com.example.Service.init" } }); + + NullabilityConfiguration config = this.participant.parseConfiguration(plugin, project); + + assertThat(config.nullAwayOptions()).containsExactly(entry("KnownInitializers", "com.example.Service.init")); + } + + @Test + void rejectsNullAwayOptionWithoutValue() { + Plugin plugin = pluginWithNullAwayOptions(new String[][] { { "KnownInitializers", null } }); + + assertThatThrownBy(() -> this.participant.parseConfiguration(plugin, new MavenProject())) + .isInstanceOf(MavenExecutionException.class) + .hasMessageContaining("must have a name and a value"); + } + + @Test + void rejectsNullAwayOptionValueWithWhitespace() { + Plugin plugin = pluginWithNullAwayOptions( + new String[][] { { "KnownInitializers", "com.example.Service.init com.example.Other.setUp" } }); + + assertThatThrownBy(() -> this.participant.parseConfiguration(plugin, new MavenProject())) + .isInstanceOf(MavenExecutionException.class) + .hasMessageContaining("contains") + .hasMessageContaining("whitespace"); + } + + private static Plugin pluginWithNullAwayOptions(String[][] options) { + Xpp3Dom configuration = new Xpp3Dom("configuration"); + Xpp3Dom nullAwayOptions = new Xpp3Dom("nullAwayOptions"); + for (String[] option : options) { + Xpp3Dom child = new Xpp3Dom(option[0]); + child.setValue(option[1]); + nullAwayOptions.addChild(child); + } + configuration.addChild(nullAwayOptions); + Plugin plugin = new Plugin(); + plugin.setConfiguration(configuration); + return plugin; + } + +}