diff --git a/python/psi-api/src/com/jetbrains/python/psi/PyLiteralStringNode.java b/python/psi-api/src/com/jetbrains/python/psi/PyLiteralStringNode.java
new file mode 100644
index 000000000000..3e983d44047a
--- /dev/null
+++ b/python/psi-api/src/com/jetbrains/python/psi/PyLiteralStringNode.java
@@ -0,0 +1,7 @@
+package com.jetbrains.python.psi;
+
+/**
+ * @author Mikhail Golubev
+ */
+public interface PyLiteralStringNode extends PyRichStringNode {
+}
diff --git a/python/psi-api/src/com/jetbrains/python/psi/PyRichStringNode.java b/python/psi-api/src/com/jetbrains/python/psi/PyRichStringNode.java
new file mode 100644
index 000000000000..aca8a87cf20e
--- /dev/null
+++ b/python/psi-api/src/com/jetbrains/python/psi/PyRichStringNode.java
@@ -0,0 +1,88 @@
+package com.jetbrains.python.psi;
+
+import com.intellij.openapi.util.TextRange;
+import com.intellij.psi.PsiElement;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Set;
+
+/**
+ * @author Mikhail Golubev
+ */
+public interface PyRichStringNode extends PsiElement {
+
+ enum Modifier {
+ UNICODE,
+ BYTES,
+ RAW,
+ FORMATTED
+ }
+
+ /**
+ * @return string prefix, e.g. "UR", "b" etc.
+ */
+ @NotNull
+ String getPrefix();
+
+ int getPrefixLength();
+
+ /**
+ * @return the same text as {@code getNode().getText().substring(getPrefixLength())}
+ */
+ @NotNull
+ String getTextWithoutPrefix();
+
+ /**
+ * @return relative range of the content (excluding prefix and quotes)
+ * @see #getAbsoluteContentRange()
+ */
+ @NotNull
+ TextRange getContentRange();
+
+ /**
+ * @return absolute content range that accounts offset of the {@link #getNode() node} in the document
+ */
+ @NotNull
+ TextRange getAbsoluteContentRange();
+
+ /**
+ * @return content of the string node between quotes
+ */
+ @NotNull
+ String getContent();
+
+ @NotNull
+ String getQuote();
+
+ /**
+ * @return the first character of {@link #getQuote()}
+ */
+ char getQuoteChar();
+
+ boolean isTripleQuoted();
+
+ /**
+ * @return true if string literal ends with starting quote
+ */
+ boolean isTerminated();
+
+ @NotNull
+ Set getModifiers();
+
+ /**
+ * @return true if given string node contains "u" or "U" prefix
+ */
+ boolean isUnicode();
+
+ /**
+ * @return true if given string node contains "r" or "R" prefix
+ */
+ boolean isRaw();
+
+ /**
+ * @return true if given string node contains "b" or "B" prefix
+ */
+ boolean isBytes();
+
+ boolean isFormatted();
+}
diff --git a/python/psi-api/src/com/jetbrains/python/psi/PyStringLiteralExpression.java b/python/psi-api/src/com/jetbrains/python/psi/PyStringLiteralExpression.java
index 163090a9df81..17aa00f2400a 100644
--- a/python/psi-api/src/com/jetbrains/python/psi/PyStringLiteralExpression.java
+++ b/python/psi-api/src/com/jetbrains/python/psi/PyStringLiteralExpression.java
@@ -27,6 +27,9 @@ public interface PyStringLiteralExpression extends PyLiteralExpression, StringLi
@NotNull
List getStringNodes();
+ @NotNull
+ List getGluedStringNodes();
+
int valueOffsetToTextOffset(int valueOffset);
/**
diff --git a/python/src/META-INF/python-core-common.xml b/python/src/META-INF/python-core-common.xml
index 2c2611e5ebe3..0ee3a9ff3a98 100644
--- a/python/src/META-INF/python-core-common.xml
+++ b/python/src/META-INF/python-core-common.xml
@@ -27,6 +27,7 @@
+
diff --git a/python/src/com/jetbrains/python/psi/impl/PyLiteralStringNodeImpl.java b/python/src/com/jetbrains/python/psi/impl/PyLiteralStringNodeImpl.java
new file mode 100644
index 000000000000..f70b0c68460d
--- /dev/null
+++ b/python/src/com/jetbrains/python/psi/impl/PyLiteralStringNodeImpl.java
@@ -0,0 +1,134 @@
+package com.jetbrains.python.psi.impl;
+
+import com.intellij.openapi.util.Pair;
+import com.intellij.openapi.util.TextRange;
+import com.intellij.openapi.util.text.StringUtil;
+import com.intellij.psi.impl.source.tree.LeafPsiElement;
+import com.intellij.psi.tree.IElementType;
+import com.jetbrains.python.psi.PyLiteralStringNode;
+import com.jetbrains.python.psi.PyStringLiteralUtil;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.EnumSet;
+import java.util.Set;
+
+/**
+ * @author Mikhail Golubev
+ */
+public class PyLiteralStringNodeImpl extends LeafPsiElement implements PyLiteralStringNode {
+ public PyLiteralStringNodeImpl(@NotNull IElementType type, CharSequence text) {
+ super(type, text);
+ }
+
+ @NotNull
+ @Override
+ public String getPrefix() {
+ return PyStringLiteralUtil.getPrefix(getText());
+ }
+
+ @Override
+ public int getPrefixLength() {
+ return PyStringLiteralUtil.getPrefixEndOffset(getText(), 0);
+ }
+
+ @NotNull
+ @Override
+ public String getContent() {
+ return getContentRange().substring(getText());
+ }
+
+ @NotNull
+ @Override
+ public String getTextWithoutPrefix() {
+ return getText().substring(getPrefixLength());
+ }
+
+ @NotNull
+ @Override
+ public TextRange getContentRange() {
+ final Pair quotes = PyStringLiteralUtil.getQuotes(getText());
+ assert quotes != null;
+ final String prefixWithOpeningQuote = quotes.getFirst();
+ final String closingQuote = quotes.getSecond();
+ return TextRange.create(prefixWithOpeningQuote.length(), getTextLength() - closingQuote.length());
+ }
+
+ @NotNull
+ @Override
+ public TextRange getAbsoluteContentRange() {
+ return getContentRange().shiftRight(getStartOffset());
+ }
+
+ @Override
+ public char getQuoteChar() {
+ return getQuote().charAt(0);
+ }
+
+ @NotNull
+ @Override
+ public String getQuote() {
+ final Pair quotes = PyStringLiteralUtil.getQuotes(getText());
+ assert quotes != null;
+ return quotes.getFirst();
+ }
+
+ @Override
+ public boolean isTripleQuoted() {
+ return getQuote().length() == 3;
+ }
+
+ @Override
+ public boolean isTerminated() {
+ final String unprefixed = getTextWithoutPrefix();
+ final String quote = getQuote();
+ return unprefixed.length() >= quote.length() * 2 && unprefixed.endsWith(quote);
+ }
+
+ @NotNull
+ @Override
+ public Set getModifiers() {
+ final EnumSet result = EnumSet.noneOf(Modifier.class);
+ if (isUnicode()) {
+ result.add(Modifier.UNICODE);
+ }
+ if (isBytes()) {
+ result.add(Modifier.BYTES);
+ }
+ if (isRaw()) {
+ result.add(Modifier.RAW);
+ }
+ if (isFormatted()) {
+ result.add(Modifier.FORMATTED);
+ }
+ return result;
+ }
+
+ /**
+ * @return true if given string node contains "u" or "U" prefix
+ */
+ @Override
+ public boolean isUnicode() {
+ return StringUtil.containsIgnoreCase(getPrefix(), "u");
+ }
+
+ /**
+ * @return true if given string node contains "r" or "R" prefix
+ */
+ @Override
+ public boolean isRaw() {
+ return StringUtil.containsIgnoreCase(getPrefix(), "r");
+ }
+
+ /**
+ * @return true if given string node contains "b" or "B" prefix
+ */
+ @Override
+ public boolean isBytes() {
+ return StringUtil.containsIgnoreCase(getPrefix(), "b");
+ }
+
+ @Override
+ public boolean isFormatted() {
+ return StringUtil.containsIgnoreCase(getPrefix(), "f");
+ }
+}
diff --git a/python/src/com/jetbrains/python/psi/impl/PyStringLiteralExpressionImpl.java b/python/src/com/jetbrains/python/psi/impl/PyStringLiteralExpressionImpl.java
index 56b35bbae9ba..959e1433f27c 100644
--- a/python/src/com/jetbrains/python/psi/impl/PyStringLiteralExpressionImpl.java
+++ b/python/src/com/jetbrains/python/psi/impl/PyStringLiteralExpressionImpl.java
@@ -37,6 +37,7 @@ import com.jetbrains.python.lexer.PythonHighlightingLexer;
import com.jetbrains.python.psi.*;
import com.jetbrains.python.psi.types.PyType;
import com.jetbrains.python.psi.types.TypeEvalContext;
+import one.util.streamex.StreamEx;
import org.intellij.lang.regexp.DefaultRegExpPropertiesProvider;
import org.intellij.lang.regexp.RegExpLanguageHost;
import org.intellij.lang.regexp.psi.*;
@@ -259,6 +260,14 @@ public class PyStringLiteralExpressionImpl extends PyElementImpl implements PySt
return Arrays.asList(getNode().getChildren(PyTokenTypes.STRING_NODES));
}
+ @NotNull
+ @Override
+ public List getGluedStringNodes() {
+ return StreamEx.of(getStringNodes())
+ .select(PyRichStringNode.class)
+ .toList();
+ }
+
@NotNull
@Override
public String getStringValue() {
diff --git a/python/src/com/jetbrains/python/psi/impl/PythonASTFactory.java b/python/src/com/jetbrains/python/psi/impl/PythonASTFactory.java
new file mode 100644
index 000000000000..e88d52d14668
--- /dev/null
+++ b/python/src/com/jetbrains/python/psi/impl/PythonASTFactory.java
@@ -0,0 +1,23 @@
+package com.jetbrains.python.psi.impl;
+
+import com.intellij.lang.ASTFactory;
+import com.intellij.psi.impl.source.tree.LeafElement;
+import com.intellij.psi.tree.IElementType;
+import com.jetbrains.python.PyTokenTypes;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * @author Mikhail Golubev
+ */
+public class PythonASTFactory extends ASTFactory {
+
+ @Nullable
+ @Override
+ public LeafElement createLeaf(@NotNull IElementType type, CharSequence text) {
+ if (PyTokenTypes.STRING_NODES.contains(type)) {
+ return new PyLiteralStringNodeImpl(type, text);
+ }
+ return super.createLeaf(type, text);
+ }
+}
diff --git a/python/testSrc/com/jetbrains/python/PyStringLiteralTest.java b/python/testSrc/com/jetbrains/python/PyStringLiteralTest.java
index 90b489535d43..d1f8690118b7 100644
--- a/python/testSrc/com/jetbrains/python/PyStringLiteralTest.java
+++ b/python/testSrc/com/jetbrains/python/PyStringLiteralTest.java
@@ -154,8 +154,8 @@ public class PyStringLiteralTest extends PyTestCase {
}
private PyStringLiteralExpression createLiteralFromText(final String text) {
- final PsiFile file = PsiFileFactory.getInstance(myFixture.getProject()).createFileFromText("test.py", "a = " + text);
- final PyStringLiteralExpression expr = PsiTreeUtil.getParentOfType(file.findElementAt(5), PyStringLiteralExpression.class);
+ final PsiFile file = PsiFileFactory.getInstance(myFixture.getProject()).createFileFromText("test.py", PythonFileType.INSTANCE, "a = (" + text + ")");
+ final PyStringLiteralExpression expr = PsiTreeUtil.getParentOfType(file.findElementAt(6), PyStringLiteralExpression.class);
assert expr != null;
return expr;
}
@@ -169,4 +169,9 @@ public class PyStringLiteralTest extends PyTestCase {
}
return characters;
}
+
+ public void testRichStringNodes() {
+ final PyStringLiteralExpression string = createLiteralFromText("'foo' 'bar' 'baz'");
+ assertSize(3, string.getGluedStringNodes());
+ }
}