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/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/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/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/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/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")) 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; + } + +}