Fix PY-20754 Supress 'Replace with str.format method call' for bytes
[idea/community.git] / python / src / com / jetbrains / python / codeInsight / intentions / ConvertFormatOperatorToMethodIntention.java
1 /*
2  * Copyright 2000-2014 JetBrains s.r.o.
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  * http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.jetbrains.python.codeInsight.intentions;
17
18 import com.intellij.codeInsight.intention.impl.BaseIntentionAction;
19 import com.intellij.lang.ASTNode;
20 import com.intellij.openapi.editor.Editor;
21 import com.intellij.openapi.project.Project;
22 import com.intellij.openapi.util.Pair;
23 import com.intellij.openapi.util.TextRange;
24 import com.intellij.openapi.util.text.StringUtil;
25 import com.intellij.psi.PsiElement;
26 import com.intellij.psi.PsiFile;
27 import com.intellij.psi.PsiWhiteSpace;
28 import com.intellij.psi.util.PsiTreeUtil;
29 import com.intellij.util.IncorrectOperationException;
30 import com.jetbrains.python.PyBundle;
31 import com.jetbrains.python.PyNames;
32 import com.jetbrains.python.PyTokenTypes;
33 import com.jetbrains.python.psi.*;
34 import com.jetbrains.python.psi.impl.PyBuiltinCache;
35 import com.jetbrains.python.psi.impl.PyPsiUtils;
36 import com.jetbrains.python.psi.impl.PyStringLiteralExpressionImpl;
37 import com.jetbrains.python.psi.types.PyClassType;
38 import com.jetbrains.python.psi.types.PyType;
39 import com.jetbrains.python.psi.types.PyTypeChecker;
40 import com.jetbrains.python.psi.types.TypeEvalContext;
41 import org.jetbrains.annotations.NotNull;
42
43 import java.util.ArrayList;
44 import java.util.List;
45 import java.util.regex.Matcher;
46 import java.util.regex.Pattern;
47
48 import static com.jetbrains.python.psi.PyUtil.guessLanguageLevel;
49 import static com.jetbrains.python.psi.PyUtil.sure;
50
51 /**
52  * Replaces expressions like <code>"%s" % values</code> with likes of <code>"{0:s}".format(values)</code>.
53  * <br/>
54  * Author: Alexey.Ivanov, dcheryasov
55  */
56 public class ConvertFormatOperatorToMethodIntention extends BaseIntentionAction {
57
58   private static final Pattern FORMAT_PATTERN =
59     Pattern.compile("%(?:\\((\\w+)\\))?([-#0+ ]*)((?:\\*|\\d+)?(?:\\.(?:\\*|\\d+))?)?[hlL]?([diouxXeEfFgGcrs%])");
60   // groups: %:ignored,     1:key      2:mods    3:width-and---preci.sion            x:len  4: conversion-type
61
62   private static final Pattern BRACE_PATTERN = Pattern.compile("(\\{|\\})");
63
64   /**
65    * copy source to target, doubling every brace.
66    */
67   private static void appendDoublingBraces(CharSequence source, StringBuilder target) {
68     int index = 0;
69     Matcher scanner = BRACE_PATTERN.matcher(source);
70     boolean skipClosingBrace = false;
71     while (scanner.find(index)) {
72       if (scanner.start() > 1) {
73         // handle escaping sequences PY-977
74         if ("{".equals(scanner.group(0)) && "\\N".equals(source.subSequence(scanner.start()-2, scanner.start()).toString())) {
75           skipClosingBrace = true;
76           target.append(source.subSequence(index, scanner.end()));
77           index = scanner.end();
78           continue;
79         }
80       }
81       if (skipClosingBrace && "}".equals(scanner.group(0))) {
82         skipClosingBrace = false;
83         target.append(source.subSequence(index, scanner.end()));
84         index = scanner.end();
85         continue;
86       }
87
88       target.append(source.subSequence(index, scanner.start()));
89       if ("{".equals(scanner.group(0))) target.append("{{");
90       else target.append("}}");
91       index = scanner.end();
92     }
93     target.append(source.subSequence(index, source.length()));
94   }
95
96   /**
97    * Converts format expressions inside a string
98    * @return a pair of string builder with resulting string expression and a flag which is true if formats inside use mapping by name.
99    */
100   private static Pair<StringBuilder, Boolean> convertFormat(PyStringLiteralExpression stringLiteralExpression, String prefix) {
101     // python string may be made of several literals, all different
102     List<StringBuilder> constants = new ArrayList<>();
103     boolean usesNamedFormat = false;
104     final List<ASTNode> stringNodes = stringLiteralExpression.getStringNodes();
105     sure(stringNodes);
106     sure(stringNodes.size() > 0);
107     for (ASTNode stringNode : stringNodes) {
108       // preserve prefixes and quote form
109       CharSequence text = stringNode.getChars();
110       int openPos = 0;
111       boolean hasPrefix = false;
112       final int prefixLength = PyStringLiteralExpressionImpl.getPrefixLength(String.valueOf(text));
113       if (prefixLength != 0) hasPrefix = true;
114       openPos += prefixLength;
115
116       char quote = text.charAt(openPos);
117       sure("\"'".indexOf(quote) >= 0);
118       if (text.length() - openPos >= 6) {
119         // triple-quoted?
120         if (text.charAt(openPos+1) == quote && text.charAt(openPos+2) == quote) {
121           openPos += 2;
122         }
123       }
124       int index = openPos + 1; // from quote to first in-string char
125       StringBuilder out = new StringBuilder(text.subSequence(0, openPos+1));
126       if (!hasPrefix) out.insert(0, prefix);
127       Matcher scanner = FORMAT_PATTERN.matcher(text);
128       while (scanner.find(index)) {
129         // store previous non-format part
130         appendDoublingBraces(text.subSequence(index, scanner.start()), out);
131         //out.append(text.subSequence(index, scanner.start()));
132         // unpack format
133         final String f_key = scanner.group(1);
134         final String f_modifier = scanner.group(2);
135         final String f_width = scanner.group(3);
136         String f_conversion = scanner.group(4);
137         // convert to format()'s
138         if ("%%".equals(scanner.group(0))) {
139           // shortcut to put a literal %
140           out.append("%");
141         }
142         else {
143           sure(f_conversion);
144           sure(!"%".equals(f_conversion)); // a padded percent literal; can't bother to autoconvert, and in 3k % is different.
145           out.append("{");
146           if (f_key != null) {
147             out.append(f_key);
148             usesNamedFormat = true;
149           }
150           if ("r".equals(f_conversion)) out.append("!r");
151           // don't convert %s -> !s, for %s is the normal way to output the default representation
152           out.append(":");
153           if (f_modifier != null) {
154             // in strict order
155             if (has(f_modifier, '-')) out.append("<"); // left align
156             else if ("s".equals(f_conversion) && !StringUtil.isEmptyOrSpaces(f_width)) {
157               // "%20s" aligns right, "{0:20s}" aligns left; to preserve align, make it explicit
158               out.append(">");
159             }
160             if (has(f_modifier, '+')) out.append("+"); // signed
161             else if (has(f_modifier, ' ')) out.append(" "); // default-signed
162             if (has(f_modifier, '#')) out.append("#"); // alt numbers
163             if (has(f_modifier, '0')) out.append("0"); // padding
164             // anything else can't be here
165           }
166           if (f_width != null) {
167             out.append(f_width);
168           }
169           if ("i".equals(f_conversion) || "u".equals(f_conversion)) out.append("d");
170           else if ("r".equals(f_conversion)) out.append("s"); // we want our raw string as a string
171           else if (!"s".equals(f_conversion)) out.append(f_conversion);
172
173           final int lastIndexOf = out.lastIndexOf(":");
174           if (lastIndexOf == out.length() - 1) {
175             out.deleteCharAt(lastIndexOf);
176           }
177           out.append("}");
178         }
179         index = scanner.end();
180       }
181       // store non-format final part
182       //out.append(text.subSequence(index, text.length()-1));
183       appendDoublingBraces(text.subSequence(index, text.length()), out);
184       constants.add(out);
185     }
186     // form the entire literal filling possible gaps between constants.
187     // we assume that a string literal begins with its first constant, without a gap.
188     TextRange full_range = stringLiteralExpression.getTextRange();
189     int full_start = full_range.getStartOffset();
190     CharSequence full_text = stringLiteralExpression.getNode().getChars();
191     TextRange prev_range = stringNodes.get(0).getTextRange();
192     int fragment_no = 1; // look at second and further fragments
193     while (fragment_no < stringNodes.size()) {
194       TextRange next_range = stringNodes.get(fragment_no).getTextRange();
195       int left = prev_range.getEndOffset() - full_start;
196       int right = next_range.getStartOffset() - full_start;
197       if (left < right) {
198         constants.get(fragment_no-1).append(full_text.subSequence(left, right));
199       }
200       fragment_no += 1;
201       prev_range = next_range;
202     }
203     final int left = prev_range.getEndOffset() - full_start;
204     final int right = full_range.getEndOffset() - full_start;
205     if (left < right) {
206       // the barely possible case of last dangling "\"
207       constants.get(constants.size()-1).append(full_text.subSequence(left, right));
208     }
209
210     // join everything
211     StringBuilder result = new StringBuilder();
212     for (StringBuilder one : constants) result.append(one);
213     return new Pair<>(result, usesNamedFormat);
214   }
215
216   private static boolean has(String where, char what) {
217     return where.indexOf(what) >= 0;
218   }
219
220   @NotNull
221   public String getFamilyName() {
222     return PyBundle.message("INTN.format.operator.to.method");
223   }
224
225   public boolean isAvailable(@NotNull Project project, Editor editor, PsiFile file) {
226     if (!(file instanceof PyFile)) {
227       return false;
228     }
229
230     PyBinaryExpression binaryExpression  =
231       PsiTreeUtil.getParentOfType(file.findElementAt(editor.getCaretModel().getOffset()), PyBinaryExpression.class, false);
232     if (binaryExpression == null) {
233       return false;
234     }
235     final LanguageLevel languageLevel = LanguageLevel.forElement(binaryExpression);
236     if (languageLevel.isOlderThan(LanguageLevel.PYTHON26)) {
237       return false;
238     }
239     if (binaryExpression.getLeftExpression() instanceof PyStringLiteralExpression 
240         && binaryExpression.getOperator() == PyTokenTypes.PERC) {
241       final PyStringLiteralExpression str = (PyStringLiteralExpression)binaryExpression.getLeftExpression();
242       if (!(str.getText().length() > 0 && Character.toUpperCase(str.getText().charAt(0)) == 'B')) {
243         setText(PyBundle.message("INTN.replace.with.method"));
244         return true;        
245       }
246     }
247     return false;
248   }
249
250   public void invoke(@NotNull Project project, Editor editor, PsiFile file) throws IncorrectOperationException {
251     final PsiElement elementAt = file.findElementAt(editor.getCaretModel().getOffset());
252     final PyBinaryExpression element = PsiTreeUtil.getParentOfType(elementAt, PyBinaryExpression.class, false);
253     if (element == null) return;
254     final PyElementGenerator elementGenerator = PyElementGenerator.getInstance(project);
255     final PyExpression rightExpression = sure(element).getRightExpression();
256     if (rightExpression == null) {
257       return;
258     }
259     final PyExpression rhs = PyPsiUtils.flattenParens(rightExpression);
260     if (rhs == null) return;
261     final String paramText = sure(rhs).getText();
262     final TypeEvalContext context = TypeEvalContext.userInitiated(file.getProject(), file);
263     final PyType rhsType = context.getType(rhs);
264     String prefix = "";
265     final LanguageLevel languageLevel = guessLanguageLevel(project);
266     if (!languageLevel.isPy3K() && PyTypeChecker.match(PyBuiltinCache.getInstance(rhs).getObjectType("unicode"), rhsType, context)) {
267       prefix = "u";
268     }
269     final PyStringLiteralExpression leftExpression = (PyStringLiteralExpression)element.getLeftExpression();
270     final Pair<StringBuilder, Boolean> converted = convertFormat(leftExpression, prefix);
271     final StringBuilder target = converted.getFirst();
272     final String separator = getSeparator(leftExpression);
273     target.append(separator).append(".format");
274
275     if (rhs instanceof PyDictLiteralExpression) target.append("(**").append(paramText).append(")");
276     else if (rhs instanceof PyCallExpression) { // potential dict(foo=1) -> format(foo=1)
277       final PyCallExpression callExpression = (PyCallExpression)rhs;
278       final PyExpression callee = callExpression.getCallee();
279       if (callee instanceof PyReferenceExpression) {
280         PsiElement maybeDict = ((PyReferenceExpression)callee).getReference().resolve();
281         if (maybeDict instanceof PyFunction) {
282           PyFunction dictInit = (PyFunction)maybeDict;
283           if (PyNames.INIT.equals(dictInit.getName())) {
284             final PyClassType dictType = PyBuiltinCache.getInstance(file).getDictType();
285             if (dictType != null && dictType.getPyClass() == dictInit.getContainingClass()) {
286               target.append(sure(sure(callExpression.getArgumentList()).getNode()).getChars());
287             }
288           }
289           else { // just a call, reuse
290             target.append("(");
291             if (converted.getSecond()) target.append("**"); // map-by-name formatting was detected
292             target.append(paramText).append(")");
293           }
294         }
295       }
296     }
297     else target.append("(").append(paramText).append(")"); // tuple is ok as is
298     // Correctly handle multiline implicitly concatenated string literals (PY-9176)
299     target.insert(0, '(').append(')');
300     final PyExpression parenthesized = elementGenerator.createExpressionFromText(LanguageLevel.forElement(element), target.toString());
301     element.replace(sure(((PyParenthesizedExpression)parenthesized).getContainedExpression()));
302   }
303
304   private static String getSeparator(PyStringLiteralExpression leftExpression) {
305     String separator = ""; // detect nontrivial whitespace around the "%"
306     Pair<String, PsiElement> crop = collectWhitespace(leftExpression);
307     String maybeSeparator = crop.getFirst();
308     if (maybeSeparator != null && !maybeSeparator.isEmpty() && !" ".equals(maybeSeparator))
309       separator = maybeSeparator;
310     else { // after "%"
311       crop = collectWhitespace(crop.getSecond());
312       maybeSeparator = crop.getFirst();
313       if (maybeSeparator != null && !maybeSeparator.isEmpty() && !" ".equals(maybeSeparator))
314         separator = maybeSeparator;
315     }
316     return separator;
317   }
318
319   private static Pair<String, PsiElement> collectWhitespace(PsiElement start) {
320     StringBuilder sb = new StringBuilder();
321     PsiElement seeker = start;
322     while (seeker != null) {
323       seeker = seeker.getNextSibling();
324       if (seeker != null && seeker instanceof PsiWhiteSpace) sb.append(seeker.getText());
325       else break;
326     }
327     return Pair.create(sb.toString(), seeker);
328   }
329 }