PY-21161 Convert to f-string literal for printf-style formatting

This commit is contained in:
Mikhail Golubev
2016-10-25 21:30:13 +03:00
parent 802697d5e1
commit 2fcfcacdb7
26 changed files with 457 additions and 17 deletions
@@ -0,0 +1,2 @@
f"Pi is {math.pi:03.5}"
f'{"eggs"}'
@@ -0,0 +1,2 @@
"Pi is {:03.5}".format(math.pi)
'%(spam)s' % {'spam': 'eggs'}
@@ -0,0 +1,7 @@
<html>
<body>
This intention converts string formatting via format() method and format
operator to Python 3.6 f-string literals.
<!-- tooltip end -->
</body>
</html>
@@ -310,6 +310,11 @@
<className>com.jetbrains.python.codeInsight.intentions.PyYieldFromIntention</className>
<category>Python</category>
</intentionAction>
<intentionAction>
<className>com.jetbrains.python.codeInsight.intentions.PyConvertToFStringIntention</className>
<category>Python</category>
</intentionAction>
<intentionAction>
<className>com.jetbrains.python.codeInsight.intentions.PyConvertTypeCommentToVariableAnnotationIntention</className>
@@ -219,7 +219,11 @@ INTN.string.concatenation.to.format=Replace string concatenation with format ope
INTN.replace.plus.with.format.operator=Replace + with string formatting operator
INTN.replace.plus.with.str.format=Replace + with str.format method call
# ConvertFormatOperatorToMethodIntention
INTN.format.operator.to.method=Convert format operator usage to str.format method call
# PyConvertToFStringIntention
INTN.convert.to.fstring.literal=Convert to f-string literal
INTN.replace.with.method=Replace with str.format method call
INTN.flip.comparison=Flip comparison
@@ -37,23 +37,23 @@ public class PythonFormattedStringReferenceProvider extends PsiReferenceProvider
}
}
private static PsiReference[] getReferencesFromFormatString(@NotNull final PyStringLiteralExpression element) {
private static PySubstitutionChunkReference[] getReferencesFromFormatString(@NotNull final PyStringLiteralExpression element) {
final List<PyStringFormatParser.SubstitutionChunk> chunks = PyStringFormatParser.filterSubstitutions(
PyStringFormatParser.parseNewStyleFormat(element.getStringValue()));
return getReferencesFromChunks(element, chunks, false);
}
private static PsiReference[] getReferencesFromPercentString(@NotNull final PyStringLiteralExpression element) {
private static PySubstitutionChunkReference[] getReferencesFromPercentString(@NotNull final PyStringLiteralExpression element) {
final List<PyStringFormatParser.SubstitutionChunk>
chunks = PyStringFormatParser.filterSubstitutions(PyStringFormatParser.parsePercentFormat(element.getStringValue()));
return getReferencesFromChunks(element, chunks, true);
}
@NotNull
private static PsiReference[] getReferencesFromChunks(@NotNull final PyStringLiteralExpression element,
@NotNull final List<PyStringFormatParser.SubstitutionChunk> chunks,
boolean isPercent) {
final PsiReference[] result = new PsiReference[chunks.size()];
public static PySubstitutionChunkReference[] getReferencesFromChunks(@NotNull final PyStringLiteralExpression element,
@NotNull final List<PyStringFormatParser.SubstitutionChunk> chunks,
boolean isPercent) {
final PySubstitutionChunkReference[] result = new PySubstitutionChunkReference[chunks.size()];
for (int i = 0; i < chunks.size(); i++) {
final PyStringFormatParser.SubstitutionChunk chunk = chunks.get(i);
result[i] = new PySubstitutionChunkReference(element, chunk, i, isPercent);
@@ -36,6 +36,7 @@ import com.jetbrains.python.psi.impl.PyPsiUtils;
import com.jetbrains.python.psi.impl.PyStringLiteralExpressionImpl;
import com.jetbrains.python.psi.types.*;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.List;
@@ -148,17 +149,7 @@ public class ConvertFormatOperatorToMethodIntention extends PyBaseIntentionActio
// don't convert %s -> !s, for %s is the normal way to output the default representation
out.append(":");
if (f_modifier != null) {
// in strict order
if (has(f_modifier, '-')) out.append("<"); // left align
else if ("s".equals(fConversion) && !StringUtil.isEmptyOrSpaces(f_width)) {
// "%20s" aligns right, "{0:20s}" aligns left; to preserve align, make it explicit
out.append(">");
}
if (has(f_modifier, '+')) out.append("+"); // signed
else if (has(f_modifier, ' ')) out.append(" "); // default-signed
if (has(f_modifier, '#')) out.append("#"); // alt numbers
if (has(f_modifier, '0')) out.append("0"); // padding
// anything else can't be here
out.append(convertFormatSpec(f_modifier, f_width, fConversion));
}
if (f_width != null) {
out.append(f_width);
@@ -209,6 +200,29 @@ public class ConvertFormatOperatorToMethodIntention extends PyBaseIntentionActio
return new Pair<>(result, usesNamedFormat);
}
@NotNull
public static String convertFormatSpec(@NotNull String modifier,
@Nullable String widthAndPrecision,
@Nullable String conversionChar) {
final StringBuilder result = new StringBuilder();
// in strict order
if (has(modifier, '-')) {
result.append("<"); // left align
}
else if ("s".equals(conversionChar) && !StringUtil.isEmptyOrSpaces(widthAndPrecision)) {
// "%20s" aligns right, "{0:20s}" aligns left; to preserve align, make it explicit
result.append(">");
}
if (has(modifier, '+')) {
result.append("+"); // signed
}
else if (has(modifier, ' ')) result.append(" "); // default-signed
if (has(modifier, '#')) result.append("#"); // alt numbers
if (has(modifier, '0')) result.append("0"); // padding
// anything else can't be here
return result.toString();
}
private static boolean has(String where, char what) {
return where.indexOf(what) >= 0;
}
@@ -0,0 +1,303 @@
/*
* Copyright 2000-2016 JetBrains s.r.o.
*
* 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
*
* http://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 com.jetbrains.python.codeInsight.intentions;
import com.intellij.lang.ASTNode;
import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.util.Pair;
import com.intellij.openapi.util.TextRange;
import com.intellij.openapi.util.text.StringUtil;
import com.intellij.psi.PsiElement;
import com.intellij.psi.PsiFile;
import com.intellij.psi.util.PsiTreeUtil;
import com.intellij.util.IncorrectOperationException;
import com.jetbrains.python.PyBundle;
import com.jetbrains.python.PyTokenTypes;
import com.jetbrains.python.codeInsight.PySubstitutionChunkReference;
import com.jetbrains.python.codeInsight.PythonFormattedStringReferenceProvider;
import com.jetbrains.python.inspections.PyStringFormatParser;
import com.jetbrains.python.inspections.PyStringFormatParser.ConstantChunk;
import com.jetbrains.python.inspections.PyStringFormatParser.FormatStringChunk;
import com.jetbrains.python.inspections.PyStringFormatParser.SubstitutionChunk;
import com.jetbrains.python.psi.*;
import com.jetbrains.python.psi.PyUtil.StringNodeInfo;
import com.jetbrains.python.psi.impl.PyStringLiteralExpressionImpl;
import org.jetbrains.annotations.Nls;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import static com.jetbrains.python.psi.PyUtil.as;
/**
* @author Mikhail Golubev
*/
public class PyConvertToFStringIntention extends PyBaseIntentionAction {
@Nls
@NotNull
@Override
public String getFamilyName() {
return PyBundle.message("INTN.convert.to.fstring.literal");
}
@NotNull
@Override
public String getText() {
return getFamilyName();
}
@Override
public boolean isAvailable(@NotNull Project project, Editor editor, PsiFile file) {
if (!(file instanceof PyFile) || !LanguageLevel.forElement(file).isAtLeast(LanguageLevel.PYTHON36)) return false;
final Pair<PyStringLiteralExpression, Boolean> pair = findTargetStringUnderCaret(editor, file);
if (pair == null) return false;
final PyStringLiteralExpression pyString = pair.getFirst();
final boolean percentOperator = pair.getSecond();
// TODO handle "glued" literals
if (pyString != null && pyString.getStringNodes().size() == 1) {
final String stringText = pyString.getText();
final String prefix = PyStringLiteralUtil.getPrefix(stringText);
if (PyStringLiteralUtil.isBytesPrefix(prefix) || PyStringLiteralUtil.isFormattedPrefix(prefix)) {
return false;
}
final List<FormatStringChunk> chunks = percentOperator ? PyStringFormatParser.parsePercentFormat(stringText)
: PyStringFormatParser.parseNewStyleFormat(stringText);
final List<SubstitutionChunk> substitutions = PyStringFormatParser.filterSubstitutions(chunks);
// TODO handle dynamic format spec in both formatting styles
final boolean hasDynamicFormatting;
if (percentOperator) {
hasDynamicFormatting = substitutions.stream().anyMatch(s -> "*".equals(s.getWidth()) || "*".equals(s.getPrecision()));
}
else {
hasDynamicFormatting = false;
}
if (hasDynamicFormatting) return false;
final PySubstitutionChunkReference[] references =
PythonFormattedStringReferenceProvider.getReferencesFromChunks(pyString, substitutions, percentOperator);
final PsiElement valuesSource;
if (percentOperator) {
final PyBinaryExpression binaryExpression = as(pyString.getParent(), PyBinaryExpression.class);
assert binaryExpression != null;
valuesSource = binaryExpression.getRightExpression();
}
else {
final PyCallExpression callExpression = PsiTreeUtil.getParentOfType(pyString, PyCallExpression.class);
assert callExpression != null;
valuesSource = callExpression.getArgumentList();
}
return Arrays.stream(references)
.map(PyConvertToFStringIntention::getActualReplacementExpression)
.allMatch(element -> element != null &&
!(element instanceof PyStarExpression) &&
!(element instanceof PyStarArgument) &&
PsiTreeUtil.isAncestor(valuesSource, element, false) &&
expressionCanBeInlined(pyString, element));
}
return false;
}
private static boolean expressionCanBeInlined(@NotNull PyStringLiteralExpression host, @NotNull PyExpression target) {
// Cannot inline multi-line expressions or expressions that contains backslashes (yet)
if (target.textContains('\\') || target.textContains('\n')) return false;
return adjustQuotesInside((PyExpression)target.copy(), host) != null;
}
@Nullable
private static PsiElement adjustQuotesInside(@NotNull PyExpression element, @NotNull PyStringLiteralExpression host) {
final StringNodeInfo hostInfo = new StringNodeInfo(host.getStringNodes().get(0));
final char hostQuote = hostInfo.getSingleQuote();
final PyElementGenerator generator = PyElementGenerator.getInstance(host.getProject());
final Collection<PyStringLiteralExpression> innerStrings = PsiTreeUtil.collectElementsOfType(element, PyStringLiteralExpression.class);
for (PyStringLiteralExpression literal : innerStrings) {
final List<ASTNode> nodes = literal.getStringNodes();
// TODO figure out what to do with those
if (nodes.size() > 1) {
return null;
}
final StringNodeInfo info = new StringNodeInfo(nodes.get(0));
// Nest string contain the same type of quote as host string inside, and we cannot escape inside f-string -- retreat
final String content = info.getContent();
if (content.indexOf(hostQuote) >= 0) {
return null;
}
if (!info.isTerminated()) {
return null;
}
if (info.getSingleQuote() == hostQuote) {
final char targetSingleQuote = hostQuote == '"' ? '\'' : '"';
final String targetQuote = info.getQuote().replace(hostQuote, targetSingleQuote);
final String stringWithSwappedQuotes = info.getPrefix() + targetQuote + content + targetQuote;
final PsiElement replaced = literal.replace(generator.createStringLiteralAlreadyEscaped(stringWithSwappedQuotes));
if (literal == element) {
return replaced;
}
}
}
return element;
}
@Nullable
private static Pair<PyStringLiteralExpression, Boolean> findTargetStringUnderCaret(@NotNull Editor editor, @NotNull PsiFile file) {
final PsiElement anchor = file.findElementAt(editor.getCaretModel().getOffset());
if (anchor == null) return null;
final PyBinaryExpression binaryExpr = PsiTreeUtil.getParentOfType(anchor, PyBinaryExpression.class);
if (binaryExpr != null && binaryExpr.getOperator() == PyTokenTypes.PERC) {
final PyStringLiteralExpression pyString = as(binaryExpr.getLeftExpression(), PyStringLiteralExpression.class);
if (pyString != null) {
return Pair.create(pyString, true);
}
}
final PyCallExpression callExpr = PsiTreeUtil.getParentOfType(anchor, PyCallExpression.class);
if (callExpr != null) {
final PyReferenceExpression callee = as(callExpr.getCallee(), PyReferenceExpression.class);
if (callee != null) {
final PyStringLiteralExpression pyString = as(callee.getQualifier(), PyStringLiteralExpression.class);
if (pyString != null) {
return Pair.create(pyString, false);
}
}
}
return null;
}
@Override
public void doInvoke(@NotNull Project project, Editor editor, PsiFile file) throws IncorrectOperationException {
final Pair<PyStringLiteralExpression, Boolean> pair = findTargetStringUnderCaret(editor, file);
assert pair != null;
final Boolean percentOperator = pair.getSecond();
if (percentOperator) {
convertPercentOperatorFormatting(pair.getFirst());
}
else {
convertFormatMethodFormatting(pair.getFirst());
}
}
private static void convertPercentOperatorFormatting(@NotNull PyStringLiteralExpression pyString) {
final String stringText = pyString.getText();
final Pair<String, String> quotes = PyStringLiteralUtil.getQuotes(stringText);
assert quotes != null;
final StringBuilder result = new StringBuilder();
result.append("f");
result.append(quotes.getFirst().replaceAll("[uU]", ""));
final List<FormatStringChunk> chunks = PyStringFormatParser.parsePercentFormat(stringText);
final TextRange contentRange = PyStringLiteralExpressionImpl.getNodeTextRange(stringText);
int subsChunkPosition = 0;
for (FormatStringChunk chunk : chunks) {
if (chunk instanceof ConstantChunk) {
final TextRange rangeWithoutQuotes = chunk.getTextRange().intersection(contentRange);
assert rangeWithoutQuotes != null;
result.append(rangeWithoutQuotes.substring(stringText));
}
else {
final SubstitutionChunk subsChunk = (SubstitutionChunk)chunk;
final char conversionChar = subsChunk.getConversionType();
final String widthAndPrecision;
if (StringUtil.isNotEmpty(subsChunk.getWidth()) || StringUtil.isNotEmpty(subsChunk.getPrecision())) {
widthAndPrecision = StringUtil.notNullize(subsChunk.getWidth()) + "." + StringUtil.notNullize(subsChunk.getPrecision());
}
else {
widthAndPrecision = "";
}
final String conversionFlags = subsChunk.getConversionFlags();
result.append("{");
final PySubstitutionChunkReference reference = new PySubstitutionChunkReference(pyString, subsChunk, subsChunkPosition, true);
final PyExpression resolveResult = getActualReplacementExpression(reference);
assert resolveResult != null;
final PsiElement adjusted = adjustQuotesInside(resolveResult, pyString);
if (adjusted == null) return;
result.append(adjusted.getText());
// TODO mostly duplicates the logic of ConvertFormatOperatorToMethodIntention
if (conversionChar == 'r') {
result.append("!r");
}
if ((conversionChar != 'r' && conversionChar != 's')
|| StringUtil.isNotEmpty(conversionFlags)
|| StringUtil.isNotEmpty(widthAndPrecision)) {
result.append(":");
}
if (StringUtil.isNotEmpty(conversionFlags)) {
final String conversionStr = String.valueOf(conversionChar);
result.append(ConvertFormatOperatorToMethodIntention.convertFormatSpec(conversionFlags, widthAndPrecision, conversionStr));
}
if (StringUtil.isNotEmpty(widthAndPrecision)) {
result.append(widthAndPrecision);
}
if ('i' == conversionChar || 'u' == conversionChar) {
result.append("d");
}
else if ('s' != conversionChar && 'r' != conversionChar) {
result.append(conversionChar);
}
result.append("}");
subsChunkPosition++;
}
}
result.append(quotes.getSecond());
final PyBinaryExpression expressionToReplace = PsiTreeUtil.getParentOfType(pyString, PyBinaryExpression.class);
assert expressionToReplace != null;
final PyElementGenerator generator = PyElementGenerator.getInstance(pyString.getProject());
final PyExpression fString = generator.createExpressionFromText(LanguageLevel.PYTHON36, result.toString());
expressionToReplace.replace(fString);
}
@Nullable
private static PyExpression getActualReplacementExpression(@NotNull PySubstitutionChunkReference reference) {
final PsiElement resolveResult = reference.resolve();
if (resolveResult == null) {
return null;
}
final PyKeywordArgument argument = as(resolveResult, PyKeywordArgument.class);
if (argument != null) {
return argument.getValueExpression();
}
final PyKeyValueExpression parent = as(resolveResult.getParent(), PyKeyValueExpression.class);
if (parent != null && parent.getKey() == resolveResult) {
return parent.getValue();
}
return as(resolveResult, PyExpression.class);
}
private void convertFormatMethodFormatting(@NotNull PyStringLiteralExpression pyString) {
}
}
@@ -0,0 +1 @@
b'{}'.format(42)
@@ -0,0 +1 @@
'{}' '{}'.format('foo', 42)
@@ -0,0 +1 @@
"%*s" % (5, 'spam')
@@ -0,0 +1,5 @@
'%s %s %s' % (
d[r'foo'][b'bar']['baz'],
u'''[]''',
f('foo', g(rf'bar'))
)
@@ -0,0 +1 @@
f'{d[r"foo"][b"bar"]["baz"]} {u"""[]"""} {f("foo", g(rf"bar"))}'
@@ -0,0 +1 @@
'%(foo)d %(bar)s' % dict(foo=.25, bar='spam'.upper())
@@ -0,0 +1 @@
"%(foo)s %(bar)r" % {'foo': 'bar', 'bar': [1, 2, 3]}
@@ -0,0 +1 @@
u'%s %d %f' % ("foo", 42, 2.5)
@@ -0,0 +1 @@
f'{"foo"} {42:d} {2.5:f}'
@@ -0,0 +1,81 @@
/*
* Copyright 2000-2016 JetBrains s.r.o.
*
* 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
*
* http://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 com.jetbrains.python.intentions;
import com.jetbrains.python.PyBundle;
import com.jetbrains.python.psi.LanguageLevel;
/**
* @author Mikhail Golubev
*/
public class PyConvertToFStringIntentionTest extends PyIntentionTestCase {
private void doTest() {
doTest(PyBundle.message("INTN.convert.to.fstring.literal"), LanguageLevel.PYTHON36);
}
private void doNegativeTest() {
runWithLanguageLevel(LanguageLevel.PYTHON36, () -> doNegativeTest(PyBundle.message("INTN.convert.to.fstring.literal")));
}
public void testFormatMethodByteString() {
doNegativeTest();
}
public void testFormatMethodGluedString() {
doNegativeTest();
}
public void testPercentOperatorDynamicWidth() {
doNegativeTest();
}
public void testPercentOperatorDynamicPrecision() {
doNegativeTest();
}
public void testPercentOperatorSingleExpression() {
doTest();
}
public void testPercentOperatorSimpleTuple() {
doTest();
}
public void testPercentOperatorSimpleDictLiteral() {
doTest();
}
public void testPercentOperatorSimpleDictConstructorCall() {
doTest();
}
public void testPercentOperatorQuotesInsideInlinedExpressions() {
doTest();
}
public void testPercentOperatorMultilineExpression() {
doNegativeTest();
}
public void testPercentOperatorExpressionWithBackslash() {
doNegativeTest();
}
public void testPercentOperatorExpressionContainBadQuotes() {
doNegativeTest();
}
}