diff --git a/python/src/com/jetbrains/python/inspections/PyStringFormatParser.java b/python/src/com/jetbrains/python/inspections/PyStringFormatParser.java index 82a56b4323f1..19d04cd4ab9f 100644 --- a/python/src/com/jetbrains/python/inspections/PyStringFormatParser.java +++ b/python/src/com/jetbrains/python/inspections/PyStringFormatParser.java @@ -248,6 +248,16 @@ public class PyStringFormatParser { return result; } + + @NotNull + public static List substitutionsToRanges(@NotNull List substitutions) { + final List ranges = new ArrayList(); + for (SubstitutionChunk substitution : substitutions) { + ranges.add(TextRange.create(substitution.getStartIndex(), substitution.getEndIndex())); + } + return ranges; + } + /** * Return the RHS operand of %-based string literal format expression. */ diff --git a/python/src/com/jetbrains/python/refactoring/PyReplaceExpressionUtil.java b/python/src/com/jetbrains/python/refactoring/PyReplaceExpressionUtil.java index 1cc2147a55b5..3e649113d72a 100644 --- a/python/src/com/jetbrains/python/refactoring/PyReplaceExpressionUtil.java +++ b/python/src/com/jetbrains/python/refactoring/PyReplaceExpressionUtil.java @@ -1,5 +1,6 @@ package com.jetbrains.python.refactoring; +import com.intellij.lang.ASTNode; import com.intellij.openapi.util.Key; import com.intellij.openapi.util.Pair; import com.intellij.openapi.util.TextRange; @@ -9,6 +10,7 @@ import com.jetbrains.python.PyElementTypes; import com.jetbrains.python.PythonStringUtil; import com.jetbrains.python.inspections.PyStringFormatParser; import com.jetbrains.python.psi.*; +import com.jetbrains.python.psi.impl.PyPsiUtils; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -83,11 +85,35 @@ public class PyReplaceExpressionUtil implements PyElementTypes { final LanguageLevel languageLevel = LanguageLevel.forElement(oldExpression); final List substitutions = new PyStringFormatParser(fullText).parseSubstitutions(); - // TODO: Handle %-formatted strings + if (valueExpression instanceof PyTupleExpression && !containsStringFormatting(fullText, textRange)) { + // 'foo%s' % (x,) -> '%s%s' % (s, x) + // TODO: Support dict literals and dict() function + // TODO: It is possible to resolve to a tuple or dict literal and modify them + final String newLiteralText = prefix + "%s" + suffix; + final PyStringLiteralExpression newLiteralExpression = generator.createStringLiteralAlreadyEscaped(newLiteralText); + oldExpression.replace(newLiteralExpression); - if (isConcatFormatting(oldExpression) || substitutions.size() > 0) { + final PyTupleExpression tuple = (PyTupleExpression)valueExpression; + final PyExpression[] members = tuple.getElements(); + final List positional = PyStringFormatParser.getPositionalSubstitutions(substitutions); + final int i = getPositionInRanges(PyStringFormatParser.substitutionsToRanges(positional), textRange); + final int n = members.length; + if (n > 0 && i <= n) { + final boolean last = i == n; + final ASTNode trailingComma = PyPsiUtils.getNextComma(members[n - 1].getNode()); + if (trailingComma != null) { + tuple.getNode().removeChild(trailingComma); + } + final PyExpression before = last ? null : members[i]; + PyUtil.addListNode(tuple, newExpression, before != null ? before.getNode() : null, i == 0 || !last, last, !last); + return newExpression; + } + return null; + } + else if (isConcatFormatting(oldExpression) || substitutions.size() > 0) { // 'foobar' + 'baz' -> s + 'bar' + 'baz' // 'foobar%s' -> s + 'bar%s' + // 'f%soobar' % x -> (s + 'bar') % x final Pair detectedQuotes = PythonStringUtil.getQuotes(fullText); final Pair quotes = detectedQuotes != null ? detectedQuotes : Pair.create("'", "'"); final String leftQuote = quotes.getFirst(); @@ -113,6 +139,7 @@ public class PyReplaceExpressionUtil implements PyElementTypes { } else { // 'foobar' -> '%sbar' % s + // TODO: Handle extracting substring from a string with new-style formatting final PsiElement parent = oldExpression.getParent(); final boolean parensNeeded = parent instanceof PyExpression && !(parent instanceof PyParenthesizedExpression); final StringBuilder builder = new StringBuilder(); @@ -134,6 +161,28 @@ public class PyReplaceExpressionUtil implements PyElementTypes { } } + private static int getPositionInRanges(@NotNull List ranges, @NotNull TextRange range) { + final int end = range.getEndOffset(); + final int size = ranges.size(); + for (int i = 0; i < size; i++) { + final TextRange r = ranges.get(i); + if (end < r.getStartOffset()) { + return i; + } + } + return size; + } + + private static boolean containsStringFormatting(@NotNull String s, @NotNull TextRange range) { + final List ranges = PyStringFormatParser.substitutionsToRanges(new PyStringFormatParser(s).parseSubstitutions()); + for (TextRange r : ranges) { + if (range.contains(r)) { + return true; + } + } + return false; + } + private static boolean isConcatFormatting(PyStringLiteralExpression element) { final PsiElement parent = element.getParent(); return parent instanceof PyBinaryExpression && ((PyBinaryExpression)parent).isOperator("+"); diff --git a/python/src/com/jetbrains/python/refactoring/introduce/IntroduceHandler.java b/python/src/com/jetbrains/python/refactoring/introduce/IntroduceHandler.java index 20f093f3f85a..0bf3f0d12e1f 100644 --- a/python/src/com/jetbrains/python/refactoring/introduce/IntroduceHandler.java +++ b/python/src/com/jetbrains/python/refactoring/introduce/IntroduceHandler.java @@ -286,9 +286,7 @@ abstract public class IntroduceHandler implements RefactoringActionHandler { final TextRange intersection = selectionRange.shiftRight(-offset).intersection(innerRange); final TextRange finalRange = intersection != null ? intersection : selectionRange; final String text = literal.getText(); - // TODO: Handle extracting substring from a string with %-formatting // TODO: Protect against substrings with new-style format characters - // TODO: Handle extracting substring from a string with new-style formatting if (breaksStringFormatting(text, finalRange) || breaksStringEscaping(text, finalRange)) { showCannotPerformError(project, editor); return; @@ -305,12 +303,7 @@ abstract public class IntroduceHandler implements RefactoringActionHandler { } private boolean breaksStringFormatting(@NotNull String s, @NotNull TextRange range) { - final PyStringFormatParser parser = new PyStringFormatParser(s); - final List ranges = new ArrayList(); - for (PyStringFormatParser.SubstitutionChunk chunk : parser.parseSubstitutions()) { - ranges.add(TextRange.create(chunk.getStartIndex(), chunk.getEndIndex())); - } - return breaksRanges(ranges, range); + return breaksRanges(PyStringFormatParser.substitutionsToRanges(new PyStringFormatParser(s).parseSubstitutions()), range); } private boolean breaksStringEscaping(@NotNull String s, @NotNull TextRange range) { diff --git a/python/testData/refactoring/introduceVariable/substringAfterFormatTuple.after.py b/python/testData/refactoring/introduceVariable/substringAfterFormatTuple.after.py new file mode 100644 index 000000000000..04e73d6ee14b --- /dev/null +++ b/python/testData/refactoring/introduceVariable/substringAfterFormatTuple.after.py @@ -0,0 +1,2 @@ +a = "World" +print("%s: %s %s" % ("Error", "Hello", a)) diff --git a/python/testData/refactoring/introduceVariable/substringAfterFormatTuple.py b/python/testData/refactoring/introduceVariable/substringAfterFormatTuple.py new file mode 100644 index 000000000000..6d3934bddc56 --- /dev/null +++ b/python/testData/refactoring/introduceVariable/substringAfterFormatTuple.py @@ -0,0 +1 @@ +print("%s: %s World" % ("Error", "Hello")) diff --git a/python/testData/refactoring/introduceVariable/substringAfterFormatTupleWithComma.after.py b/python/testData/refactoring/introduceVariable/substringAfterFormatTupleWithComma.after.py new file mode 100644 index 000000000000..04e73d6ee14b --- /dev/null +++ b/python/testData/refactoring/introduceVariable/substringAfterFormatTupleWithComma.after.py @@ -0,0 +1,2 @@ +a = "World" +print("%s: %s %s" % ("Error", "Hello", a)) diff --git a/python/testData/refactoring/introduceVariable/substringAfterFormatTupleWithComma.py b/python/testData/refactoring/introduceVariable/substringAfterFormatTupleWithComma.py new file mode 100644 index 000000000000..ca34963a9884 --- /dev/null +++ b/python/testData/refactoring/introduceVariable/substringAfterFormatTupleWithComma.py @@ -0,0 +1 @@ +print("%s: %s World" % ("Error", "Hello",)) diff --git a/python/testData/refactoring/introduceVariable/substringBeforeFormatTuple.after.py b/python/testData/refactoring/introduceVariable/substringBeforeFormatTuple.after.py new file mode 100644 index 000000000000..aef0b7c8a0ff --- /dev/null +++ b/python/testData/refactoring/introduceVariable/substringBeforeFormatTuple.after.py @@ -0,0 +1,2 @@ +a = "Hello" +print("%s %s" % (a, "World")) \ No newline at end of file diff --git a/python/testData/refactoring/introduceVariable/substringBeforeFormatTuple.py b/python/testData/refactoring/introduceVariable/substringBeforeFormatTuple.py new file mode 100644 index 000000000000..b70eecff3c9d --- /dev/null +++ b/python/testData/refactoring/introduceVariable/substringBeforeFormatTuple.py @@ -0,0 +1 @@ +print("Hello %s" % ("World",)) \ No newline at end of file diff --git a/python/testData/refactoring/introduceVariable/substringInsideFormatTuple.after.py b/python/testData/refactoring/introduceVariable/substringInsideFormatTuple.after.py new file mode 100644 index 000000000000..2aa1c25681d2 --- /dev/null +++ b/python/testData/refactoring/introduceVariable/substringInsideFormatTuple.after.py @@ -0,0 +1,2 @@ +a = "Hello" +print("%s: %s %s" % ("Error", a, "World")) \ No newline at end of file diff --git a/python/testData/refactoring/introduceVariable/substringInsideFormatTuple.py b/python/testData/refactoring/introduceVariable/substringInsideFormatTuple.py new file mode 100644 index 000000000000..9238fd7c8079 --- /dev/null +++ b/python/testData/refactoring/introduceVariable/substringInsideFormatTuple.py @@ -0,0 +1 @@ +print("%s: Hello %s" % ("Error", "World")) \ No newline at end of file diff --git a/python/testSrc/com/jetbrains/python/refactoring/PyIntroduceVariableTest.java b/python/testSrc/com/jetbrains/python/refactoring/PyIntroduceVariableTest.java index 7dce38b1fb63..7cb8e3613b04 100644 --- a/python/testSrc/com/jetbrains/python/refactoring/PyIntroduceVariableTest.java +++ b/python/testSrc/com/jetbrains/python/refactoring/PyIntroduceVariableTest.java @@ -158,6 +158,26 @@ public class PyIntroduceVariableTest extends PyIntroduceTestCase { doTestCannotPerform(); } + // PY-3654 + public void testSubstringBeforeFormatTuple() { + doTest(); + } + + // PY-3654 + public void testSubstringInsideFormatTuple() { + doTest(); + } + + // PY-3654 + public void testSubstringAfterFormatTuple() { + doTest(); + } + + // PY-3654 + public void testSubstringAfterFormatTupleWithComma() { + doTest(); + } + private void doTestCannotPerform() { boolean thrownExpectedException = false; try {