[java] IDEA-358431 Support MessageFormat specifier-to-argument navigation, similar to String.format

GitOrigin-RevId: 5bfb87b48e714f92f5c469d4110426ff76f8c14b
This commit is contained in:
Tagir Valeev
2024-09-02 15:21:00 +02:00
committed by intellij-monorepo-bot
parent 3b834aa61a
commit 49f0880f57
9 changed files with 228 additions and 65 deletions

View File

@@ -11,8 +11,7 @@ import com.intellij.psi.impl.source.tree.java.PsiEmptyExpressionImpl;
import com.intellij.psi.util.PsiTreeUtil;
import com.intellij.util.containers.ContainerUtil;
import com.siyeh.InspectionGadgetsBundle;
import com.siyeh.ig.bugs.message.MessageFormatUtil;
import com.siyeh.ig.callMatcher.CallMatcher;
import com.siyeh.ig.format.MessageFormatUtil;
import com.siyeh.ig.psiutils.ConstructionUtils;
import org.jetbrains.annotations.Nls;
import org.jetbrains.annotations.NotNull;
@@ -20,15 +19,8 @@ import org.jetbrains.annotations.Nullable;
import java.util.*;
import static com.siyeh.ig.callMatcher.CallMatcher.anyOf;
import static com.siyeh.ig.callMatcher.CallMatcher.staticCall;
public final class IncorrectMessageFormatInspection extends AbstractBaseJavaLocalInspectionTool {
private static final CallMatcher PATTERN_METHODS = anyOf(
staticCall("java.text.MessageFormat", "format").parameterCount(2)
);
@NotNull
@Override
public PsiElementVisitor buildVisitor(@NotNull ProblemsHolder holder, boolean isOnTheFly) {
@@ -64,7 +56,7 @@ public final class IncorrectMessageFormatInspection extends AbstractBaseJavaLoca
@Override
public void visitMethodCallExpression(@NotNull PsiMethodCallExpression call) {
if (PATTERN_METHODS.test(call)) {
if (MessageFormatUtil.PATTERN_METHODS.test(call)) {
List<MessageFormatUtil.MessageFormatPlaceholder> indexes =
checkStringFormatAndGetIndexes(call.getArgumentList().getExpressions()[0]);
if (indexes != null) {

View File

@@ -38,6 +38,9 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.IntStream;
/**
* Utilities related to printf-like format string
*/
public final class FormatDecode {
private static final Pattern fsPattern = Pattern.compile(
@@ -603,6 +606,27 @@ public final class FormatDecode {
}
}
/**
* @param validators validators returned from {@link #decode(String, int)} or similar methods
* @return list of {@link FormatPlaceholder} objects
*/
public static @NotNull List<@NotNull FormatPlaceholder> asPlaceholders(Validator @NotNull [] validators) {
List<FormatPlaceholder> result = new ArrayList<>();
for (int i = 0; i < validators.length; i++) {
FormatDecode.Validator metaValidator = validators[i];
if (metaValidator == null) continue;
Collection<FormatDecode.Validator> unpacked = metaValidator instanceof FormatDecode.MultiValidator multi ?
multi.getValidators() : List.of(metaValidator);
for (FormatDecode.Validator validator : unpacked) {
TextRange stringRange = validator.getRange();
if (stringRange == null) continue;
record MyPlaceholder(int index, @NotNull TextRange range) implements FormatPlaceholder {}
result.add(new MyPlaceholder(i, stringRange));
}
}
return result;
}
public record Spec(@Nullable String posSpec ,
@Nullable String flags,
@Nullable String width,

View File

@@ -0,0 +1,20 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.siyeh.ig.format;
import com.intellij.openapi.util.TextRange;
import org.jetbrains.annotations.NotNull;
/**
* Represents a single placeholder in a format string, like %d in printf-format, or {1} in MessageFormat-format
*/
public interface FormatPlaceholder {
/**
* @return zero-based index of the argument, which corresponds to this format placeholder
*/
int index();
/**
* @return range inside the original format string which is occupied by a given placeholder
*/
@NotNull TextRange range();
}

View File

@@ -1,11 +1,11 @@
// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.siyeh.ig.bugs.message;
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.siyeh.ig.format;
import com.intellij.openapi.util.Pair;
import com.intellij.openapi.util.TextRange;
import com.intellij.util.containers.ContainerUtil;
import com.siyeh.ig.callMatcher.CallMatcher;
import it.unimi.dsi.fastutil.ints.Int2IntFunction;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.VisibleForTesting;
@@ -13,7 +13,19 @@ import org.jetbrains.annotations.VisibleForTesting;
import java.text.ChoiceFormat;
import java.util.*;
import static com.siyeh.ig.callMatcher.CallMatcher.anyOf;
import static com.siyeh.ig.callMatcher.CallMatcher.staticCall;
/**
* Utilities related to MessageFormat-like format string
*/
public final class MessageFormatUtil {
/**
* Matcher to match known JDK library methods that accept MessageFormat-like format string, along with an array/vararg of arguments
*/
public static final CallMatcher PATTERN_METHODS = anyOf(
staticCall("java.text.MessageFormat", "format").parameterCount(2)
);
private static final Map<String, List<String>> knownContractions = Map.ofEntries(
Map.entry("aren", List.of("t")),
@@ -46,7 +58,10 @@ public final class MessageFormatUtil {
Map.entry("you", List.of("d", "ll", "re", "ve"))
);
@ApiStatus.Experimental
/**
* @param pattern MessageFormat-like formatting string
* @return MessageFormatResult object that contains information about placeholders and possible syntax errors inside the pattern
*/
@NotNull
public static MessageFormatResult checkFormat(@NotNull String pattern) {
if (pattern.isEmpty()) {
@@ -255,7 +270,7 @@ public final class MessageFormatUtil {
MessageFormatPart part = holder.getLastPart();
if (!(part.getMessageFormatElement() != null &&
part.getMessageFormatElement().currentPart == MessageFormatElementPart.FORMAT_TYPE &&
part.getMessageFormatElement().formatTypeSegment.length() == 0)) {
part.getMessageFormatElement().formatTypeSegment.isEmpty())) {
holder.addChar(ch);
}
}
@@ -323,7 +338,7 @@ public final class MessageFormatUtil {
MessageHolder holder = parseMessageHolder(nextPattern);
if (holder.errors.isEmpty()) {
List<MessageFormatPart> notStrings =
ContainerUtil.filter(holder.parts, t -> !(t.getParsedType() == MessageFormatParsedType.STRING && t.getText().length() == 0));
ContainerUtil.filter(holder.parts, t -> !(t.getParsedType() == MessageFormatParsedType.STRING && t.getText().isEmpty()));
if (notStrings.size() == 1 && notStrings.get(0).getParsedType() == MessageFormatParsedType.FORMAT_ELEMENT) {
return nextQuote + current;
}
@@ -445,11 +460,18 @@ public final class MessageFormatUtil {
RUNTIME_EXCEPTION, WARNING, WEAK_WARNING
}
/**
* Information about MessageFormat-like format string
*
* @param valid if true, then the format string is valid
* @param errors list of errors inside the format string
* @param placeholders list of placeholders inside the format string
*/
public record MessageFormatResult(boolean valid, @NotNull List<MessageFormatError> errors,
@NotNull List<MessageFormatPlaceholder> placeholders) {
}
public record MessageFormatPlaceholder(int index, @NotNull TextRange range, boolean isString) {
public record MessageFormatPlaceholder(int index, @NotNull TextRange range, boolean isString) implements FormatPlaceholder {
}
static class MessageFormatPart {

View File

@@ -21,16 +21,18 @@ import com.intellij.psi.*;
import com.intellij.psi.search.LocalSearchScope;
import com.intellij.psi.search.SearchScope;
import com.intellij.psi.util.PsiTreeUtil;
import com.intellij.psi.util.PsiUtil;
import com.intellij.util.ObjectUtils;
import com.intellij.util.containers.ContainerUtil;
import com.siyeh.ig.psiutils.ExpressionUtils;
import com.siyeh.ig.psiutils.MethodCallUtils;
import com.siyeh.ig.psiutils.VariableAccessUtils;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.*;
import java.util.function.Supplier;
public final class StringFormatSymbolReferenceProvider implements PsiSymbolReferenceProvider {
@Override
@@ -44,12 +46,30 @@ public final class StringFormatSymbolReferenceProvider implements PsiSymbolRefer
static @NotNull List<@NotNull PsiSymbolReference> getReferences(@NotNull PsiLiteralExpression expression) {
PsiCallExpression callExpression = findContextCall(expression);
if (callExpression == null) return List.of();
List<@NotNull PsiSymbolReference> refs = getPrintFormatRefs(expression, callExpression);
return refs.isEmpty() ? getMessageFormatRefs(expression, callExpression) : refs;
}
private static @NotNull List<@NotNull PsiSymbolReference> getMessageFormatRefs(@NotNull PsiLiteralExpression expression,
@NotNull PsiCallExpression callExpression) {
if (!MessageFormatUtil.PATTERN_METHODS.matches(callExpression)) return List.of();
String formatString = ObjectUtils.tryCast(expression.getValue(), String.class);
if (formatString == null) return List.of();
MessageFormatUtil.MessageFormatResult format = MessageFormatUtil.checkFormat(formatString);
List<MessageFormatUtil.MessageFormatPlaceholder> placeholders = format.placeholders();
if (placeholders.isEmpty()) return List.of();
return createReferences(callExpression, 0, expression, placeholders);
}
private static @NotNull List<@NotNull PsiSymbolReference> getPrintFormatRefs(@NotNull PsiLiteralExpression expression,
@NotNull PsiCallExpression callExpression) {
FormatDecode.FormatArgument argument = FormatDecode.FormatArgument.extract(callExpression, List.of(), List.of(), true);
if (argument == null || !PsiTreeUtil.isAncestor(argument.getExpression(), expression, false)) return List.of();
if (argument == null || !PsiTreeUtil.isAncestor(resolve(argument.getExpression()), expression, false)) return List.of();
String formatString = ObjectUtils.tryCast(expression.getValue(), String.class);
if (formatString == null) return List.of();
PsiExpression[] arguments = Objects.requireNonNull(callExpression.getArgumentList()).getExpressions();
int argumentCount = arguments.length - argument.getIndex();
int index = argument.getIndex();
int argumentCount = arguments.length - index;
FormatDecode.Validator[] validators;
try {
validators = FormatDecode.decodeNoVerify(formatString, argumentCount);
@@ -57,27 +77,43 @@ public final class StringFormatSymbolReferenceProvider implements PsiSymbolRefer
catch (FormatDecode.IllegalFormatException e) {
return List.of();
}
List<PsiSymbolReference> result = new ArrayList<>();
for (int i = 0; i < validators.length; i++) {
int index = argument.getIndex() + i;
if (index >= arguments.length) break;
List<@NotNull FormatPlaceholder> placeholders = FormatDecode.asPlaceholders(validators);
return createReferences(callExpression, index - 1, expression, placeholders);
}
FormatDecode.Validator metaValidator = validators[i];
if (metaValidator == null) continue;
Collection<FormatDecode.Validator> unpacked = metaValidator instanceof FormatDecode.MultiValidator multi ?
multi.getValidators() : List.of(metaValidator);
for (FormatDecode.Validator validator : unpacked) {
TextRange stringRange = validator.getRange();
if (stringRange == null) continue;
TextRange range = ExpressionUtils.findStringLiteralRange(expression, stringRange.getStartOffset(),
stringRange.getEndOffset());
if (range == null) continue;
result.add(new JavaFormatArgumentSymbolReference(expression, range, arguments[index]));
}
private static @NotNull List<@NotNull PsiSymbolReference> createReferences(@NotNull PsiCallExpression callExpression,
int formatStringIndex,
@NotNull PsiLiteralExpression formatExpression,
@NotNull List<? extends FormatPlaceholder> placeholders) {
List<PsiExpression> formatArguments = getFormatArguments(callExpression, formatStringIndex);
List<@NotNull PsiSymbolReference> result = new ArrayList<>();
for (FormatPlaceholder placeholder : placeholders) {
int index = placeholder.index();
if (index >= formatArguments.size()) continue;
TextRange stringRange = placeholder.range();
TextRange range = ExpressionUtils.findStringLiteralRange(formatExpression, stringRange.getStartOffset(),
stringRange.getEndOffset());
if (range == null) continue;
PsiExpression arg = formatArguments.get(index);
result.add(new JavaFormatArgumentSymbolReference(formatExpression, range, () -> new JavaFormatArgumentSymbol(arg, formatStringIndex)));
}
return result;
}
private static List<PsiExpression> getFormatArguments(@NotNull PsiCallExpression callExpression, int formatIndex) {
PsiExpression[] arguments = Objects.requireNonNull(callExpression.getArgumentList()).getExpressions();
int firstArgument = formatIndex + 1;
if (arguments.length <= firstArgument) return List.of();
if (MethodCallUtils.isVarArgCall(callExpression)) {
return Arrays.asList(arguments).subList(firstArgument, arguments.length);
}
if (arguments.length != firstArgument + 1) return List.of();
if (!(PsiUtil.skipParenthesizedExprDown(arguments[firstArgument]) instanceof PsiNewExpression array)) return List.of();
PsiArrayInitializerExpression initializer = array.getArrayInitializer();
if (initializer == null) return List.of();
return Arrays.asList(initializer.getInitializers());
}
private static boolean hintsCheck(@NotNull PsiSymbolReferenceHints hints) {
if (!hints.getReferenceClass().isAssignableFrom(JavaFormatArgumentSymbolReference.class)) return false;
Class<? extends Symbol> targetClass = hints.getTargetClass();
@@ -89,6 +125,12 @@ public final class StringFormatSymbolReferenceProvider implements PsiSymbolRefer
private static PsiCallExpression findContextCall(PsiElement context) {
if (!(context instanceof PsiExpression expr)) return null;
expr = ExpressionUtils.getPassThroughExpression(expr);
if (expr.getParent() instanceof PsiLocalVariable variable) {
PsiReferenceExpression ref = ContainerUtil.getOnlyItem(VariableAccessUtils.getVariableReferences(variable));
if (ref != null) {
expr = ref;
}
}
if (expr.getParent() instanceof PsiExpressionList list && list.getParent() instanceof PsiCallExpression call) {
return call;
}
@@ -103,12 +145,14 @@ public final class StringFormatSymbolReferenceProvider implements PsiSymbolRefer
private static class JavaFormatArgumentSymbolReference implements PsiSymbolReference {
private final PsiExpression myFormat;
private final TextRange myRange;
private final PsiExpression myArgument;
private final Supplier<JavaFormatArgumentSymbol> myResolver;
private JavaFormatArgumentSymbolReference(@NotNull PsiExpression format, @NotNull TextRange range, @NotNull PsiExpression argument) {
private JavaFormatArgumentSymbolReference(@NotNull PsiExpression format,
@NotNull TextRange range,
@NotNull Supplier<JavaFormatArgumentSymbol> resolver) {
myFormat = format;
myRange = range;
myArgument = argument;
myResolver = resolver;
}
@Override
@@ -123,21 +167,24 @@ public final class StringFormatSymbolReferenceProvider implements PsiSymbolRefer
@Override
public @NotNull Collection<? extends Symbol> resolveReference() {
return List.of(new JavaFormatArgumentSymbol(myArgument));
return List.of(myResolver.get());
}
}
@ApiStatus.Internal
public static final class JavaFormatArgumentSymbol implements Symbol, SearchTarget, NavigatableSymbol {
private final @NotNull PsiExpression myExpression;
private final int myFormatStringIndex;
JavaFormatArgumentSymbol(@NotNull PsiExpression argument) {
JavaFormatArgumentSymbol(@NotNull PsiExpression argument, int index) {
myExpression = argument;
myFormatStringIndex = index;
}
@Override
public @NotNull Pointer<JavaFormatArgumentSymbol> createPointer() {
return Pointer.delegatingPointer(SmartPointerManager.createPointer(myExpression), JavaFormatArgumentSymbol::new);
return Pointer.delegatingPointer(SmartPointerManager.createPointer(myExpression),
argument -> new JavaFormatArgumentSymbol(argument, myFormatStringIndex));
}
@Override
@@ -158,9 +205,13 @@ public final class StringFormatSymbolReferenceProvider implements PsiSymbolRefer
!(list.getParent() instanceof PsiCallExpression call)) {
return null;
}
FormatDecode.FormatArgument argument = FormatDecode.FormatArgument.extract(call, List.of(), List.of(), true);
if (argument == null) return null;
return argument.getExpression();
PsiExpressionList argumentList = call.getArgumentList();
if (argumentList == null) return null;
PsiExpression[] expressions = argumentList.getExpressions();
if (expressions.length <= myFormatStringIndex) {
return null;
}
return resolve(expressions[myFormatStringIndex]);
}
@Override
@@ -191,4 +242,11 @@ public final class StringFormatSymbolReferenceProvider implements PsiSymbolRefer
return myExpression.hashCode();
}
}
private static @Nullable PsiExpression resolve(PsiExpression target) {
if (target instanceof PsiReferenceExpression ref && ref.resolve() instanceof PsiLocalVariable local) {
return local.getInitializer();
}
return target;
}
}

View File

@@ -19,9 +19,9 @@ import com.siyeh.InspectionGadgetsBundle;
import com.siyeh.ig.BaseInspection;
import com.siyeh.ig.BaseInspectionVisitor;
import com.siyeh.ig.PsiReplacementUtil;
import com.siyeh.ig.bugs.message.MessageFormatUtil;
import com.siyeh.ig.callMatcher.CallMatcher;
import com.siyeh.ig.format.FormatDecode;
import com.siyeh.ig.format.MessageFormatUtil;
import com.siyeh.ig.psiutils.CommentTracker;
import com.siyeh.ig.psiutils.ExpressionUtils;
import com.siyeh.ig.psiutils.TypeUtils;

View File

@@ -1,5 +1,5 @@
// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.siyeh.ig.bugs.message;
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.siyeh.ig.format;
import com.intellij.util.containers.ContainerUtil;
import org.junit.Assert;

View File

@@ -4,7 +4,7 @@ package com.siyeh.ig.format;
import com.intellij.model.Symbol;
import com.intellij.model.psi.PsiSymbolReference;
import com.intellij.model.psi.PsiSymbolReferenceService;
import com.intellij.psi.PsiConditionalExpression;
import com.intellij.psi.PsiElement;
import com.intellij.psi.PsiLiteralExpression;
import com.intellij.psi.util.PsiTreeUtil;
import com.intellij.testFramework.LightProjectDescriptor;
@@ -24,28 +24,75 @@ public class StringFormatSymbolReferenceProviderTest extends LightJavaCodeInsigh
public void testResolveFormatSpecifiers() {
myFixture.configureByText("Test.java", """
class Demo {
final class Demo {
static void process(String s, Object date, boolean b) {
String conditional = String.format(b ? "myFormat: num = %1$d, date = %2$s" :
"<caret>myFormat: date = %2$s; num = %1$d", 123, date);
}
}""");
PsiLiteralExpression str = getLiteral();
Collection<? extends @NotNull PsiSymbolReference> refs = PsiSymbolReferenceService.getService().getReferences(str);
assertEquals(2, refs.size());
checkRefs(refs, str, str.getParent(), Map.of("%2$s", "date", "%1$d", "123"));
}
public void testResolveFromLocalVar() {
myFixture.configureByText("Test.java", """
final class Demo {
static void process(String s) {
String template = "<caret>Hello %d %s";
System.out.printf(template, 123, s);
}
}""");
PsiLiteralExpression str = getLiteral();
Collection<? extends @NotNull PsiSymbolReference> refs = PsiSymbolReferenceService.getService().getReferences(str);
assertEquals(2, refs.size());
Map<String, String> expected = Map.of("%d", "123", "%s", "s");
checkRefs(refs, str, str, expected);
}
public void testMessageFormat() {
myFixture.configureByText("Test.java", """
import java.text.MessageFormat;
final class Demo {
static void process(String s) {
String template = "<caret>Hello {1} {1} {0}";
System.out.println(MessageFormat.format(template, s, 123));
}
}""");
PsiLiteralExpression str = getLiteral();
Collection<? extends @NotNull PsiSymbolReference> refs = PsiSymbolReferenceService.getService().getReferences(str);
assertEquals(3, refs.size());
Map<String, String> expected = Map.of("{0}", "s", "{1}", "123");
checkRefs(refs, str, str, expected);
}
private @NotNull PsiLiteralExpression getLiteral() {
PsiLiteralExpression str =
PsiTreeUtil.getParentOfType(myFixture.getFile().findElementAt(myFixture.getEditor().getCaretModel().getOffset()),
PsiLiteralExpression.class);
assertNotNull(str);
Collection<? extends @NotNull PsiSymbolReference> refs = PsiSymbolReferenceService.getService().getReferences(str);
assertEquals(2, refs.size());
Map<String, String> expected = Map.of("%2$s", "date", "%1$d", "123");
return str;
}
private static @NotNull JavaFormatArgumentSymbol resolveRef(@NotNull PsiSymbolReference ref) {
Collection<? extends Symbol> symbols = ref.resolveReference();
assertEquals(1, symbols.size());
Symbol symbol = CollectionUtils.getOnlyElement(symbols);
assertTrue(symbol instanceof JavaFormatArgumentSymbol);
return (JavaFormatArgumentSymbol)symbol;
}
private static void checkRefs(@NotNull Collection<? extends PsiSymbolReference> refs,
@NotNull PsiLiteralExpression str,
@NotNull PsiElement formatString,
@NotNull Map<String, String> expected) {
for (PsiSymbolReference ref : refs) {
assertEquals(str, ref.getElement());
String formatSpecifier = ref.getRangeInElement().substring(ref.getElement().getText());
Collection<? extends Symbol> symbols = ref.resolveReference();
assertEquals(1, symbols.size());
Symbol symbol = CollectionUtils.getOnlyElement(symbols);
assertTrue(symbol instanceof JavaFormatArgumentSymbol);
JavaFormatArgumentSymbol formatSymbol = (JavaFormatArgumentSymbol)symbol;
assertTrue(formatSymbol.getFormatString() instanceof PsiConditionalExpression);
JavaFormatArgumentSymbol formatSymbol = resolveRef(ref);
assertEquals(formatString, formatSymbol.getFormatString());
String expressionText = formatSymbol.getExpression().getText();
assertEquals(expected.get(formatSpecifier), expressionText);
}

View File

@@ -9,9 +9,9 @@ import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiElementVisitor
import com.siyeh.ig.bugs.IncorrectMessageFormatInspection
import com.siyeh.ig.bugs.message.MessageFormatUtil
import com.siyeh.ig.bugs.message.MessageFormatUtil.MessageFormatError
import com.siyeh.ig.bugs.message.MessageFormatUtil.MessageFormatErrorType
import com.siyeh.ig.format.MessageFormatUtil
import com.siyeh.ig.format.MessageFormatUtil.MessageFormatError
import com.siyeh.ig.format.MessageFormatUtil.MessageFormatErrorType
import org.jetbrains.idea.devkit.inspections.DevKitInspectionUtil
private val SKIPPED_ERROR_TYPES: Set<MessageFormatErrorType> = setOf(MessageFormatErrorType.INDEX_NEGATIVE,