From f362f0e6a229e53b45f36a386cc384043d9778d7 Mon Sep 17 00:00:00 2001 From: Mikhail Golubev Date: Tue, 4 Aug 2015 19:06:23 +0300 Subject: [PATCH] PY-9795 First version of parsing of section-based docstrings --- .../jetbrains/python/toolbox/Substring.java | 57 ++- .../python/documentation/DocStringFormat.java | 3 +- .../GoogleCodeStyleDocString.java | 106 +++++ .../documentation/SectionBasedDocString.java | 440 ++++++++++++++++++ .../docstrings/simpleFunctionDocString.py | 14 + .../PyGoogleCodeStyleDocStringTest.java | 110 +++++ 6 files changed, 726 insertions(+), 4 deletions(-) create mode 100644 python/src/com/jetbrains/python/documentation/GoogleCodeStyleDocString.java create mode 100644 python/src/com/jetbrains/python/documentation/SectionBasedDocString.java create mode 100644 python/testData/docstrings/simpleFunctionDocString.py create mode 100644 python/testSrc/com/jetbrains/python/PyGoogleCodeStyleDocStringTest.java diff --git a/python/psi-api/src/com/jetbrains/python/toolbox/Substring.java b/python/psi-api/src/com/jetbrains/python/toolbox/Substring.java index 38caf06aae71..b21d954adb0d 100644 --- a/python/psi-api/src/com/jetbrains/python/toolbox/Substring.java +++ b/python/psi-api/src/com/jetbrains/python/toolbox/Substring.java @@ -17,6 +17,7 @@ package com.jetbrains.python.toolbox; import com.intellij.openapi.util.TextRange; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.List; @@ -34,10 +35,26 @@ import java.util.regex.Pattern; public class Substring implements CharSequence { private static final Pattern RE_NL = Pattern.compile("(\\r?\\n)"); + @NotNull + public static Substring fromMatcherGroup(@NotNull String s, @NotNull Matcher matcher, int groupNumber) { + if (matcher.groupCount() < groupNumber || matcher.end(groupNumber) > s.length()) { + throw new IllegalArgumentException("Inconsistent matcher, group number and underlying string"); + } + return new Substring(s, matcher.start(groupNumber), matcher.end(groupNumber)); + } + + @NotNull + public static Substring fromMatcherGroup(@NotNull Substring s, @NotNull Matcher matcher, int groupNumber) { + if (matcher.groupCount() < groupNumber || matcher.end(groupNumber) > s.length()) { + throw new IllegalArgumentException("Inconsistent matcher, group number and underlying string"); + } + return new Substring(s.getSuperString(), s.myStartOffset + matcher.start(groupNumber), s.myStartOffset + matcher.end(groupNumber)); + } + @NotNull private final String myString; private final int myStartOffset; private final int myEndOffset; - + public Substring(@NotNull String s) { this(s, 0, s.length()); } @@ -87,21 +104,33 @@ public class Substring implements CharSequence { @NotNull public List split(@NotNull String regex) { - return split(Pattern.compile(regex)); + return split(regex, Integer.MAX_VALUE); + } + + @NotNull + public List split(@NotNull String regex, int maxSplits) { + return split(Pattern.compile(regex), maxSplits); } @NotNull public List split(@NotNull Pattern pattern) { + return split(pattern, Integer.MAX_VALUE); + } + + @NotNull + public List split(@NotNull Pattern pattern, int maxSplits) { final List result = new ArrayList(); final Matcher m = pattern.matcher(myString); int start = myStartOffset; int end = myEndOffset; + int splitCount = 0; if (m.find(start)) { do { + splitCount++; end = m.start(); result.add(createAnotherSubstring(start, Math.min(end, myEndOffset))); start = m.end(); - } while (end < myEndOffset && m.find()); + } while (end < myEndOffset && m.find() && splitCount < maxSplits); if (start < myEndOffset) { result.add(createAnotherSubstring(start, myEndOffset)); } @@ -190,4 +219,26 @@ public class Substring implements CharSequence { private Substring createAnotherSubstring(int start, int end) { return new Substring(myString, start, end); } + + public int getStartOffset() { + return myStartOffset; + } + + public int getEndOffset() { + return myEndOffset; + } + + /** + * If both substrings share the same origin, returns new substring that includes both of them. Otherwise return {@code null}. + * + * @param other substring to concat with + * @return new substring as described + */ + @Nullable + public Substring getSmallestInclusiveSubstring(@NotNull Substring other) { + if (myString.equals(other.myString)) { + return new Substring(myString, Math.min(myStartOffset, other.myStartOffset), Math.max(myEndOffset, other.myEndOffset)); + } + return null; + } } diff --git a/python/src/com/jetbrains/python/documentation/DocStringFormat.java b/python/src/com/jetbrains/python/documentation/DocStringFormat.java index 164917820b06..e81309a60e38 100644 --- a/python/src/com/jetbrains/python/documentation/DocStringFormat.java +++ b/python/src/com/jetbrains/python/documentation/DocStringFormat.java @@ -27,8 +27,9 @@ public class DocStringFormat { public static final String EPYTEXT = "Epytext"; public static final String REST = "reStructuredText"; public static final String NUMPY = "NumPy"; + public static final String GOOGLE = "Google"; - public static final List ALL = ImmutableList.of(PLAIN, EPYTEXT, REST, NUMPY); + public static final List ALL = ImmutableList.of(PLAIN, EPYTEXT, REST, NUMPY, GOOGLE); private DocStringFormat() { } diff --git a/python/src/com/jetbrains/python/documentation/GoogleCodeStyleDocString.java b/python/src/com/jetbrains/python/documentation/GoogleCodeStyleDocString.java new file mode 100644 index 000000000000..0647ffd0d165 --- /dev/null +++ b/python/src/com/jetbrains/python/documentation/GoogleCodeStyleDocString.java @@ -0,0 +1,106 @@ +/* + * Copyright 2000-2015 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.documentation; + +import com.intellij.openapi.util.Pair; +import com.intellij.util.containers.ContainerUtil; +import com.jetbrains.python.toolbox.Substring; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * @author Mikhail Golubev + */ +public class GoogleCodeStyleDocString extends SectionBasedDocString { + private static final Pattern SECTION_HEADER_RE = Pattern.compile("^\\s*(.+?):\\s*$"); + private static final Pattern FIELD_NAME_AND_TYPE_RE = Pattern.compile("\\s*(.+?)\\s*\\(\\s*(.+?)\\s*\\)\\s*"); + + public GoogleCodeStyleDocString(@NotNull String text) { + super(text); + } + + @NotNull + @Override + protected Pair parseFieldWithType(int lineNum, int sectionIndent) { + return parseField(lineNum, sectionIndent, true); + } + + @NotNull + @Override + protected Pair parseFieldWithNameAndOptionalType(int lineNum, int sectionIndent) { + return parseField(lineNum, sectionIndent, false); + } + + /** + *

Example

+ *

+   * Attributes:
+   *  arg1 (int): field with name and optional type before description
+   *  
+   * Raises:
+   *  RuntimeException: field with only type before description
+   * 
+ * + * @param typeBeforeColon according to Google Code Style there can be either type or name and type in parenthesis before colon + */ + @NotNull + private Pair parseField(int lineNum, int sectionIndent, boolean typeBeforeColon) { + Substring name = null, type = null, description; + final List parts = getLine(lineNum).split(":", 1); + assert parts.size() <= 2; + if (parts.size() < 2) { + return Pair.create(null, lineNum); + } + final Substring textBeforeColon = parts.get(0); + if (typeBeforeColon) { + type = textBeforeColon.trim(); + } + else { + // TODO skip references in types like Napoleon does + final Matcher matcher = FIELD_NAME_AND_TYPE_RE.matcher(textBeforeColon); + if (matcher.matches()) { + name = Substring.fromMatcherGroup(textBeforeColon, matcher, 1).trim(); + type = Substring.fromMatcherGroup(textBeforeColon, matcher, 2).trim(); + } + else { + name = textBeforeColon.trim(); + } + } + description = parts.get(1); + final Pair, Integer> pair = parseIndentedBlock(lineNum + 1, getLineIndent(lineNum), sectionIndent); + final List nestedBlock = pair.getFirst(); + if (!nestedBlock.isEmpty()) { + //noinspection ConstantConditions + description = description.getSmallestInclusiveSubstring(ContainerUtil.getLastItem(nestedBlock)); + } + assert description != null; + description = description.trim(); + return Pair.create(new SectionField(name, type, description), pair.getSecond()); + } + + @NotNull + @Override + protected Pair parseSectionHeader(int lineNum) { + final Matcher matcher = SECTION_HEADER_RE.matcher(getLine(lineNum)); + if (matcher.matches()) { + return Pair.create(matcher.group(1).trim(), lineNum + 1); + } + return Pair.create(null, lineNum); + } +} diff --git a/python/src/com/jetbrains/python/documentation/SectionBasedDocString.java b/python/src/com/jetbrains/python/documentation/SectionBasedDocString.java new file mode 100644 index 000000000000..33c7648346e3 --- /dev/null +++ b/python/src/com/jetbrains/python/documentation/SectionBasedDocString.java @@ -0,0 +1,440 @@ +/* + * Copyright 2000-2015 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.documentation; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.intellij.openapi.util.Pair; +import com.intellij.openapi.util.text.StringUtil; +import com.intellij.util.containers.ContainerUtil; +import com.jetbrains.python.psi.StructuredDocString; +import com.jetbrains.python.toolbox.Substring; +import org.jetbrains.annotations.NonNls; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Common base class for docstring styles supported by Napoleon Sphinx extension. + * + * @author Mikhail Golubev + * @see Napoleon + */ +public abstract class SectionBasedDocString implements StructuredDocString { + private static final Map SECTION_ALIASES = + ImmutableMap.builder() + .put("arguments", "parameters") + .put("args", "parameters") + .put("parameters", "parameters") + .put("keyword args", "keyword arguments") + .put("keyword arguments", "keyword arguments") + .put("other parameters", "other parameters") + .put("attributes", "attributes") + .put("methods", "methods") + .put("note", "notes") + .put("notes", "notes") + .put("example", "examples") + .put("examples", "examples") + .put("return", "returns") + .put("returns", "returns") + .put("yield", "yields") + .put("yields", "yields") + .put("raises", "raises") + .put("references", "references") + .put("see also", "see also") + .put("warning", "warnings") + .put("warns", "warnings") + .put("warnings", "warnings") + .build(); + + private static ImmutableSet SECTIONS_WITH_NAME_AND_TYPE = ImmutableSet.of("attributes", "methods", + "parameters", "keyword arguments", "other parameters"); + private static ImmutableSet SECTIONS_WITH_TYPE = ImmutableSet.of("returns", "raises", "yields"); + + protected final List myLines; + + private final List myOtherContent = new ArrayList(); + private final List
mySections = new ArrayList
(); + private final String mySummary; + + protected SectionBasedDocString(@NotNull String text) { + myLines = new Substring(text).splitLines(); + String summary = ""; + int lineNum = 0; + while (lineNum < myLines.size()) { + Pair result = parseSection(lineNum); + if (result.getFirst() != null) { + mySections.add(result.getFirst()); + lineNum = result.getSecond(); + } + else { + if (lineNum == 0 && isEmptyOrDoesNotExist(lineNum + 1)) { + summary = getLine(0).trim().toString(); + } + else { + myOtherContent.add(getLine(lineNum)); + } + lineNum++; + } + } + mySummary = summary; + + } + + @NotNull + protected Pair parseSection(int sectionStartLine) { + final Pair pair = parseSectionHeader(sectionStartLine); + final String title = normalizeSectionTitle(pair.getFirst()); + if (title == null) { + return Pair.create(null, sectionStartLine); + } + int lineNum = skipEmptyLines(pair.getSecond()); + final List fields = new ArrayList(); + final int sectionIndent = getLineIndent(sectionStartLine); + while (!isSectionBreak(lineNum, sectionIndent)) { + final Pair result = parseField(lineNum, title, sectionIndent); + if (result.getFirst() == null) { + break; + } + fields.add(result.getFirst()); + lineNum = skipEmptyLines(result.getSecond()); + } + return Pair.create(new Section(title, fields), lineNum); + } + + @NotNull + protected Pair parseField(int lineNum, @NotNull String sectionTitle, int sectionIndent) { + if (SECTIONS_WITH_NAME_AND_TYPE.contains(sectionTitle)) { + return parseFieldWithNameAndOptionalType(lineNum, sectionIndent); + } + if (SECTIONS_WITH_TYPE.contains(sectionTitle)) { + return parseFieldWithType(lineNum, sectionIndent); + } + return parseGeneralField(lineNum, sectionIndent); + } + + @NotNull + protected Pair parseGeneralField(int lineNum, int sectionIndent) { + final Pair, Integer> pair = parseIndentedBlock(lineNum, sectionIndent, sectionIndent); + final Substring firstLine = ContainerUtil.getFirstItem(pair.getFirst()); + final Substring lastLine = ContainerUtil.getLastItem(pair.getFirst()); + if (firstLine != null && lastLine != null) { + final Substring mergedSubstring = new Substring(firstLine.getSuperString(), firstLine.getStartOffset(), lastLine.getEndOffset()); + return Pair.create(new SectionField(null, null, mergedSubstring), pair.getSecond()); + } + return Pair.create(null, pair.getSecond()); + } + + @NotNull + protected abstract Pair parseFieldWithType(int lineNum, int sectionIndent); + + @NotNull + protected abstract Pair parseFieldWithNameAndOptionalType(int lineNum, int sectionIndent); + + @NotNull + protected abstract Pair parseSectionHeader(int lineNum); + + protected int getLineIndent(int lineNum) { + final Substring line = getLine(lineNum); + for (int i = 0; i < line.length(); i++) { + if (!Character.isSpaceChar(line.charAt(i))) { + return i; + } + } + return 0; + } + + private int skipEmptyLines(int lineNum) { + while (lineNum < myLines.size() && isEmpty(lineNum)) { + lineNum++; + } + return lineNum; + } + + @Nullable + protected String normalizeSectionTitle(@Nullable @NonNls String title) { + return title == null ? null : SECTION_ALIASES.get(title.toLowerCase()); + } + + private boolean isEmptyOrDoesNotExist(int lineNum) { + return lineNum >= myLines.size() - 1 || isEmpty(lineNum); + } + + private boolean isEmpty(int lineNum) { + return StringUtil.isEmptyOrSpaces(getLine(lineNum)); + } + + private boolean isSectionStart(int lineNum) { + final Pair pair = parseSectionHeader(lineNum); + return pair.getFirst() != null; + } + + private boolean isSectionBreak(int lineNum, int curSectionIndent) { + return lineNum >= myLines.size() || + isSectionStart(lineNum) || + (!isEmpty(lineNum) && getLineIndent(lineNum) <= curSectionIndent); + } + + @NotNull + protected Pair, Integer> parseIndentedBlock(int lineNum, int blockIndent, int sectionIndent) { + final List result = new ArrayList(); + while (!isSectionBreak(lineNum, sectionIndent) && (isEmpty(lineNum) || getLineIndent(lineNum) > blockIndent)) { + result.add(getLine(lineNum)); + lineNum++; + } + + return Pair.create(result, lineNum); + } + + @NotNull + protected Substring getLine(int indent) { + return myLines.get(indent); + } + + @VisibleForTesting + public List
getSections() { + return Collections.unmodifiableList(mySections); + } + + @NotNull + @Override + public String createParameterType(@NotNull String name, @NotNull String type) { + return null; + } + + @Override + public String getSummary() { + return mySummary.toString(); + } + + @Override + public String getDescription() { + return null; + } + + @Override + public List getParameters() { + return null; + } + + @Override + public List getParameterSubstrings() { + return null; + } + + @Nullable + @Override + public String getParamType(@Nullable String paramName) { + return null; + } + + @Nullable + @Override + public Substring getParamTypeSubstring(@Nullable String paramName) { + return null; + } + + @Nullable + @Override + public String getParamDescription(@Nullable String paramName) { + return null; + } + + @Override + public List getKeywordArguments() { + return null; + } + + @Override + public List getKeywordArgumentSubstrings() { + return null; + } + + @Nullable + @Override + public String getKeywordArgumentDescription(@Nullable String paramName) { + return null; + } + + @Nullable + @Override + public String getReturnType() { + return null; + } + + @Nullable + @Override + public Substring getReturnTypeSubstring() { + return null; + } + + @Nullable + @Override + public String getReturnDescription() { + return null; + } + + @Override + public List getRaisedExceptions() { + return null; + } + + @Nullable + @Override + public String getRaisedExceptionDescription(@Nullable String exceptionName) { + return null; + } + + @Nullable + @Override + public String getAttributeDescription() { + return null; + } + + @Nullable + @Override + public Substring getTagValue(String... tagNames) { + return null; + } + + @Nullable + @Override + public Substring getTagValue(String tagName, @NotNull String argName) { + return null; + } + + @Nullable + @Override + public Substring getTagValue(String[] tagNames, @NotNull String argName) { + return null; + } + + @Override + public List getTagArguments(String... tagNames) { + return null; + } + + @Nullable + @Override + public Substring getParamByNameAndKind(@NotNull String name, String kind) { + return null; + } + + @Override + public List getAdditionalTags() { + return null; + } + + public static class Section { + private final String myTitle; + private final List myFields; + + public Section(@NotNull String title, @NotNull List fields) { + myTitle = title; + myFields = new ArrayList(fields); + } + + @NotNull + public String getTitle() { + return myTitle; + } + + @NotNull + public List getFields() { + return Collections.unmodifiableList(myFields); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Section section = (Section)o; + + if (!myTitle.equals(section.myTitle)) return false; + if (!myFields.equals(section.myFields)) return false; + + return true; + } + + @Override + public int hashCode() { + int result = myTitle.hashCode(); + result = 31 * result + myFields.hashCode(); + return result; + } + } + + public static class SectionField { + private final Substring myName; + private final Substring myType; + private final Substring myDescription; + + public SectionField(@Nullable Substring name, @Nullable Substring type, @Nullable Substring description) { + myName = name; + myType = type; + myDescription = description; + } + + @Nullable + public Substring getName() { + return myName; + } + + @Nullable + public Substring getType() { + return myType; + } + + @Nullable + public Substring getDescription() { + return myDescription; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + SectionField field = (SectionField)o; + + if (myName != null ? !myName.equals(field.myName) : field.myName != null) return false; + if (myType != null ? !myType.equals(field.myType) : field.myType != null) return false; + if (myDescription != null ? !myDescription.equals(field.myDescription) : field.myDescription != null) return false; + + return true; + } + + @Override + public int hashCode() { + int result = myName != null ? myName.hashCode() : 0; + result = 31 * result + (myType != null ? myType.hashCode() : 0); + result = 31 * result + (myDescription != null ? myDescription.hashCode() : 0); + return result; + } + } + + // MethodsSection + // AttributesSection + // YieldsSection + // RaisesSection + // ReturnsSection +} diff --git a/python/testData/docstrings/simpleFunctionDocString.py b/python/testData/docstrings/simpleFunctionDocString.py new file mode 100644 index 000000000000..aaa7540748fa --- /dev/null +++ b/python/testData/docstrings/simpleFunctionDocString.py @@ -0,0 +1,14 @@ +def func(x, y, *args, **kwargs): + """Summary + + Parameters: + x (int) : first parameter + y: second parameter + with longer description + + Raises: + Exception: if anything bad happens + Returns: + None: always + """ + pass \ No newline at end of file diff --git a/python/testSrc/com/jetbrains/python/PyGoogleCodeStyleDocStringTest.java b/python/testSrc/com/jetbrains/python/PyGoogleCodeStyleDocStringTest.java new file mode 100644 index 000000000000..7c0bf2385660 --- /dev/null +++ b/python/testSrc/com/jetbrains/python/PyGoogleCodeStyleDocStringTest.java @@ -0,0 +1,110 @@ +/* + * Copyright 2000-2015 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; + +import com.intellij.psi.PsiElement; +import com.intellij.psi.search.PsiElementProcessor; +import com.intellij.psi.util.PsiTreeUtil; +import com.jetbrains.python.documentation.GoogleCodeStyleDocString; +import com.jetbrains.python.documentation.SectionBasedDocString.Section; +import com.jetbrains.python.documentation.SectionBasedDocString.SectionField; +import com.jetbrains.python.fixtures.PyTestCase; +import com.jetbrains.python.psi.PyStringLiteralExpression; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +/** + * @author Mikhail Golubev + */ +public class PyGoogleCodeStyleDocStringTest extends PyTestCase { + + public void testSimpleFunctionDocString() { + myFixture.configureByFile(getTestName(true) + ".py"); + final String docStringText = findFirstDocString(); + + assertNotNull(docStringText); + final GoogleCodeStyleDocString docString = new GoogleCodeStyleDocString(docStringText); + assertEquals("Summary", docString.getSummary()); + final List
sections = docString.getSections(); + assertSize(3, sections); + + assertEquals("parameters", sections.get(0).getTitle()); + final List paramFields = sections.get(0).getFields(); + assertSize(2, paramFields); + final SectionField firstParamField = paramFields.get(0); + assertNotNull(firstParamField.getName()); + assertEquals("x", firstParamField.getName().toString()); + assertNotNull(firstParamField.getType()); + assertEquals("int", firstParamField.getType().toString()); + assertNotNull(firstParamField.getDescription()); + assertEquals("first parameter", firstParamField.getDescription().toString()); + + final SectionField secondParamField = paramFields.get(1); + assertNotNull(secondParamField.getName()); + assertEquals("y", secondParamField.getName().toString()); + assertNull(secondParamField.getType()); + assertNotNull(secondParamField.getDescription()); + assertEquals("second parameter\n" + + " with longer description", secondParamField.getDescription().toString()); + + assertEquals("raises", sections.get(1).getTitle()); + final List exceptionFields = sections.get(1).getFields(); + assertSize(1, exceptionFields); + final SectionField firstExcField = exceptionFields.get(0); + assertNull(firstExcField.getName()); + assertNotNull(firstExcField.getType()); + assertEquals("Exception", firstExcField.getType().toString()); + assertNotNull(firstExcField.getDescription()); + assertEquals("if anything bad happens", firstExcField.getDescription().toString()); + + assertEquals("returns", sections.get(2).getTitle()); + final List returnFields = sections.get(2).getFields(); + assertSize(1, returnFields); + final SectionField firstReturnField = returnFields.get(0); + assertNull(firstReturnField.getName()); + assertNotNull(firstReturnField.getType()); + assertEquals("None", firstReturnField.getType().toString()); + assertNotNull(firstReturnField.getDescription()); + assertEquals("always", firstReturnField.getDescription().toString()); + } + + @Nullable + private String findFirstDocString() { + final PsiElementProcessor.FindElement processor = new PsiElementProcessor.FindElement() { + @Override + public boolean execute(@NotNull PsiElement element) { + if (element instanceof PyStringLiteralExpression && element.getFirstChild().getNode().getElementType() == PyTokenTypes.DOCSTRING) { + return setFound(element); + } + return true; + } + }; + PsiTreeUtil.processElements(myFixture.getFile(), processor); + if (!processor.isFound()) { + return null; + } + final PsiElement foundElement = processor.getFoundElement(); + assertNotNull(foundElement); + return ((PyStringLiteralExpression)foundElement).getStringValue(); + } + + @Override + protected String getTestDataPath() { + return super.getTestDataPath() + "/docstrings"; + } +}