diff --git a/java-checks-test-sources/default/src/main/java/checks/UnsupportedChronoUnitWithInstantCheckSample.java b/java-checks-test-sources/default/src/main/java/checks/UnsupportedChronoUnitWithInstantCheckSample.java new file mode 100644 index 00000000000..7690dd44930 --- /dev/null +++ b/java-checks-test-sources/default/src/main/java/checks/UnsupportedChronoUnitWithInstantCheckSample.java @@ -0,0 +1,143 @@ +package checks; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.time.temporal.Temporal; +import java.time.temporal.TemporalUnit; + +import static java.time.temporal.ChronoUnit.YEARS; +import static checks.UnsupportedChronoUnitWithInstantCheckSample.CustomUnit.MONTHS; + +class UnsupportedChronoUnitWithInstantCheckSample { + + private static final TemporalUnit STORED_UNIT = ChronoUnit.MONTHS; + + void unsupported(Instant instant, Instant end) { + instant.plus(1, ChronoUnit.WEEKS); // Noncompliant {{"WEEKS" is unsupported by Instant and causes an UnsupportedTemporalTypeException.}} +// ^^^^^^^^^^^^^^^^ + instant.plus(1, ChronoUnit.MONTHS); // Noncompliant {{"MONTHS" is unsupported by Instant and causes an UnsupportedTemporalTypeException.}} +// ^^^^^^^^^^^^^^^^^ + instant.plus(1, YEARS); // Noncompliant {{"YEARS" is unsupported by Instant and causes an UnsupportedTemporalTypeException.}} +// ^^^^^ + instant.plus(1, ChronoUnit.DECADES); // Noncompliant + instant.plus(1, ChronoUnit.CENTURIES); // Noncompliant + instant.plus(1, java.time.temporal.ChronoUnit.MILLENNIA); // Noncompliant + instant.plus(1, ChronoUnit.ERAS); // Noncompliant + instant.plus(1, ChronoUnit.FOREVER); // Noncompliant + + instant.minus(1, ChronoUnit.WEEKS); // Noncompliant + instant.minus(1, ChronoUnit.MONTHS); // Noncompliant + instant.minus(1, ChronoUnit.YEARS); // Noncompliant + instant.minus(1, ChronoUnit.DECADES); // Noncompliant + instant.minus(1, ChronoUnit.CENTURIES); // Noncompliant + instant.minus(1, ChronoUnit.MILLENNIA); // Noncompliant + instant.minus(1, ChronoUnit.ERAS); // Noncompliant + instant.minus(1, ChronoUnit.FOREVER); // Noncompliant + + instant.until(end, ChronoUnit.WEEKS); // Noncompliant + instant.until(end, (ChronoUnit.MONTHS)); // Noncompliant +// ^^^^^^^^^^^^^^^^^^^ + instant.until(end, ChronoUnit.YEARS); // Noncompliant + instant.until(end, ChronoUnit.DECADES); // Noncompliant + instant.until(end, ChronoUnit.CENTURIES); // Noncompliant + instant.until(end, ChronoUnit.MILLENNIA); // Noncompliant + instant.until(end, ChronoUnit.ERAS); // Noncompliant + instant.until(end, ChronoUnit.FOREVER); // Noncompliant + } + + void supported(Instant instant, Instant end) { + instant.plus(1, ChronoUnit.NANOS); + instant.plus(1, ChronoUnit.MICROS); + instant.plus(1, ChronoUnit.MILLIS); + instant.plus(1, ChronoUnit.SECONDS); + instant.plus(1, ChronoUnit.MINUTES); + instant.plus(1, ChronoUnit.HOURS); + instant.plus(1, ChronoUnit.HALF_DAYS); + instant.plus(1, ChronoUnit.DAYS); + + instant.minus(1, ChronoUnit.NANOS); + instant.minus(1, ChronoUnit.MICROS); + instant.minus(1, ChronoUnit.MILLIS); + instant.minus(1, ChronoUnit.SECONDS); + instant.minus(1, ChronoUnit.MINUTES); + instant.minus(1, ChronoUnit.HOURS); + instant.minus(1, ChronoUnit.HALF_DAYS); + instant.minus(1, ChronoUnit.DAYS); + + instant.until(end, ChronoUnit.NANOS); + instant.until(end, ChronoUnit.MICROS); + instant.until(end, ChronoUnit.MILLIS); + instant.until(end, ChronoUnit.SECONDS); + instant.until(end, ChronoUnit.MINUTES); + instant.until(end, ChronoUnit.HOURS); + instant.until(end, ChronoUnit.HALF_DAYS); + instant.until(end, ChronoUnit.DAYS); + } + + void excludedApis(Instant instant) { + instant.plus(Duration.ofDays(1)); + instant.minus(Duration.ofDays(1)); + instant.truncatedTo(ChronoUnit.WEEKS); + } + + void otherReceiver(ZonedDateTime dateTime, ZonedDateTime end) { + dateTime.plus(1, ChronoUnit.MONTHS); + dateTime.minus(1, ChronoUnit.YEARS); + dateTime.until(end, ChronoUnit.WEEKS); + } + + void indirectAndDynamic(Instant instant, Instant end, TemporalUnit unit, boolean condition) { + TemporalUnit localUnit = ChronoUnit.YEARS; + instant.plus(1, localUnit); + instant.minus(1, STORED_UNIT); + instant.until(end, unit); + instant.plus(1, condition ? ChronoUnit.MONTHS : ChronoUnit.DAYS); + } + + void customUnits(Instant instant, Instant end) { + instant.plus(1, CustomUnit.MONTHS); + instant.minus(1, MONTHS); + instant.until(end, CustomUnit.MONTHS); + } + + enum CustomUnit implements TemporalUnit { + MONTHS; + + @Override + public Duration getDuration() { + return Duration.ZERO; + } + + @Override + public boolean isDurationEstimated() { + return false; + } + + @Override + public boolean isDateBased() { + return false; + } + + @Override + public boolean isTimeBased() { + return true; + } + + @Override + public boolean isSupportedBy(Temporal temporal) { + return true; + } + + @Override + public R addTo(R temporal, long amount) { + return temporal; + } + + @Override + public long between(Temporal first, Temporal second) { + return 0; + } + } +} diff --git a/java-checks/src/main/java/org/sonar/java/checks/UnsupportedChronoUnitWithInstantCheck.java b/java-checks/src/main/java/org/sonar/java/checks/UnsupportedChronoUnitWithInstantCheck.java new file mode 100644 index 00000000000..b54f7a7edaf --- /dev/null +++ b/java-checks/src/main/java/org/sonar/java/checks/UnsupportedChronoUnitWithInstantCheck.java @@ -0,0 +1,103 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.checks; + +import java.util.Set; +import javax.annotation.Nullable; +import org.sonar.check.Rule; +import org.sonar.java.checks.methods.AbstractMethodDetection; +import org.sonar.java.model.ExpressionUtils; +import org.sonar.plugins.java.api.JavaVersion; +import org.sonar.plugins.java.api.JavaVersionAwareVisitor; +import org.sonar.plugins.java.api.semantic.MethodMatchers; +import org.sonar.plugins.java.api.semantic.Symbol; +import org.sonar.plugins.java.api.tree.ExpressionTree; +import org.sonar.plugins.java.api.tree.IdentifierTree; +import org.sonar.plugins.java.api.tree.MemberSelectExpressionTree; +import org.sonar.plugins.java.api.tree.MethodInvocationTree; + +@Rule(key = "S8218") +public class UnsupportedChronoUnitWithInstantCheck extends AbstractMethodDetection implements JavaVersionAwareVisitor { + + private static final String INSTANT = "java.time.Instant"; + private static final String TEMPORAL = "java.time.temporal.Temporal"; + private static final String TEMPORAL_UNIT = "java.time.temporal.TemporalUnit"; + private static final String CHRONO_UNIT = "java.time.temporal.ChronoUnit"; + + private static final Set UNSUPPORTED_UNITS = Set.of( + "WEEKS", + "MONTHS", + "YEARS", + "DECADES", + "CENTURIES", + "MILLENNIA", + "ERAS", + "FOREVER" + ); + + private static final MethodMatchers MATCHERS = MethodMatchers.create() + .ofTypes(INSTANT) + .names("plus", "minus") + .addParametersMatcher("long", TEMPORAL_UNIT) + .build(); + + private static final MethodMatchers UNTIL_MATCHER = MethodMatchers.create() + .ofTypes(INSTANT) + .names("until") + .addParametersMatcher(TEMPORAL, TEMPORAL_UNIT) + .build(); + + @Override + public boolean isCompatibleWithJavaVersion(JavaVersion version) { + return version.isJava8Compatible(); + } + + @Override + protected MethodMatchers getMethodInvocationMatchers() { + return MethodMatchers.or(MATCHERS, UNTIL_MATCHER); + } + + @Override + protected void onMethodInvocationFound(MethodInvocationTree mit) { + if (context.getSemanticModel() == null) { + return; + } + ExpressionTree argument = mit.arguments().get(1); + Symbol symbol = referencedSymbol(ExpressionUtils.skipParentheses(argument)); + if (isChronoUnitConstant(symbol) && UNSUPPORTED_UNITS.contains(symbol.name())) { + reportIssue(argument, String.format("\"%s\" is unsupported by Instant and causes an UnsupportedTemporalTypeException.", symbol.name())); + } + } + + private static @Nullable Symbol referencedSymbol(ExpressionTree argument) { + if (argument instanceof IdentifierTree identifier) { + return identifier.symbol(); + } + if (argument instanceof MemberSelectExpressionTree memberSelect) { + return memberSelect.identifier().symbol(); + } + return null; + } + + private static boolean isChronoUnitConstant(@Nullable Symbol symbol) { + if (symbol == null || symbol.isUnknown() || !symbol.isVariableSymbol() || !symbol.isEnum()) { + return false; + } + Symbol owner = symbol.owner(); + return owner != null && !owner.isUnknown() && owner.type().is(CHRONO_UNIT); + } +} diff --git a/java-checks/src/test/java/org/sonar/java/checks/UnsupportedChronoUnitWithInstantCheckTest.java b/java-checks/src/test/java/org/sonar/java/checks/UnsupportedChronoUnitWithInstantCheckTest.java new file mode 100644 index 00000000000..0e6cb268b1e --- /dev/null +++ b/java-checks/src/test/java/org/sonar/java/checks/UnsupportedChronoUnitWithInstantCheckTest.java @@ -0,0 +1,53 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.checks; + +import org.junit.jupiter.api.Test; +import org.sonar.java.checks.verifier.CheckVerifier; + +import static org.sonar.java.checks.verifier.TestUtils.mainCodeSourcesPath; + +class UnsupportedChronoUnitWithInstantCheckTest { + + private static final String SAMPLE = "checks/UnsupportedChronoUnitWithInstantCheckSample.java"; + + @Test + void test() { + CheckVerifier.newVerifier() + .onFile(mainCodeSourcesPath(SAMPLE)) + .withCheck(new UnsupportedChronoUnitWithInstantCheck()) + .verifyIssues(); + } + + @Test + void test_without_semantic() { + CheckVerifier.newVerifier() + .onFile(mainCodeSourcesPath(SAMPLE)) + .withCheck(new UnsupportedChronoUnitWithInstantCheck()) + .withoutSemantic() + .verifyNoIssues(); + } + + @Test + void no_issue_before_java_8() { + CheckVerifier.newVerifier() + .onFile(mainCodeSourcesPath(SAMPLE)) + .withCheck(new UnsupportedChronoUnitWithInstantCheck()) + .withJavaVersion(7) + .verifyNoIssues(); + } +} diff --git a/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S8218.html b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S8218.html new file mode 100644 index 00000000000..ccb89abb221 --- /dev/null +++ b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S8218.html @@ -0,0 +1,85 @@ +

Instant supports only time-based ChronoUnit values and ChronoUnit.DAYS. Passing another +ChronoUnit value to its arithmetic methods causes an UnsupportedTemporalTypeException when the unit is evaluated.

+

Why is this an issue?

+

An Instant represents a point on the timeline as a number of seconds and nanoseconds from the epoch. It does not define how date-based +ChronoUnit values such as MONTHS or YEARS apply to its timeline. Custom TemporalUnit +implementations may define their own behavior.

+

The plus(long, TemporalUnit), minus(long, TemporalUnit), and until(Temporal, TemporalUnit) methods support +the following ChronoUnit values:

+ +

Using WEEKS, MONTHS, YEARS, DECADES, CENTURIES, MILLENNIA, +ERAS, or FOREVER with these methods throws an UnsupportedTemporalTypeException when the unit is evaluated. The +operation cannot produce a result, which can interrupt date and time processing in the application.

+

Exceptions

+

This rule reports only direct ChronoUnit constants. It does not track a constant stored in a variable.

+

Custom TemporalUnit implementations are not reported because Instant delegates the operation to them. The +plus(TemporalAmount) and minus(TemporalAmount) overloads and the truncatedTo(TemporalUnit) method are also +outside the scope of this rule.

+

How to fix it

+

Use a supported ChronoUnit for a fixed amount of elapsed time. When an operation must follow calendar rules, convert the +Instant to a calendar type such as ZonedDateTime using the intended ZoneId. A custom TemporalUnit +is also valid when its implementation explicitly defines the required timeline behavior.

+

Code examples

+

Noncompliant code example

+
+import static java.time.temporal.ChronoUnit.YEARS;
+
+void update(Instant instant, Instant end) {
+  instant.plus(
+    1,
+    ChronoUnit.MONTHS); // Noncompliant: "MONTHS" is unsupported by Instant and causes an UnsupportedTemporalTypeException.
+
+  instant.minus(
+    1,
+    YEARS); // Noncompliant: "YEARS" is unsupported by Instant and causes an UnsupportedTemporalTypeException.
+
+  instant.until(
+    end,
+    ChronoUnit.WEEKS); // Noncompliant: "WEEKS" is unsupported by Instant and causes an UnsupportedTemporalTypeException.
+}
+
+

Compliant solution

+
+import static java.time.temporal.ChronoUnit.YEARS;
+
+void update(Instant instant, Instant end, ZoneId zone, TemporalUnit customUnit) {
+  // Use supported units for elapsed-time operations.
+  instant.plus(1, ChronoUnit.DAYS);
+  instant.minus(12, ChronoUnit.HOURS);
+  instant.until(end, ChronoUnit.MINUTES);
+
+  // Use a calendar type and an explicit time zone for calendar arithmetic.
+  ZonedDateTime dateTime = instant.atZone(zone);
+  ZonedDateTime endDateTime = end.atZone(zone);
+  dateTime.plus(1, ChronoUnit.MONTHS);
+  dateTime.minus(1, YEARS);
+  dateTime.until(endDateTime, ChronoUnit.WEEKS);
+
+  // Support for custom units is defined by their implementation.
+  instant.plus(1, customUnit);
+  instant.minus(1, customUnit);
+  instant.until(end, customUnit);
+}
+
+

Resources

+

Documentation

+ + diff --git a/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S8218.json b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S8218.json new file mode 100644 index 00000000000..aa04cabd2bf --- /dev/null +++ b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S8218.json @@ -0,0 +1,25 @@ +{ + "title": "Instant APIs should only use supported temporal units", + "type": "BUG", + "status": "ready", + "remediation": { + "func": "Constant\/Issue", + "constantCost": "5min" + }, + "tags": [ + "java8", + "datetime", + "pitfall" + ], + "defaultSeverity": "Major", + "ruleSpecification": "RSPEC-8218", + "sqKey": "S8218", + "scope": "All", + "quickfix": "infeasible", + "code": { + "impacts": { + "RELIABILITY": "MEDIUM" + }, + "attribute": "LOGICAL" + } +} diff --git a/sonar-java-plugin/src/main/resources/profiles/Sonar_way/S8218 b/sonar-java-plugin/src/main/resources/profiles/Sonar_way/S8218 new file mode 100644 index 00000000000..e69de29bb2d