diff --git a/python/src/com/jetbrains/python/PyBundle.properties b/python/src/com/jetbrains/python/PyBundle.properties index 0ab87e4aafc0..3820a1a69a5b 100644 --- a/python/src/com/jetbrains/python/PyBundle.properties +++ b/python/src/com/jetbrains/python/PyBundle.properties @@ -1035,6 +1035,7 @@ formatter.align.when.multiline=Align when multiline formatter.collections.and.comprehensions=Collections and Comprehensions formatter.from.import.statements="From" Import Statements formatter.from.import.statements.force.parentheses=Force parentheses +formatter.from.import.statements.force.comma.if.multline=Force trailing comma if multiline formatter.dictionary.literals=Dictionary literals formatter.hang.closing.brackets=Hang closing brackets diff --git a/python/src/com/jetbrains/python/formatter/PyCodeStyleSettings.java b/python/src/com/jetbrains/python/formatter/PyCodeStyleSettings.java index 21ec073e7662..1c48174e4a43 100644 --- a/python/src/com/jetbrains/python/formatter/PyCodeStyleSettings.java +++ b/python/src/com/jetbrains/python/formatter/PyCodeStyleSettings.java @@ -120,6 +120,7 @@ public class PyCodeStyleSettings extends CustomCodeStyleSettings { CommonCodeStyleSettings.FORCE_BRACES_ALWAYS} ) public int FROM_IMPORT_PARENTHESES_FORCE = CommonCodeStyleSettings.DO_NOT_FORCE; + public boolean FROM_IMPORT_TRAILING_COMMA_IF_MULTILINE = false; /** * Corresponds to the option of pycodestyle.py "--hang-closing". Basically, it means that the closing brace of a collection literal, diff --git a/python/src/com/jetbrains/python/formatter/PyFromImportPostFormatProcessor.java b/python/src/com/jetbrains/python/formatter/PyFromImportPostFormatProcessor.java index 7b0132861177..ab87b880cb09 100644 --- a/python/src/com/jetbrains/python/formatter/PyFromImportPostFormatProcessor.java +++ b/python/src/com/jetbrains/python/formatter/PyFromImportPostFormatProcessor.java @@ -25,7 +25,10 @@ import com.intellij.psi.codeStyle.CodeStyleSettings; import com.intellij.psi.codeStyle.CommonCodeStyleSettings; import com.intellij.psi.impl.source.codeStyle.PostFormatProcessor; import com.intellij.psi.impl.source.codeStyle.PostFormatProcessorHelper; +import com.intellij.util.containers.ContainerUtil; +import com.jetbrains.python.PyTokenTypes; import com.jetbrains.python.psi.*; +import com.jetbrains.python.psi.impl.PyPsiUtils; import org.jetbrains.annotations.NotNull; import java.util.ArrayList; @@ -57,14 +60,24 @@ public class PyFromImportPostFormatProcessor implements PostFormatProcessor { @Override public void visitPyFromImportStatement(PyFromImportStatement node) { if (myHelper.isElementFullyInRange(node)) { - final PyImportElement[] importedNames = node.getImportElements(); - final PyCodeStyleSettings pySettings = ((CodeStyleSettings)myHelper.getSettings()).getCustomSettings(PyCodeStyleSettings.class); - final PsiElement leftParen = node.getLeftParen(); - final boolean enabledInSettings = pySettings.FROM_IMPORT_PARENTHESES_FORCE == CommonCodeStyleSettings.FORCE_BRACES_ALWAYS || - pySettings.FROM_IMPORT_PARENTHESES_FORCE == CommonCodeStyleSettings.FORCE_BRACES_IF_MULTILINE && - PostFormatProcessorHelper.isMultiline(node); - if (enabledInSettings && importedNames.length > 1 && leftParen == null) { - myImportStatements.add(node); + // If non-parenthesized "from" import ends with one or more of trailing commas, the array returned by getImportElements() + // contains empty import elements at the end + final List importedNames = ContainerUtil.filter(node.getImportElements(), elem -> elem.getTextLength() != 0); + if (importedNames.size() > 1) { + + final PyCodeStyleSettings pySettings = ((CodeStyleSettings)myHelper.getSettings()).getCustomSettings(PyCodeStyleSettings.class); + final boolean forcedParentheses = pySettings.FROM_IMPORT_PARENTHESES_FORCE == CommonCodeStyleSettings.FORCE_BRACES_ALWAYS || + pySettings.FROM_IMPORT_PARENTHESES_FORCE == CommonCodeStyleSettings.FORCE_BRACES_IF_MULTILINE && + PostFormatProcessorHelper.isMultiline(node); + final boolean forcedComma = pySettings.FROM_IMPORT_TRAILING_COMMA_IF_MULTILINE && PostFormatProcessorHelper.isMultiline(node); + final PyImportElement lastImportedName = importedNames.get(importedNames.size() - 1); + final PsiElement afterLastName = PyPsiUtils.getNextNonCommentSibling(lastImportedName, true); + final PsiElement openingParen = node.getLeftParen(); + final boolean missingComma = afterLastName == null || afterLastName.getNode().getElementType() != PyTokenTypes.COMMA; + // Trailing comma is allowed only in "from" imports wrapped in parentheses + if (forcedParentheses && openingParen == null || forcedComma && missingComma && openingParen != null) { + myImportStatements.add(node); + } } } } @@ -94,34 +107,55 @@ public class PyFromImportPostFormatProcessor implements PostFormatProcessor { @NotNull private PyFromImportStatement replaceFromImport(@NotNull PyFromImportStatement fromImport) { - final PyImportElement firstName = fromImport.getImportElements()[0]; - final String beforeFirstName = fromImport.getText().substring(0, firstName.getStartOffsetInParent()); - final StringBuilder newStatementText = new StringBuilder(beforeFirstName); - newStatementText.append("("); - boolean lastElementWasComment = false; - for (PsiElement cur = firstName; cur != null; cur = cur.getNextSibling()) { - if (cur instanceof PsiWhiteSpace) { - newStatementText.append(cur.getText().replace("\\", "")); - } - else { - newStatementText.append(cur.getText()); - } - lastElementWasComment = cur instanceof PsiComment; - } - if (lastElementWasComment) { - newStatementText.append("\n"); - } - newStatementText.append(")"); - + final PyImportElement[] allNames = fromImport.getImportElements(); + final PyImportElement firstName = allNames[0]; final PyElementGenerator generator = PyElementGenerator.getInstance(fromImport.getProject()); final CodeStyleManager codeStyleManager = CodeStyleManager.getInstance(fromImport.getProject()); - - final LanguageLevel level = LanguageLevel.forElement(fromImport); - PyFromImportStatement newFromImport = generator.createFromText(level, PyFromImportStatement.class, newStatementText.toString()); - newFromImport = (PyFromImportStatement)fromImport.replace(newFromImport); - newFromImport = (PyFromImportStatement)codeStyleManager.reformat(newFromImport); - myHelper.updateResultRange(fromImport.getTextLength(), newFromImport.getTextLength()); - return newFromImport; + + if (fromImport.getLeftParen() == null) { + // Surround with parentheses stripping obsolete continuation backslashes and added trailing comma if necessary + final String beforeFirstName = fromImport.getText().substring(0, firstName.getStartOffsetInParent()); + final StringBuilder newStatementText = new StringBuilder(beforeFirstName); + newStatementText.append("("); + boolean lastElementWasComment = false; + int lastVisibleNameCommaOffset = -1; + for (PsiElement cur = firstName; cur != null; cur = cur.getNextSibling()) { + if (cur instanceof PsiWhiteSpace) { + newStatementText.append(cur.getText().replace("\\", "")); + } + else { + newStatementText.append(cur.getText()); + } + if (cur instanceof PyImportElement && cur.getTextLength() != 0) { + lastVisibleNameCommaOffset = newStatementText.length(); + } + else if (lastVisibleNameCommaOffset != -1 && cur.getNode().getElementType() == PyTokenTypes.COMMA) { + lastVisibleNameCommaOffset = -1; + } + lastElementWasComment = cur instanceof PsiComment; + } + final PyCodeStyleSettings pySettings = ((CodeStyleSettings)myHelper.getSettings()).getCustomSettings(PyCodeStyleSettings.class); + if (lastVisibleNameCommaOffset != -1 && pySettings.FROM_IMPORT_TRAILING_COMMA_IF_MULTILINE) { + newStatementText.insert(lastVisibleNameCommaOffset, ","); + } + if (lastElementWasComment) { + newStatementText.append("\n"); + } + newStatementText.append(")"); + + final LanguageLevel level = LanguageLevel.forElement(fromImport); + PyFromImportStatement newFromImport = generator.createFromText(level, PyFromImportStatement.class, newStatementText.toString()); + newFromImport = (PyFromImportStatement)fromImport.replace(newFromImport); + newFromImport = (PyFromImportStatement)codeStyleManager.reformat(newFromImport, true); + myHelper.updateResultRange(fromImport.getTextLength(), newFromImport.getTextLength()); + return newFromImport; + } + else { + // Add only trailing comma + final PsiElement comma = fromImport.addAfter(generator.createComma().getPsi(), allNames[allNames.length - 1]); + codeStyleManager.reformat(comma); + return fromImport; + } } } diff --git a/python/src/com/jetbrains/python/formatter/PyLanguageCodeStyleSettingsProvider.java b/python/src/com/jetbrains/python/formatter/PyLanguageCodeStyleSettingsProvider.java index e7d004a91991..bdd2ce2a88da 100644 --- a/python/src/com/jetbrains/python/formatter/PyLanguageCodeStyleSettingsProvider.java +++ b/python/src/com/jetbrains/python/formatter/PyLanguageCodeStyleSettingsProvider.java @@ -135,6 +135,9 @@ public class PyLanguageCodeStyleSettingsProvider extends LanguageCodeStyleSettin PyBundle.message("formatter.from.import.statements.force.parentheses"), PyBundle.message("formatter.from.import.statements"), BRACE_OPTIONS, BRACE_VALUES); + consumer.showCustomOption(PyCodeStyleSettings.class, "FROM_IMPORT_TRAILING_COMMA_IF_MULTILINE", + PyBundle.message("formatter.from.import.statements.force.comma.if.multline"), + PyBundle.message("formatter.from.import.statements")); consumer.showCustomOption(PyCodeStyleSettings.class, "DICT_WRAPPING", PyBundle.message("formatter.dictionary.literals"), null, WRAP_OPTIONS, WRAP_VALUES); diff --git a/python/testData/formatter/fromImportTrailingCommaWithParentheses.py b/python/testData/formatter/fromImportTrailingCommaWithParentheses.py new file mode 100644 index 000000000000..c45eaac8a40e --- /dev/null +++ b/python/testData/formatter/fromImportTrailingCommaWithParentheses.py @@ -0,0 +1,23 @@ +from module import foo +from module import foo, bar +from module import foo, bar, +# | margin +from module import foo, bar, baz +from module import foo, \ + bar +from module import foo, \ + bar, + +from module import foo, \ + bar # comment + +from module import (foo, + bar) + +from module import (foo, + bar,) + +from module import ( + foo, + bar # comment +) \ No newline at end of file diff --git a/python/testData/formatter/fromImportTrailingCommaWithParentheses_after.py b/python/testData/formatter/fromImportTrailingCommaWithParentheses_after.py new file mode 100644 index 000000000000..1857e3d30298 --- /dev/null +++ b/python/testData/formatter/fromImportTrailingCommaWithParentheses_after.py @@ -0,0 +1,27 @@ +from module import foo +from module import (foo, + bar, ) +from module import (foo, + bar, ) +# | margin +from module import (foo, bar, + baz, ) +from module import (foo, + bar, ) +from module import (foo, + bar, ) + +from module import (foo, + bar, # comment + ) + +from module import (foo, + bar, ) + +from module import (foo, + bar, ) + +from module import ( + foo, + bar, # comment +) diff --git a/python/testData/formatter/fromImportTrailingCommaWithoutParentheses.py b/python/testData/formatter/fromImportTrailingCommaWithoutParentheses.py new file mode 100644 index 000000000000..c45eaac8a40e --- /dev/null +++ b/python/testData/formatter/fromImportTrailingCommaWithoutParentheses.py @@ -0,0 +1,23 @@ +from module import foo +from module import foo, bar +from module import foo, bar, +# | margin +from module import foo, bar, baz +from module import foo, \ + bar +from module import foo, \ + bar, + +from module import foo, \ + bar # comment + +from module import (foo, + bar) + +from module import (foo, + bar,) + +from module import ( + foo, + bar # comment +) \ No newline at end of file diff --git a/python/testData/formatter/fromImportTrailingCommaWithoutParentheses_after.py b/python/testData/formatter/fromImportTrailingCommaWithoutParentheses_after.py new file mode 100644 index 000000000000..bd6caf7ff688 --- /dev/null +++ b/python/testData/formatter/fromImportTrailingCommaWithoutParentheses_after.py @@ -0,0 +1,24 @@ +from module import foo +from module import foo, bar +from module import foo, bar, +# | margin +from module import foo, bar, \ + baz +from module import foo, \ + bar +from module import foo, \ + bar, + +from module import foo, \ + bar # comment + +from module import (foo, + bar, ) + +from module import (foo, + bar, ) + +from module import ( + foo, + bar, # comment +) diff --git a/python/testData/formatter/pyramidFromImportFormatting_after.py b/python/testData/formatter/pyramidFromImportFormatting_after.py index da4948c9daca..efa762b318ae 100644 --- a/python/testData/formatter/pyramidFromImportFormatting_after.py +++ b/python/testData/formatter/pyramidFromImportFormatting_after.py @@ -1,15 +1,15 @@ from module import foo from module import ( foo, - bar + bar, ) from module import ( foo, - bar + bar, ) from module import ( foo, - bar # comment + bar, # comment ) from module import ( foo, diff --git a/python/testSrc/com/jetbrains/python/PyFormatterTest.java b/python/testSrc/com/jetbrains/python/PyFormatterTest.java index 264d7e4b9dfb..dd6048fa5525 100644 --- a/python/testSrc/com/jetbrains/python/PyFormatterTest.java +++ b/python/testSrc/com/jetbrains/python/PyFormatterTest.java @@ -786,10 +786,27 @@ public class PyFormatterTest extends PyTestCase { getPythonCodeStyleSettings().FROM_IMPORT_NEW_LINE_AFTER_LEFT_PARENTHESIS = true; getPythonCodeStyleSettings().FROM_IMPORT_NEW_LINE_BEFORE_RIGHT_PARENTHESIS = true; getPythonCodeStyleSettings().FROM_IMPORT_WRAPPING = WrapType.ALWAYS.getLegacyRepresentation(); + getPythonCodeStyleSettings().FROM_IMPORT_TRAILING_COMMA_IF_MULTILINE = true; getPythonCodeStyleSettings().HANG_CLOSING_BRACKETS = true; doTest(); } + // PY-9764 + public void testFromImportTrailingCommaWithParentheses() { + getCodeStyleSettings().setRightMargin(PythonLanguage.INSTANCE, 30); + getPythonCodeStyleSettings().FROM_IMPORT_PARENTHESES_FORCE = CommonCodeStyleSettings.FORCE_BRACES_ALWAYS; + getPythonCodeStyleSettings().FROM_IMPORT_TRAILING_COMMA_IF_MULTILINE = true; + doTest(); + } + + // PY-9764 + public void testFromImportTrailingCommaWithoutParentheses() { + getCodeStyleSettings().setRightMargin(PythonLanguage.INSTANCE, 30); + getPythonCodeStyleSettings().FROM_IMPORT_PARENTHESES_FORCE = CommonCodeStyleSettings.DO_NOT_FORCE; + getPythonCodeStyleSettings().FROM_IMPORT_TRAILING_COMMA_IF_MULTILINE = true; + doTest(); + } + public void testVariableAnnotations() { runWithLanguageLevel(LanguageLevel.PYTHON36, this::doTest); }