Split PyConvertToFStringIntention into several classes

This commit is contained in:
Mikhail Golubev
2016-11-08 19:03:04 +03:00
parent a1a9155c9c
commit af120efee4
9 changed files with 586 additions and 459 deletions

View File

@@ -312,7 +312,7 @@
</intentionAction>
<intentionAction>
<className>com.jetbrains.python.codeInsight.intentions.PyConvertToFStringIntention</className>
<className>com.jetbrains.python.codeInsight.intentions.convertToFString.PyConvertToFStringIntention</className>
<category>Python</category>
</intentionAction>

View File

@@ -1,456 +0,0 @@
/*
* 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.intellij.util.ObjectUtils;
import com.jetbrains.python.PyBundle;
import com.jetbrains.python.PyNames;
import com.jetbrains.python.PyTokenTypes;
import com.jetbrains.python.codeInsight.PySubstitutionChunkReference;
import com.jetbrains.python.codeInsight.PythonFormattedStringReferenceProvider;
import com.jetbrains.python.inspections.PyNewStyleStringFormatParser;
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.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import static com.jetbrains.python.codeInsight.intentions.ConvertFormatOperatorToMethodIntention.convertFormatSpec;
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<SubstitutionChunk> substitutions;
if (percentOperator) {
final List<FormatStringChunk> chunks = PyStringFormatParser.parsePercentFormat(stringText);
substitutions = PyStringFormatParser.filterSubstitutions(chunks);
}
else {
substitutions = new ArrayList<>(PyNewStyleStringFormatParser.parse(stringText).getFields());
}
// TODO handle dynamic width and precision in old-style/"percent" formatting
if (percentOperator && substitutions.stream().anyMatch(s -> "*".equals(s.getWidth()) || "*".equals(s.getPrecision()))) {
return false;
}
final PySubstitutionChunkReference[] references = createChunkReferences(substitutions, pyString, 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();
}
if (percentOperator) {
for (int i = 0; i < substitutions.size(); i++) {
final SubstitutionChunk chunk = substitutions.get(i);
if ((chunk.getMappingKey() != null || substitutions.size() > 1) && references[i].resolve() == valuesSource) {
return false;
}
}
}
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;
}
@NotNull
private static PySubstitutionChunkReference[] createChunkReferences(@NotNull List<SubstitutionChunk> substitutions,
@NotNull PyStringLiteralExpression pyString,
boolean percentOperator) {
if (percentOperator) {
return PythonFormattedStringReferenceProvider.getReferencesFromChunks(pyString, substitutions, true);
}
return substitutions.stream()
.filter(PyNewStyleStringFormatParser.Field.class::isInstance)
.map(PyNewStyleStringFormatParser.Field.class::cast)
.map(field -> new PySubstitutionChunkReference(pyString, field, ObjectUtils.chooseNotNull(field.getAutoPosition(), 0), false))
.toArray(PySubstitutionChunkReference[]::new);
}
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();
final char targetSingleQuote = flipQuote(hostQuote);
if (content.indexOf(hostQuote) >= 0) {
return null;
}
if (!info.isTerminated()) {
return null;
}
if (info.getQuote().startsWith(hostInfo.getQuote())) {
if (content.indexOf(targetSingleQuote) >= 0) {
return null;
}
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 && PyNames.FORMAT.equals(callee.getName())) {
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();
String widthAndPrecision = StringUtil.notNullize(subsChunk.getWidth());
if (StringUtil.isNotEmpty(subsChunk.getPrecision())) {
widthAndPrecision += "." + subsChunk.getPrecision();
}
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(":");
}
result.append(convertFormatSpec(StringUtil.notNullize(conversionFlags), widthAndPrecision, String.valueOf(conversionChar)));
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 static void convertFormatMethodFormatting(@NotNull PyStringLiteralExpression pyString) {
// TODO get rid of duplication with #convertPercentOperatorFormatting
final String stringText = pyString.getText();
final StringNodeInfo stringInfo = new StringNodeInfo(pyString.getStringNodes().get(0));
final StringBuilder newStringText = new StringBuilder();
newStringText.append("f");
newStringText.append(stringInfo.getPrefix().replaceAll("[uU]", ""));
newStringText.append(stringInfo.getQuote());
final PyNewStyleStringFormatParser.ParseResult parseResult = PyNewStyleStringFormatParser.parse(stringText);
final TextRange contentRange = stringInfo.getContentRange();
int offset = contentRange.getStartOffset();
for (PyNewStyleStringFormatParser.Field field : parseResult.getFields()) {
// Preceding literal text
newStringText.append(stringText.substring(offset, field.getLeftBraceOffset()));
offset = field.getFieldEnd();
if (!processField(field, pyString, newStringText, true)) {
return;
}
}
if (offset < contentRange.getEndOffset()) {
newStringText.append(stringText.substring(offset, contentRange.getEndOffset()));
}
newStringText.append(stringInfo.getQuote());
final PyCallExpression expressionToReplace = PsiTreeUtil.getParentOfType(pyString, PyCallExpression.class);
assert expressionToReplace != null;
final PyElementGenerator generator = PyElementGenerator.getInstance(pyString.getProject());
final PyExpression fString = generator.createExpressionFromText(LanguageLevel.PYTHON36, newStringText.toString());
expressionToReplace.replace(fString);
}
private static boolean processField(@NotNull PyNewStyleStringFormatParser.Field field,
@NotNull PyStringLiteralExpression pyString,
@NotNull StringBuilder newStringText,
boolean withNestedFields) {
String stringText = pyString.getText();
StringNodeInfo stringInfo = new StringNodeInfo(pyString.getStringNodes().get(0));
// Actual format field
newStringText.append("{");
// Isn't supposed to be used by PySubstitutionChunkReference if explicit name or index is given
final int autoNumber = field.getAutoPosition() == null ? 0 : field.getAutoPosition();
final PySubstitutionChunkReference reference = new PySubstitutionChunkReference(pyString, field, autoNumber, false);
final PyExpression resolveResult = getActualReplacementExpression(reference);
if (resolveResult == null) return false;
final PsiElement adjusted = adjustQuotesInside(resolveResult, pyString);
if (adjusted == null) return false;
newStringText.append(adjusted.getText());
final String quotedAttrsAndItems = quoteItemsInFragments(field, stringInfo);
if (quotedAttrsAndItems == null) return false;
newStringText.append(quotedAttrsAndItems);
// Conversion is copied as is if it's present
final String conversion = field.getConversion();
if (conversion != null) {
newStringText.append(conversion);
}
// Format spec is copied if present handling nested fields
final TextRange specRange = field.getFormatSpecRange();
if (specRange != null) {
if (withNestedFields) {
int specOffset = specRange.getStartOffset();
for (PyNewStyleStringFormatParser.Field nestedField : field.getNestedFields()) {
// Copy text of the format spec between nested fragments
newStringText.append(stringText.substring(specOffset, nestedField.getLeftBraceOffset()));
specOffset = nestedField.getFieldEnd();
// recursively format nested field
if (!processField(nestedField, pyString, newStringText, false)) {
return false;
}
}
if (specOffset < specRange.getEndOffset()) {
newStringText.append(stringText.substring(specOffset, specRange.getEndOffset()));
}
}
else {
// Fields nested deeper that twice append as is
newStringText.append(field.getFormatSpec());
}
}
newStringText.append("}");
return true;
}
@Nullable
private static String quoteItemsInFragments(@NotNull PyNewStyleStringFormatParser.Field field, @NotNull StringNodeInfo hostStringInfo) {
List<String> escaped = new ArrayList<>();
for (String part : field.getAttributesAndLookups()) {
if (part.startsWith(".")) {
escaped.add(part);
}
else if (part.startsWith("[")) {
if (part.contains("\\")) {
return null;
}
final String indexText = part.substring(1, part.length() - 1);
if (indexText.matches("\\d+")) {
escaped.add(part);
continue;
}
final char originalQuote = hostStringInfo.getSingleQuote();
char targetQuote = flipQuote(originalQuote);
// there are no escapes inside the fragment, so the lookup key cannot contain
// the host string quote unless it's a multiline string literal
if (indexText.indexOf(targetQuote) >= 0) {
if (!hostStringInfo.isTripleQuoted() || indexText.indexOf(originalQuote) >= 0) {
return null;
}
targetQuote = originalQuote;
}
escaped.add("[" + targetQuote + indexText + targetQuote + "]");
}
}
return StringUtil.join(escaped, "");
}
private static char flipQuote(char quote) {
return quote == '"' ? '\'' : '"';
}
}

View File

@@ -0,0 +1,183 @@
/*
* 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.convertToFString;
import com.intellij.lang.ASTNode;
import com.intellij.openapi.util.TextRange;
import com.intellij.openapi.util.text.StringUtil;
import com.intellij.psi.PsiElement;
import com.intellij.psi.util.PsiTreeUtil;
import com.jetbrains.python.codeInsight.PySubstitutionChunkReference;
import com.jetbrains.python.inspections.PyStringFormatParser;
import com.jetbrains.python.psi.*;
import com.jetbrains.python.psi.PyUtil.StringNodeInfo;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Collection;
import java.util.List;
import static com.jetbrains.python.psi.PyUtil.as;
/**
* @author Mikhail Golubev
*/
public abstract class BaseConvertToFStringProcessor<T extends PyStringFormatParser.SubstitutionChunk> {
protected final PyStringLiteralExpression myPyString;
protected final StringNodeInfo myNodeInfo;
protected BaseConvertToFStringProcessor(@NotNull PyStringLiteralExpression pyString) {
myPyString = pyString;
myNodeInfo = new StringNodeInfo(pyString.getStringNodes().get(0));
}
public final boolean isRefactoringAvailable() {
// TODO support glued/concatenated string literal with multiple nodes
if (myPyString.getStringNodes().size() > 1 || myNodeInfo.isBytes() || myNodeInfo.isFormatted()) return false;
final PsiElement valuesSource = getValuesSource();
if (valuesSource == null) return false;
final List<T> chunks = extractAllSubstitutionChunks();
for (int i = 0; i < chunks.size(); i++) {
final T chunk = chunks.get(i);
if (!checkChunk(chunk)) return false;
final PySubstitutionChunkReference reference = createReference(chunk, i);
final PsiElement referencedExpr = adjustResolveResult(reference.resolve());
if (referencedExpr == null) return false;
if (!PsiTreeUtil.isAncestor(valuesSource, referencedExpr, false)) return false;
if (referencedExpr instanceof PyStarExpression || referencedExpr instanceof PyStarArgument) return false;
if (!checkReferencedExpression(chunks, i, valuesSource, referencedExpr)) return false;
}
return true;
}
protected boolean checkReferencedExpression(@NotNull List<T> chunks,
int position,
@NotNull PsiElement valueSource,
@NotNull PsiElement expression) {
if (expression.textContains('\\') || expression.textContains('\n')) return false;
return adjustQuotesInsideInjectedExpression(expression) != null;
}
public final void doRefactoring() {
final String stringText = myPyString.getText();
final StringBuilder fStringText = new StringBuilder();
fStringText.append("f");
fStringText.append(StringUtil.replaceIgnoreCase(myNodeInfo.getPrefix(), "u", ""));
fStringText.append(myNodeInfo.getQuote());
final TextRange contentRange = myNodeInfo.getContentRange();
int offset = contentRange.getStartOffset();
final List<T> chunks = extractTopLevelSubstitutionChunks();
for (int i = 0; i < chunks.size(); i++) {
final T chunk = chunks.get(i);
// Preceding literal text
fStringText.append(stringText, offset, chunk.getStartIndex());
offset = chunk.getEndIndex();
if (!convertSubstitutionChunk(chunk, i, fStringText)) return;
}
if (offset < contentRange.getEndOffset()) {
fStringText.append(stringText, offset, contentRange.getEndOffset());
}
fStringText.append(myNodeInfo.getQuote());
final PyExpression expressionToReplace = getWholeExpressionToReplace();
final PyElementGenerator generator = PyElementGenerator.getInstance(myPyString.getProject());
final PyExpression fString = generator.createExpressionFromText(LanguageLevel.forElement(myPyString), fStringText.toString());
expressionToReplace.replace(fString);
}
@NotNull
protected abstract List<T> extractAllSubstitutionChunks();
@NotNull
protected List<T> extractTopLevelSubstitutionChunks() {
return extractAllSubstitutionChunks();
}
@NotNull
protected abstract PySubstitutionChunkReference createReference(@NotNull T chunk, int position);
protected abstract boolean checkChunk(@NotNull T chunk);
protected abstract boolean convertSubstitutionChunk(@NotNull T chunk, int position, @NotNull StringBuilder fStringText);
@Nullable
protected PsiElement adjustQuotesInsideInjectedExpression(@NotNull PsiElement expression) {
final PsiElement copied = expression.copy();
final char hostQuote = myNodeInfo.getSingleQuote();
final PyElementGenerator generator = PyElementGenerator.getInstance(myPyString.getProject());
final Collection<PyStringLiteralExpression> innerStrings = PsiTreeUtil.collectElementsOfType(copied, 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 copied;
}
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.getQuote().startsWith(myNodeInfo.getQuote())) {
final char targetSingleQuote = PyStringLiteralUtil.flipQuote(hostQuote);
if (content.indexOf(targetSingleQuote) >= 0) {
return null;
}
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 == copied) {
return replaced;
}
}
}
return copied;
}
@NotNull
protected abstract PyExpression getWholeExpressionToReplace();
@Nullable
protected abstract PsiElement getValuesSource();
@Nullable
protected static PyExpression adjustResolveResult(@Nullable PsiElement resolveResult) {
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);
}
}

View File

@@ -0,0 +1,166 @@
/*
* 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.convertToFString;
import com.intellij.openapi.util.TextRange;
import com.intellij.openapi.util.text.StringUtil;
import com.intellij.psi.PsiElement;
import com.intellij.psi.util.PsiTreeUtil;
import com.intellij.util.ObjectUtils;
import com.jetbrains.python.codeInsight.PySubstitutionChunkReference;
import com.jetbrains.python.inspections.PyNewStyleStringFormatParser;
import com.jetbrains.python.inspections.PyNewStyleStringFormatParser.Field;
import com.jetbrains.python.psi.PyCallExpression;
import com.jetbrains.python.psi.PyExpression;
import com.jetbrains.python.psi.PyStringLiteralExpression;
import com.jetbrains.python.psi.PyStringLiteralUtil;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.List;
/**
* @author Mikhail Golubev
*/
public class NewStyleConvertToFStringProcessor extends BaseConvertToFStringProcessor<Field> {
public NewStyleConvertToFStringProcessor(@NotNull PyStringLiteralExpression pyString) {
super(pyString);
}
@NotNull
@Override
protected List<Field> extractAllSubstitutionChunks() {
return PyNewStyleStringFormatParser.parse(myPyString.getText()).getAllFields();
}
@NotNull
@Override
protected List<Field> extractTopLevelSubstitutionChunks() {
return PyNewStyleStringFormatParser.parse(myPyString.getText()).getFields();
}
@NotNull
@Override
protected PySubstitutionChunkReference createReference(@NotNull Field field, int position /* unused */) {
return new PySubstitutionChunkReference(myPyString, field, ObjectUtils.chooseNotNull(field.getAutoPosition(), 0), false);
}
@Override
protected boolean checkChunk(@NotNull Field chunk) {
return true;
}
@NotNull
@Override
public PyExpression getWholeExpressionToReplace() {
//noinspection ConstantConditions
return PsiTreeUtil.getParentOfType(myPyString, PyCallExpression.class);
}
@Nullable
@Override
protected PsiElement getValuesSource() {
final PyCallExpression callExpression = PsiTreeUtil.getParentOfType(myPyString, PyCallExpression.class);
assert callExpression != null;
return callExpression.getArgumentList();
}
@Override
protected boolean convertSubstitutionChunk(@NotNull Field field, int position, @NotNull StringBuilder fStringText) {
final String stringText = myPyString.getText();
// Actual format field
fStringText.append("{");
// Isn't supposed to be used by PySubstitutionChunkReference if explicit name or index is given
final PySubstitutionChunkReference reference = createReference(field, 0);
final PyExpression resolveResult = adjustResolveResult(reference.resolve());
if (resolveResult == null) return false;
final PsiElement adjusted = adjustQuotesInsideInjectedExpression(resolveResult);
if (adjusted == null) return false;
fStringText.append(adjusted.getText());
final String quotedAttrsAndItems = quoteItemsInFragments(field);
if (quotedAttrsAndItems == null) return false;
fStringText.append(quotedAttrsAndItems);
// Conversion is copied as is if it's present
final String conversion = field.getConversion();
if (conversion != null) {
fStringText.append(conversion);
}
// Format spec is copied if present handling nested fields
final TextRange specRange = field.getFormatSpecRange();
if (specRange != null) {
int specOffset = specRange.getStartOffset();
// Do not proceed too nested fields
if (field.getDepth() == 1) {
for (Field nestedField : field.getNestedFields()) {
// Copy text of the format spec between nested fragments
fStringText.append(stringText, specOffset, nestedField.getLeftBraceOffset());
specOffset = nestedField.getFieldEnd();
// recursively format nested field
if (!convertSubstitutionChunk(nestedField, 0, fStringText)) {
return false;
}
}
}
if (specOffset < specRange.getEndOffset()) {
fStringText.append(stringText, specOffset, specRange.getEndOffset());
}
}
fStringText.append("}");
return true;
}
@Nullable
private String quoteItemsInFragments(@NotNull Field field) {
final List<String> escaped = new ArrayList<>();
for (String part : field.getAttributesAndLookups()) {
if (part.startsWith(".")) {
escaped.add(part);
}
else if (part.startsWith("[")) {
if (part.contains("\\")) {
return null;
}
final String indexText = part.substring(1, part.length() - 1);
if (indexText.matches("\\d+")) {
escaped.add(part);
continue;
}
final char originalQuote = myNodeInfo.getSingleQuote();
char targetQuote = PyStringLiteralUtil.flipQuote(originalQuote);
// there are no escapes inside the fragment, so the lookup key cannot contain
// the host string quote unless it's a multiline string literal
if (indexText.indexOf(targetQuote) >= 0) {
if (!myNodeInfo.isTripleQuoted() || indexText.indexOf(originalQuote) >= 0) {
return null;
}
targetQuote = originalQuote;
}
escaped.add("[" + targetQuote + indexText + targetQuote + "]");
}
}
return StringUtil.join(escaped, "");
}
}

View File

@@ -0,0 +1,135 @@
/*
* 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.convertToFString;
import com.intellij.openapi.util.text.StringUtil;
import com.intellij.psi.PsiElement;
import com.intellij.psi.util.PsiTreeUtil;
import com.jetbrains.python.codeInsight.PySubstitutionChunkReference;
import com.jetbrains.python.inspections.PyStringFormatParser;
import com.jetbrains.python.inspections.PyStringFormatParser.SubstitutionChunk;
import com.jetbrains.python.psi.PyBinaryExpression;
import com.jetbrains.python.psi.PyExpression;
import com.jetbrains.python.psi.PyStringLiteralExpression;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import static com.jetbrains.python.codeInsight.intentions.ConvertFormatOperatorToMethodIntention.convertFormatSpec;
import static com.jetbrains.python.psi.PyUtil.as;
/**
* @author Mikhail Golubev
*/
public class OldStyleConvertToFStringProcessor extends BaseConvertToFStringProcessor<SubstitutionChunk> {
public OldStyleConvertToFStringProcessor(@NotNull PyStringLiteralExpression pyString) {
super(pyString);
}
@NotNull
@Override
protected List<SubstitutionChunk> extractAllSubstitutionChunks() {
return PyStringFormatParser.filterSubstitutions(PyStringFormatParser.parsePercentFormat(myPyString.getText()));
}
@NotNull
@Override
protected PySubstitutionChunkReference createReference(@NotNull SubstitutionChunk chunk, int position) {
return new PySubstitutionChunkReference(myPyString, chunk, position, true);
}
@Override
protected boolean checkChunk(@NotNull SubstitutionChunk chunk) {
// TODO handle dynamic width and precision in old-style/"percent" formatting
return !("*".equals(chunk.getPrecision()) || "*".equals(chunk.getWidth()));
}
@Override
protected boolean checkReferencedExpression(@NotNull List<SubstitutionChunk> chunks,
int position,
@NotNull PsiElement valueSource,
@NotNull PsiElement expression) {
final SubstitutionChunk chunk = chunks.get(position);
if ((chunk.getMappingKey() != null || chunks.size() > 1) && expression == valueSource) {
return false;
}
return super.checkReferencedExpression(chunks, position, valueSource, expression);
}
@NotNull
@Override
public PyExpression getWholeExpressionToReplace() {
//noinspection ConstantConditions
return PsiTreeUtil.getParentOfType(myPyString, PyBinaryExpression.class);
}
@Nullable
@Override
protected PsiElement getValuesSource() {
final PyBinaryExpression binaryExpression = as(myPyString.getParent(), PyBinaryExpression.class);
assert binaryExpression != null;
return binaryExpression.getRightExpression();
}
@Override
protected boolean convertSubstitutionChunk(@NotNull SubstitutionChunk subsChunk, int position, @NotNull StringBuilder fStringText) {
final char conversionChar = subsChunk.getConversionType();
String widthAndPrecision = StringUtil.notNullize(subsChunk.getWidth());
if (StringUtil.isNotEmpty(subsChunk.getPrecision())) {
widthAndPrecision += "." + subsChunk.getPrecision();
}
final String conversionFlags = subsChunk.getConversionFlags();
fStringText.append("{");
final PySubstitutionChunkReference reference = createReference(subsChunk, position);
final PyExpression resolveResult = adjustResolveResult(reference.resolve());
assert resolveResult != null;
final PsiElement adjusted = adjustQuotesInsideInjectedExpression(resolveResult);
if (adjusted == null) return false;
fStringText.append(adjusted.getText());
// TODO mostly duplicates the logic of ConvertFormatOperatorToMethodIntention
if (conversionChar == 'r') {
fStringText.append("!r");
}
if ((conversionChar != 'r' && conversionChar != 's')
|| StringUtil.isNotEmpty(conversionFlags)
|| StringUtil.isNotEmpty(widthAndPrecision)) {
fStringText.append(":");
}
fStringText.append(convertFormatSpec(StringUtil.notNullize(conversionFlags), widthAndPrecision, String.valueOf(conversionChar)));
if (StringUtil.isNotEmpty(widthAndPrecision)) {
fStringText.append(widthAndPrecision);
}
if ('i' == conversionChar || 'u' == conversionChar) {
fStringText.append("d");
}
else if ('s' != conversionChar && 'r' != conversionChar) {
fStringText.append(conversionChar);
}
fStringText.append("}");
return true;
}
}

View File

@@ -0,0 +1,92 @@
/*
* 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.convertToFString;
import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.project.Project;
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.PyNames;
import com.jetbrains.python.PyTokenTypes;
import com.jetbrains.python.codeInsight.intentions.PyBaseIntentionAction;
import com.jetbrains.python.psi.*;
import org.jetbrains.annotations.Nls;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
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).isOlderThan(LanguageLevel.PYTHON36)) return false;
final BaseConvertToFStringProcessor processor = findSuitableProcessor(editor, file);
return processor != null && processor.isRefactoringAvailable();
}
@Override
public void doInvoke(@NotNull Project project, Editor editor, PsiFile file) throws IncorrectOperationException {
final BaseConvertToFStringProcessor processor = findSuitableProcessor(editor, file);
assert processor != null;
processor.doRefactoring();
}
@Nullable
private static BaseConvertToFStringProcessor findSuitableProcessor(@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 new OldStyleConvertToFStringProcessor(pyString);
}
}
final PyCallExpression callExpr = PsiTreeUtil.getParentOfType(anchor, PyCallExpression.class);
if (callExpr != null) {
final PyReferenceExpression callee = as(callExpr.getCallee(), PyReferenceExpression.class);
if (callee != null && PyNames.FORMAT.equals(callee.getName())) {
final PyStringLiteralExpression pyString = as(callee.getQualifier(), PyStringLiteralExpression.class);
if (pyString != null) {
return new NewStyleConvertToFStringProcessor(pyString);
}
}
}
return null;
}
}

View File

@@ -159,6 +159,13 @@ public class PyStringLiteralUtil {
return StringUtil.indexOfIgnoreCase(prefix, 'f', 0) >= 0;
}
/**
* @return alternative quote character, i.e. " for ' and ' for "
*/
public static char flipQuote(char quote) {
return quote == '"' ? '\'' : '"';
}
@Nullable
private static Pair<String, String> getQuotes(@NotNull String text, @NotNull String prefix, @NotNull String quote) {
final int length = text.length();

View File

@@ -1 +1 @@
'{0.attr[item]:{foo[item]:5} {bar.attr:{baz}}}'.format(42, foo=func(), bar=MyClass(1, 2), baz=unused)
'{0.attr[item]:{foo[item]:5} {bar.attr:{baz} {quux}}}'.format(42, foo=func(), bar=MyClass(1, 2), baz=unused, quux=unused)

View File

@@ -1 +1 @@
f'{42.attr["item"]:{func()["item"]:5} {MyClass(1, 2).attr:{baz}}}'
f'{42.attr["item"]:{func()["item"]:5} {MyClass(1, 2).attr:{baz} {quux}}}'