diff --git a/src/Core/Impl/Extensions/StringExtensions.cs b/src/Core/Impl/Extensions/StringExtensions.cs
index 82c383e9d..385cfb725 100644
--- a/src/Core/Impl/Extensions/StringExtensions.cs
+++ b/src/Core/Impl/Extensions/StringExtensions.cs
@@ -181,7 +181,7 @@ public static bool CharsAreLatin1LetterOrDigitOrUnderscore(this string s, int st
return true;
}
- public static int IndexOfOrdinal(this string s, string value, int startIndex = 0, bool ignoreCase = false)
+ public static int IndexOfOrdinal(this string s, string value, int startIndex = 0, bool ignoreCase = false)
=> s?.IndexOf(value, startIndex, ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal) ?? -1;
public static bool EqualsIgnoreCase(this string s, string other)
@@ -266,5 +266,18 @@ public static bool TryGetNextNonEmptySpan(this string s, char separator, int sub
span = (start, nextSeparatorIndex == -1 || nextSeparatorIndex >= substringLength ? substringLength - start : nextSeparatorIndex - start);
return true;
}
+
+ public static string[] SplitLines(this string s, params string[] lineEndings) {
+ if (lineEndings == null || lineEndings.Length == 0) {
+ lineEndings = new[] { "\r\n", "\r", "\n" };
+ }
+
+ return s.Split(lineEndings, StringSplitOptions.None);
+ }
+
+ public static string NormalizeLineEndings(this string s, string lineEnding = null) {
+ lineEnding = lineEnding ?? Environment.NewLine;
+ return string.Join(lineEnding, s.SplitLines());
+ }
}
}
diff --git a/src/LanguageServer/Impl/Documentation/DocstringConverter.cs b/src/LanguageServer/Impl/Documentation/DocstringConverter.cs
new file mode 100644
index 000000000..0e09688f9
--- /dev/null
+++ b/src/LanguageServer/Impl/Documentation/DocstringConverter.cs
@@ -0,0 +1,487 @@
+// Copyright(c) Microsoft Corporation
+// All rights reserved.
+//
+// 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
+//
+// THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS
+// OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY
+// IMPLIED WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE,
+// MERCHANTABILITY OR NON-INFRINGEMENT.
+//
+// See the Apache Version 2.0 License for specific language governing
+// permissions and limitations under the License.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Text;
+using System.Text.RegularExpressions;
+using Microsoft.Python.Core;
+
+namespace Microsoft.Python.LanguageServer.Documentation {
+ internal class DocstringConverter {
+ ///
+ /// Converts a docstring to a plaintext, human readable form. This will
+ /// first strip any common leading indention (like inspect.cleandoc),
+ /// then remove duplicate empty/whitespace lines.
+ ///
+ /// The docstring to convert, likely from the AST.
+ /// The converted docstring, with Environment.NewLine line endings.
+ public static string ToPlaintext(string docstring) {
+ var lines = SplitDocstring(docstring);
+ var output = new List();
+
+ foreach (var line in lines) {
+ if (string.IsNullOrWhiteSpace(line) && string.IsNullOrWhiteSpace(output.LastOrDefault())) {
+ continue;
+ }
+ output.Add(line);
+ }
+
+ return string.Join(Environment.NewLine, output).TrimEnd();
+ }
+
+ ///
+ /// Converts a docstring to a markdown format. This does various things,
+ /// including removing common indention, escaping characters, handling
+ /// code blocks, and more.
+ ///
+ /// The docstring to convert, likely from the AST.
+ /// The converted docstring, with Environment.NewLine line endings.
+ public static string ToMarkdown(string docstring) => new DocstringConverter(docstring).Convert();
+
+ private readonly StringBuilder _builder = new StringBuilder();
+ private bool _skipAppendEmptyLine = true;
+ private bool _insideInlineCode = false;
+ private bool _appendDirectiveBlock = false;
+
+ private Action _state;
+ private readonly Stack _stateStack = new Stack();
+
+ private readonly IReadOnlyList _lines;
+ private int _lineNum = 0;
+ private void EatLine() => _lineNum++;
+
+ private string CurrentLine => _lines.ElementAtOrDefault(_lineNum);
+ private int CurrentIndent => CountLeadingSpaces(CurrentLine);
+ private string LineAt(int i) => _lines.ElementAtOrDefault(i);
+ private int NextBlockIndent
+ => _lines.Skip(_lineNum + 1).SkipWhile(string.IsNullOrWhiteSpace)
+ .FirstOrDefault()?.TakeWhile(char.IsWhiteSpace).Count() ?? 0;
+
+ private int _blockIndent = 0;
+ private bool CurrentLineIsOutsideBlock => CurrentIndent < _blockIndent;
+ private string CurrentLineWithinBlock => CurrentLine.Substring(_blockIndent);
+
+ private DocstringConverter(string input) {
+ _state = ParseText;
+ _lines = SplitDocstring(input);
+ }
+
+ private string Convert() {
+ while (CurrentLine != null) {
+ var before = _state;
+ var beforeLine = _lineNum;
+
+ _state();
+
+ // Parser must make progress; either the state or line number must change.
+ if (_state == before && _lineNum == beforeLine) {
+ Debug.Fail("Infinite loop during docstring conversion");
+ break;
+ }
+ }
+
+ // Close out any outstanding code blocks.
+ if (_state == ParseBacktickBlock || _state == ParseDoctest || _state == ParseLiteralBlock) {
+ TrimOutputAndAppendLine("```");
+ } else if (_insideInlineCode) {
+ TrimOutputAndAppendLine("`", true);
+ }
+
+ return _builder.ToString().Trim();
+ }
+
+ private void PushAndSetState(Action next) {
+ if (_state == ParseText) {
+ _insideInlineCode = false;
+ }
+
+ _stateStack.Push(_state);
+ _state = next;
+ }
+
+ private void PopState() {
+ _state = _stateStack.Pop();
+
+ if (_state == ParseText) {
+ // Terminate inline code when leaving a block.
+ _insideInlineCode = false;
+ }
+ }
+
+ private void ParseText() {
+ if (string.IsNullOrWhiteSpace(CurrentLine)) {
+ _state = ParseEmpty;
+ return;
+ }
+
+ if (BeginBacktickBlock()) {
+ return;
+ }
+
+ if (BeginLiteralBlock()) {
+ return;
+ }
+
+ if (BeginDoctest()) {
+ return;
+ }
+
+ if (BeginDirective()) {
+ return;
+ }
+
+ // TODO: Push into Google/Numpy style list parser.
+
+ AppendTextLine(CurrentLine);
+ EatLine();
+ }
+
+ private void AppendTextLine(string line) {
+ line = PreprocessTextLine(line);
+
+ // Hack: attempt to put directives lines into their own paragraphs.
+ // This should be removed once proper list-like parsing is written.
+ if (!_insideInlineCode && Regex.IsMatch(line, @"^\s*:(param|arg|type|return|rtype|raise|except|var|ivar|cvar|copyright|license)")) {
+ AppendLine();
+ }
+
+ var parts = line.Split('`');
+
+ for (var i = 0; i < parts.Length; i++) {
+ var part = parts[i];
+
+ if (i > 0) {
+ _insideInlineCode = !_insideInlineCode;
+ Append('`');
+ }
+
+ if (_insideInlineCode) {
+ Append(part);
+ continue;
+ }
+
+ if (i == 0) {
+ // Replace ReST style ~~~ header to prevent it being interpreted as a code block
+ // (an alternative in Markdown to triple backtick blocks).
+ if (parts.Length == 1 && Regex.IsMatch(part, @"^\s*~~~+$")) {
+ Append(part.Replace('~', '-'));
+ continue;
+ }
+
+ // Don't strip away asterisk-based bullet point lists.
+ //
+ // TODO: Replace this with real list parsing. This may have
+ // false positives and cause random italics when the ReST list
+ // doesn't match Markdown's specification.
+ var match = Regex.Match(part, @"^(\s+\* )(.*)$");
+ if (match.Success) {
+ Append(match.Groups[1].Value);
+ part = match.Groups[2].Value;
+ }
+ }
+
+ // TODO: Find a better way to handle this; the below breaks escaped
+ // characters which appear at the beginning or end of a line.
+ // Applying this only when i == 0 or i == parts.Length-1 may work.
+
+ // http://docutils.sourceforge.net/docs/ref/rst/restructuredtext.html#hyperlink-references
+ // part = Regex.Replace(part, @"^_+", "");
+ // http://docutils.sourceforge.net/docs/ref/rst/restructuredtext.html#inline-internal-targets
+ // part = Regex.Replace(part, @"_+$", "");
+
+ // TODO: Strip footnote/citation references.
+
+ // Escape _, *, and ~, but ignore things like ":param \*\*kwargs:".
+ part = Regex.Replace(part, @"(?>> ")) {
+ return false;
+ }
+
+ BeginMinIndentCodeBlock(ParseDoctest);
+ AppendLine(CurrentLineWithinBlock);
+ EatLine();
+ return true;
+ }
+
+ private void ParseDoctest() {
+ if (CurrentLineIsOutsideBlock || string.IsNullOrWhiteSpace(CurrentLine)) {
+ TrimOutputAndAppendLine("```");
+ AppendLine();
+ PopState();
+ return;
+ }
+
+ AppendLine(CurrentLineWithinBlock);
+ EatLine();
+ }
+
+ private bool BeginLiteralBlock() {
+ // The previous line must be empty.
+ var prev = LineAt(_lineNum - 1);
+ if (prev == null) {
+ return false;
+ } else if (!string.IsNullOrWhiteSpace(prev)) {
+ return false;
+ }
+
+ // Find the previous paragraph and check that it ends with ::
+ var i = _lineNum - 2;
+ for (; i >= 0; i--) {
+ var line = LineAt(i);
+
+ if (string.IsNullOrWhiteSpace(line)) {
+ continue;
+ }
+
+ // Safe to ignore whitespace after the :: because all lines have been TrimEnd'd.
+ if (line.EndsWith("::")) {
+ break;
+ }
+
+ return false;
+ }
+
+ if (i < 0) {
+ return false;
+ }
+
+ // Special case: allow one-liners at the same indent level.
+ if (CurrentIndent == 0) {
+ AppendLine("```");
+ PushAndSetState(ParseLiteralBlockSingleLine);
+ return true;
+ }
+
+ BeginMinIndentCodeBlock(ParseLiteralBlock);
+ return true;
+ }
+
+ private void ParseLiteralBlock() {
+ // Slightly different than doctest, wait until the first non-empty unindented line to exit.
+ if (string.IsNullOrWhiteSpace(CurrentLine)) {
+ AppendLine();
+ EatLine();
+ return;
+ }
+
+ if (CurrentLineIsOutsideBlock) {
+ TrimOutputAndAppendLine("```");
+ AppendLine();
+ PopState();
+ return;
+ }
+
+ AppendLine(CurrentLineWithinBlock);
+ EatLine();
+ }
+
+ private void ParseLiteralBlockSingleLine() {
+ AppendLine(CurrentLine);
+ AppendLine("```");
+ AppendLine();
+ PopState();
+ EatLine();
+ }
+
+ private bool BeginDirective() {
+ if (!Regex.IsMatch(CurrentLine, @"^\s*\.\. ")) {
+ return false;
+ }
+
+ PushAndSetState(ParseDirective);
+ _blockIndent = NextBlockIndent;
+ _appendDirectiveBlock = false;
+ return true;
+ }
+
+ private void ParseDirective() {
+ // http://docutils.sourceforge.net/docs/ref/rst/restructuredtext.html#directives
+
+ var match = Regex.Match(CurrentLine, @"^\s*\.\.\s+(\w+)::\s*(.*)$");
+ if (match.Success) {
+ var directiveType = match.Groups[1].Value;
+ var directive = match.Groups[2].Value;
+
+ if (directiveType == "class") {
+ _appendDirectiveBlock = true;
+ AppendLine();
+ AppendLine("```");
+ AppendLine(directive);
+ AppendLine("```");
+ AppendLine();
+ }
+ }
+
+ if (_blockIndent == 0) {
+ // This is a one-liner directive, so pop back.
+ PopState();
+ } else {
+ _state = ParseDirectiveBlock;
+ }
+
+ EatLine();
+ }
+
+ private void ParseDirectiveBlock() {
+ if (!string.IsNullOrWhiteSpace(CurrentLine) && CurrentLineIsOutsideBlock) {
+ PopState();
+ return;
+ }
+
+ if (_appendDirectiveBlock) {
+ // This is a bit of a hack. This just trims the text and appends it
+ // like top-level text, rather than doing actual indent-based recursion.
+ AppendTextLine(CurrentLine.TrimStart());
+ }
+
+ EatLine();
+ }
+
+ private void AppendLine(string line = null) {
+ if (!string.IsNullOrWhiteSpace(line)) {
+ _builder.AppendLine(line);
+ _skipAppendEmptyLine = false;
+ } else if (!_skipAppendEmptyLine) {
+ _builder.AppendLine();
+ _skipAppendEmptyLine = true;
+ }
+ }
+
+ private void Append(string text) {
+ _builder.Append(text);
+ _skipAppendEmptyLine = false;
+ }
+
+ private void Append(char c) {
+ _builder.Append(c);
+ _skipAppendEmptyLine = false;
+ }
+
+ private void TrimOutputAndAppendLine(string line = null, bool noNewLine = false) {
+ _builder.TrimEnd();
+ _skipAppendEmptyLine = false;
+
+ if (!noNewLine) {
+ AppendLine();
+ }
+
+ AppendLine(line);
+ }
+
+ private static List SplitDocstring(string docstring) {
+ // As done by inspect.cleandoc.
+ docstring = docstring.Replace("\t", " ");
+
+ var lines = docstring.SplitLines()
+ .Select(s => s.TrimEnd())
+ .ToList();
+
+ if (lines.Count > 0) {
+ var first = lines[0].TrimStart();
+ if (first == string.Empty) {
+ first = null;
+ } else {
+ lines.RemoveAt(0);
+ }
+
+ lines = StripLeadingWhiteSpace(lines);
+
+ if (first != null) {
+ lines.Insert(0, first);
+ }
+ }
+
+ return lines;
+ }
+
+ private static List StripLeadingWhiteSpace(List lines, int? trim = null) {
+ var amount = trim ?? LargestTrim(lines);
+ return lines.Select(line => amount > line.Length ? string.Empty : line.Substring(amount)).ToList();
+ }
+
+ private static int LargestTrim(IEnumerable lines) => lines.Where(s => !string.IsNullOrWhiteSpace(s)).Select(CountLeadingSpaces).DefaultIfEmpty().Min();
+
+ private static int CountLeadingSpaces(string s) => s.TakeWhile(char.IsWhiteSpace).Count();
+ }
+}
diff --git a/src/LanguageServer/Impl/Documentation/DocumentationExtensions.cs b/src/LanguageServer/Impl/Documentation/DocumentationExtensions.cs
new file mode 100644
index 000000000..6bb631003
--- /dev/null
+++ b/src/LanguageServer/Impl/Documentation/DocumentationExtensions.cs
@@ -0,0 +1,26 @@
+// Copyright(c) Microsoft Corporation
+// All rights reserved.
+//
+// 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
+//
+// THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS
+// OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY
+// IMPLIED WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE,
+// MERCHANTABILITY OR NON-INFRINGEMENT.
+//
+// See the Apache Version 2.0 License for specific language governing
+// permissions and limitations under the License.
+
+using Microsoft.Python.Analysis.Types;
+
+namespace Microsoft.Python.LanguageServer.Documentation {
+ internal static class DocumentationExtensions {
+ public static string PlaintextDoc(this IPythonType type)
+ => DocstringConverter.ToPlaintext(type.Documentation);
+
+ public static string MarkdownDoc(this IPythonType type)
+ => DocstringConverter.ToMarkdown(type.Documentation);
+ }
+}
diff --git a/src/LanguageServer/Impl/Implementation/Server.cs b/src/LanguageServer/Impl/Implementation/Server.cs
index 08d90d8fb..df8cb6146 100644
--- a/src/LanguageServer/Impl/Implementation/Server.cs
+++ b/src/LanguageServer/Impl/Implementation/Server.cs
@@ -122,7 +122,15 @@ public async Task InitializeAsync(InitializeParams @params, Ca
DisplayStartupInfo();
- var ds = new PlainTextDocumentationSource();
+ // TODO: Pass different documentation sources to completion/hover/signature
+ // based on client capabilities.
+ IDocumentationSource ds;
+ if (DisplayOptions.preferredFormat == MarkupKind.Markdown) {
+ ds = new MarkdownDocumentationSource();
+ } else {
+ ds = new PlainTextDocumentationSource();
+ }
+
_completionSource = new CompletionSource(ds, Settings.completion);
_hoverSource = new HoverSource(ds);
_signatureSource = new SignatureSource(ds);
diff --git a/src/LanguageServer/Impl/Sources/MarkdownDocumentationSource.cs b/src/LanguageServer/Impl/Sources/MarkdownDocumentationSource.cs
new file mode 100644
index 000000000..05b9c6d3c
--- /dev/null
+++ b/src/LanguageServer/Impl/Sources/MarkdownDocumentationSource.cs
@@ -0,0 +1,121 @@
+// Copyright(c) Microsoft Corporation
+// All rights reserved.
+//
+// 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
+//
+// THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS
+// OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY
+// IMPLIED WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE,
+// MERCHANTABILITY OR NON-INFRINGEMENT.
+//
+// See the Apache Version 2.0 License for specific language governing
+// permissions and limitations under the License.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using Microsoft.Python.Analysis;
+using Microsoft.Python.Analysis.Types;
+using Microsoft.Python.Analysis.Values;
+using Microsoft.Python.LanguageServer.Documentation;
+using Microsoft.Python.LanguageServer.Protocol;
+
+namespace Microsoft.Python.LanguageServer.Sources {
+ internal sealed class MarkdownDocumentationSource : IDocumentationSource {
+ public InsertTextFormat DocumentationFormat => InsertTextFormat.PlainText;
+
+ public MarkupContent GetHover(string name, IMember member) {
+ // We need to tell between instance and type.
+ var type = member.GetPythonType();
+ if (type.IsUnknown()) {
+ return new MarkupContent { kind = MarkupKind.Markdown, value = $"```\n{name}\n```" };
+ }
+
+ string text;
+ if (member is IPythonInstance) {
+ if (!(type is IPythonFunctionType)) {
+ text = !string.IsNullOrEmpty(name) ? $"{name}: {type.Name}" : $"{type.Name}";
+ return new MarkupContent { kind = MarkupKind.Markdown, value = $"```\n{text}\n```" };
+ }
+ }
+
+ var typeDoc = !string.IsNullOrEmpty(type.Documentation) ? $"\n---\n{type.MarkdownDoc()}" : string.Empty;
+ switch (type) {
+ case IPythonPropertyType prop:
+ text = GetPropertyHoverString(prop);
+ break;
+
+ case IPythonFunctionType ft:
+ text = GetFunctionHoverString(ft);
+ break;
+
+ case IPythonClassType cls:
+ var clsDoc = !string.IsNullOrEmpty(cls.Documentation) ? $"\n---\n{cls.MarkdownDoc()}" : string.Empty;
+ text = $"```\nclass {cls.Name}\n```{clsDoc}";
+ break;
+
+ case IPythonModule mod:
+ text = !string.IsNullOrEmpty(mod.Name) ? $"```\nmodule {mod.Name}\n```{typeDoc}" : $"`module`{typeDoc}";
+ break;
+
+ default:
+ text = !string.IsNullOrEmpty(name) ? $"```\ntype {name}: {type.Name}\n```{typeDoc}" : $"{type.Name}{typeDoc}";
+ break;
+ }
+
+ return new MarkupContent {
+ kind = MarkupKind.Markdown, value = text
+ };
+ }
+
+ public MarkupContent FormatDocumentation(string documentation) {
+ return new MarkupContent { kind = MarkupKind.Markdown, value = DocstringConverter.ToMarkdown(documentation) };
+ }
+
+ public string GetSignatureString(IPythonFunctionType ft, int overloadIndex = 0) {
+ var o = ft.Overloads[overloadIndex];
+
+ var parms = GetFunctionParameters(ft);
+ var parmString = string.Join(", ", parms);
+ var annString = string.IsNullOrEmpty(o.ReturnDocumentation) ? string.Empty : $" -> {o.ReturnDocumentation}";
+
+ return $"{ft.Name}({parmString}){annString}";
+ }
+
+ public MarkupContent FormatParameterDocumentation(IParameterInfo parameter) {
+ if (!string.IsNullOrEmpty(parameter.Documentation)) {
+ return FormatDocumentation(parameter.Documentation);
+ }
+ // TODO: show fully qualified type?
+ var text = parameter.Type.IsUnknown() ? $"```\n{parameter.Name}\n```" : $"`{parameter.Name}: {parameter.Type.Name}`";
+ return new MarkupContent { kind = MarkupKind.Markdown, value = text };
+ }
+
+ private string GetPropertyHoverString(IPythonPropertyType prop, int overloadIndex = 0) {
+ var decTypeString = prop.DeclaringType != null ? $"{prop.DeclaringType.Name}." : string.Empty;
+ var propDoc = !string.IsNullOrEmpty(prop.Documentation) ? $"\n---\n{prop.MarkdownDoc()}" : string.Empty;
+ return $"```\n{decTypeString}\n```{propDoc}";
+ }
+
+ private string GetFunctionHoverString(IPythonFunctionType ft, int overloadIndex = 0) {
+ var sigString = GetSignatureString(ft, overloadIndex);
+ var decTypeString = ft.DeclaringType != null ? $"{ft.DeclaringType.Name}." : string.Empty;
+ var funcDoc = !string.IsNullOrEmpty(ft.Documentation) ? $"\n---\n{ft.MarkdownDoc()}" : string.Empty;
+ return $"```\n{decTypeString}{sigString}\n```{funcDoc}";
+ }
+
+ private IEnumerable GetFunctionParameters(IPythonFunctionType ft, int overloadIndex = 0) {
+ var o = ft.Overloads[overloadIndex]; // TODO: display all?
+ var skip = ft.IsStatic || ft.IsUnbound ? 0 : 1;
+ return o.Parameters.Skip(skip).Select(p => {
+ if (!string.IsNullOrEmpty(p.DefaultValueString)) {
+ return $"{p.Name}={p.DefaultValueString}";
+ }
+ return p.Type.IsUnknown() ? p.Name : $"{p.Name}: {p.Type.Name}";
+ });
+ }
+ }
+}
diff --git a/src/LanguageServer/Impl/Sources/PlainTextDocumentationSource.cs b/src/LanguageServer/Impl/Sources/PlainTextDocumentationSource.cs
index e510f10d0..273afad7f 100644
--- a/src/LanguageServer/Impl/Sources/PlainTextDocumentationSource.cs
+++ b/src/LanguageServer/Impl/Sources/PlainTextDocumentationSource.cs
@@ -18,6 +18,7 @@
using Microsoft.Python.Analysis;
using Microsoft.Python.Analysis.Types;
using Microsoft.Python.Analysis.Values;
+using Microsoft.Python.LanguageServer.Documentation;
using Microsoft.Python.LanguageServer.Protocol;
namespace Microsoft.Python.LanguageServer.Sources {
@@ -39,7 +40,7 @@ public MarkupContent GetHover(string name, IMember member) {
}
}
- var typeDoc = !string.IsNullOrEmpty(type.Documentation) ? $"\n\n{type.Documentation}" : string.Empty;
+ var typeDoc = !string.IsNullOrEmpty(type.Documentation) ? $"\n\n{type.PlaintextDoc()}" : string.Empty;
switch (type) {
case IPythonPropertyType prop:
text = GetPropertyHoverString(prop);
@@ -50,7 +51,7 @@ public MarkupContent GetHover(string name, IMember member) {
break;
case IPythonClassType cls:
- var clsDoc = !string.IsNullOrEmpty(cls.Documentation) ? $"\n\n{cls.Documentation}" : string.Empty;
+ var clsDoc = !string.IsNullOrEmpty(cls.Documentation) ? $"\n\n{cls.PlaintextDoc()}" : string.Empty;
text = $"class {cls.Name}{clsDoc}";
break;
@@ -93,14 +94,14 @@ public MarkupContent FormatParameterDocumentation(IParameterInfo parameter) {
private string GetPropertyHoverString(IPythonPropertyType prop, int overloadIndex = 0) {
var decTypeString = prop.DeclaringType != null ? $"{prop.DeclaringType.Name}." : string.Empty;
- var propDoc = !string.IsNullOrEmpty(prop.Documentation) ? $"\n\n{prop.Documentation}" : string.Empty;
+ var propDoc = !string.IsNullOrEmpty(prop.Documentation) ? $"\n\n{prop.PlaintextDoc()}" : string.Empty;
return $"{decTypeString}{propDoc}";
}
private string GetFunctionHoverString(IPythonFunctionType ft, int overloadIndex = 0) {
var sigString = GetSignatureString(ft, overloadIndex);
var decTypeString = ft.DeclaringType != null ? $"{ft.DeclaringType.Name}." : string.Empty;
- var funcDoc = !string.IsNullOrEmpty(ft.Documentation) ? $"\n\n{ft.Documentation}" : string.Empty;
+ var funcDoc = !string.IsNullOrEmpty(ft.Documentation) ? $"\n\n{ft.PlaintextDoc()}" : string.Empty;
return $"{decTypeString}{sigString}{funcDoc}";
}
diff --git a/src/LanguageServer/Test/DocstringConverterTests.cs b/src/LanguageServer/Test/DocstringConverterTests.cs
new file mode 100644
index 000000000..648255267
--- /dev/null
+++ b/src/LanguageServer/Test/DocstringConverterTests.cs
@@ -0,0 +1,486 @@
+// Copyright(c) Microsoft Corporation
+// All rights reserved.
+//
+// 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
+//
+// THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS
+// OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY
+// IMPLIED WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE,
+// MERCHANTABILITY OR NON-INFRINGEMENT.
+//
+// See the Apache Version 2.0 License for specific language governing
+// permissions and limitations under the License.
+
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using TestUtilities;
+using static Microsoft.Python.LanguageServer.Tests.FluentAssertions.DocstringAssertions;
+
+namespace Microsoft.Python.LanguageServer.Tests {
+ [TestClass]
+ public class DocstringConverterTests {
+ public TestContext TestContext { get; set; }
+
+ [TestInitialize]
+ public void TestInitialize()
+ => TestEnvironmentImpl.TestInitialize($"{TestContext.FullyQualifiedTestClassName}.{TestContext.TestName}");
+
+ [TestCleanup]
+ public void Cleanup() => TestEnvironmentImpl.TestCleanup();
+
+ [DataRow("A\nB", "A\nB")]
+ [DataRow("A\n\nB", "A\n\nB")]
+ [DataRow("A\n\nB", "A\n\nB")]
+ [DataRow("A\n B", "A\nB")]
+ [DataRow(" A\n B", "A\nB")]
+ [DataRow("\nA\n B", "A\n B")]
+ [DataRow("\n A\n B", "A\nB")]
+ [DataRow("\nA\nB\n", "A\nB")]
+ [DataRow(" \n\nA \n \nB \n ", "A\n\nB")]
+ [DataTestMethod, Priority(0)]
+ public void PlaintextIndention(string docstring, string expected) {
+ docstring.Should().ConvertToPlaintext(expected);
+ }
+
+ [TestMethod, Priority(0)]
+ public void NormalText() {
+ var docstring = @"This is just some normal text
+that extends over multiple lines. This will appear
+as-is without modification.
+";
+
+ var markdown = @"This is just some normal text
+that extends over multiple lines. This will appear
+as-is without modification.
+";
+ docstring.Should().ConvertToMarkdown(markdown);
+ }
+
+ [TestMethod, Priority(0)]
+ public void InlineLitereals() {
+ var docstring = @"This paragraph talks about ``foo``
+which is related to :something:`bar`, and probably `qux`:something_else:.
+";
+
+ var markdown = @"This paragraph talks about `foo`
+which is related to `bar`, and probably `qux`.
+";
+ docstring.Should().ConvertToMarkdown(markdown);
+ }
+
+ [TestMethod, Priority(0)]
+ public void Headings() {
+ var docstring = @"Heading 1
+=========
+
+Heading 2
+---------
+
+Heading 3
+~~~~~~~~~
+";
+
+ var markdown = @"Heading 1
+=========
+
+Heading 2
+---------
+
+Heading 3
+---------
+";
+ docstring.Should().ConvertToMarkdown(markdown);
+ }
+
+ [DataRow(@"*foo*", @"\*foo\*")]
+ [DataRow(@"``*foo*``", @"`*foo*`")]
+ [DataRow(@"~foo~~bar~", @"\~foo\~\~bar\~")]
+ [DataRow(@"``~foo~~bar~``", @"`~foo~~bar~`")]
+ [DataRow(@"__init__", @"\_\_init\_\_")]
+ [DataRow(@"``__init__``", @"`__init__`")]
+ [DataRow(@"foo **bar", @"foo \*\*bar")]
+ [DataTestMethod, Priority(0)]
+ public void EscapedCharacters(string docstring, string markdown) {
+ docstring.Should().ConvertToMarkdown(markdown);
+ }
+
+ [TestMethod, Priority(0)]
+ public void AsterisksAtStartOfArgs() {
+ var docstring = @"Foo:
+
+ Args:
+ foo (Foo): Foo!
+ *args: These are positional args.
+ **kwargs: These are named args.
+";
+ var markdown = @"Foo:
+
+Args:
+ foo (Foo): Foo!
+ \*args: These are positional args.
+ \*\*kwargs: These are named args.
+";
+ docstring.Should().ConvertToMarkdown(markdown);
+ }
+
+ [TestMethod, Priority(0)]
+ public void CopyrightAndLicense() {
+ var docstring = @"This is a test.
+
+:copyright: Fake Name
+:license: ABCv123
+";
+
+ var markdown = @"This is a test.
+
+:copyright: Fake Name
+
+:license: ABCv123
+";
+
+ docstring.Should().ConvertToMarkdown(markdown);
+ }
+
+ [TestMethod, Priority(0)]
+ public void CommonRestFieldLists() {
+ var docstring = @"This function does something.
+
+:param foo: This is a description of the foo parameter
+ which does something interesting.
+:type foo: Foo
+:param bar: This is a description of bar.
+:type bar: Bar
+:return: Something else.
+:rtype: Something
+:raises ValueError: If something goes wrong.
+";
+
+ var markdown = @"This function does something.
+
+:param foo: This is a description of the foo parameter
+ which does something interesting.
+
+:type foo: Foo
+
+:param bar: This is a description of bar.
+
+:type bar: Bar
+
+:return: Something else.
+
+:rtype: Something
+
+:raises ValueError: If something goes wrong.
+";
+ docstring.Should().ConvertToMarkdown(markdown);
+ }
+
+ [TestMethod, Priority(0)]
+ public void Doctest() {
+ var docstring = @"This is a doctest:
+
+>>> print('foo')
+foo
+";
+
+ var markdown = @"This is a doctest:
+
+```
+>>> print('foo')
+foo
+```
+";
+ docstring.Should().ConvertToMarkdown(markdown);
+ }
+
+ [TestMethod, Priority(0)]
+ public void DoctestIndented() {
+ var docstring = @"This is a doctest:
+
+ >>> print('foo')
+ foo
+";
+
+ var markdown = @"This is a doctest:
+
+```
+>>> print('foo')
+foo
+```
+";
+ docstring.Should().ConvertToMarkdown(markdown);
+ }
+
+ [TestMethod, Priority(0)]
+ public void DoctestTextAfter() {
+ var docstring = @"This is a doctest:
+
+>>> print('foo')
+foo
+
+This text comes after.
+";
+
+ var markdown = @"This is a doctest:
+
+```
+>>> print('foo')
+foo
+```
+
+This text comes after.
+";
+ docstring.Should().ConvertToMarkdown(markdown);
+ }
+
+ [TestMethod, Priority(0)]
+ public void DoctestIndentedTextAfter() {
+ var docstring = @"This is a doctest:
+
+ >>> print('foo')
+ foo
+ This line has a different indent.
+";
+
+ var markdown = @"This is a doctest:
+
+```
+>>> print('foo')
+foo
+```
+
+This line has a different indent.
+";
+ docstring.Should().ConvertToMarkdown(markdown);
+ }
+
+ [TestMethod, Priority(0)]
+ public void MarkdownStyleBacktickBlock() {
+ var docstring = @"Backtick block:
+
+```
+print(foo_bar)
+
+if True:
+ print(bar_foo)
+```
+
+And some text after.
+";
+
+ var markdown = @"Backtick block:
+
+```
+print(foo_bar)
+
+if True:
+ print(bar_foo)
+```
+
+And some text after.
+";
+ docstring.Should().ConvertToMarkdown(markdown);
+ }
+
+ [TestMethod, Priority(0)]
+ public void RestLiteralBlock() {
+ var docstring = @"
+Take a look at this code::
+
+ if foo:
+ print(foo)
+ else:
+ print('not foo!')
+
+This text comes after.
+";
+
+ var markdown = @"Take a look at this code:
+
+```
+if foo:
+ print(foo)
+else:
+ print('not foo!')
+```
+
+This text comes after.
+";
+ docstring.Should().ConvertToMarkdown(markdown);
+ }
+
+ [TestMethod, Priority(0)]
+ public void RestLiteralBlockExtraSpace() {
+ var docstring = @"
+Take a look at this code::
+
+
+
+
+ if foo:
+ print(foo)
+ else:
+ print('not foo!')
+
+This text comes after.
+";
+
+ var markdown = @"Take a look at this code:
+
+```
+if foo:
+ print(foo)
+else:
+ print('not foo!')
+```
+
+This text comes after.
+";
+ docstring.Should().ConvertToMarkdown(markdown);
+ }
+
+ [TestMethod, Priority(0)]
+ public void RestLiteralBlockEmptyDoubleColonLine() {
+ var docstring = @"
+::
+
+ if foo:
+ print(foo)
+ else:
+ print('not foo!')
+";
+
+ var markdown = @"```
+if foo:
+ print(foo)
+else:
+ print('not foo!')
+```
+";
+ docstring.Should().ConvertToMarkdown(markdown);
+ }
+
+ [TestMethod, Priority(0)]
+ public void RestLiteralBlockNoIndentOneLiner() {
+ var docstring = @"
+The next code is a one-liner::
+
+print(a + foo + 123)
+
+And now it's text.
+";
+
+ var markdown = @"The next code is a one-liner:
+
+```
+print(a + foo + 123)
+```
+
+And now it's text.
+";
+ docstring.Should().ConvertToMarkdown(markdown);
+ }
+
+ [TestMethod, Priority(0)]
+ public void DirectiveRemoval() {
+ var docstring = @"This is a test.
+
+.. ignoreme:: example
+
+This text is in-between.
+
+.. versionadded:: 1.0
+ Foo was added to Bar.
+
+.. admonition:: Note
+
+ This paragraph appears inside the admonition
+ and spans multiple lines.
+
+This text comes after.
+";
+
+ var markdown = @"This is a test.
+
+This text is in-between.
+
+This text comes after.
+";
+ docstring.Should().ConvertToMarkdown(markdown);
+ }
+
+ [TestMethod, Priority(0)]
+ public void ClassDirective() {
+ var docstring = @"
+.. class:: FooBar()
+ This is a description of ``FooBar``.
+
+``FooBar`` is interesting.
+";
+
+ var markdown = @"```
+FooBar()
+```
+
+This is a description of `FooBar`.
+
+`FooBar` is interesting.
+";
+ docstring.Should().ConvertToMarkdown(markdown);
+ }
+
+ [TestMethod, Priority(0)]
+ public void UnfinishedBacktickBlock() {
+ var docstring = @"```
+something
+";
+
+ var markdown = @"```
+something
+```
+";
+ docstring.Should().ConvertToMarkdown(markdown);
+ }
+
+ [TestMethod, Priority(0)]
+ public void UnfinishedInlineLiteral() {
+ var docstring = @"`oops
+";
+
+ var markdown = @"`oops`";
+ docstring.Should().ConvertToMarkdown(markdown);
+ }
+
+ [TestMethod, Priority(0)]
+ public void DashList() {
+ var docstring = @"
+This is a list:
+ - Item 1
+ - Item 2
+";
+
+ var markdown = @"This is a list:
+ - Item 1
+ - Item 2
+";
+ docstring.Should().ConvertToMarkdown(markdown);
+ }
+
+ [TestMethod, Priority(0)]
+ public void AsteriskList() {
+ var docstring = @"
+This is a list:
+ * Item 1
+ * Item 2
+";
+
+ var markdown = @"This is a list:
+ * Item 1
+ * Item 2
+";
+ docstring.Should().ConvertToMarkdown(markdown);
+ }
+ }
+}
diff --git a/src/LanguageServer/Test/FluentAssertions/DocstringConverterAssertions.cs b/src/LanguageServer/Test/FluentAssertions/DocstringConverterAssertions.cs
new file mode 100644
index 000000000..aa838287f
--- /dev/null
+++ b/src/LanguageServer/Test/FluentAssertions/DocstringConverterAssertions.cs
@@ -0,0 +1,35 @@
+// Copyright(c) Microsoft Corporation
+// All rights reserved.
+//
+// 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
+//
+// THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS
+// OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY
+// IMPLIED WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE,
+// MERCHANTABILITY OR NON-INFRINGEMENT.
+//
+// See the Apache Version 2.0 License for specific language governing
+// permissions and limitations under the License.
+
+using System.Diagnostics.CodeAnalysis;
+using FluentAssertions;
+using FluentAssertions.Primitives;
+using Microsoft.Python.Core;
+using Microsoft.Python.LanguageServer.Documentation;
+
+namespace Microsoft.Python.LanguageServer.Tests.FluentAssertions {
+ [ExcludeFromCodeCoverage]
+ internal static class DocstringAssertions {
+ public static AndConstraint ConvertToPlaintext(this StringAssertions a, string plaintext, string because = "", params object[] reasonArgs) {
+ DocstringConverter.ToPlaintext(a.Subject.NormalizeLineEndings()).Should().Be(plaintext.NormalizeLineEndings().TrimEnd(), because, reasonArgs);
+ return new AndConstraint(a);
+ }
+
+ public static AndConstraint ConvertToMarkdown(this StringAssertions a, string markdown, string because = "", params object[] reasonArgs) {
+ DocstringConverter.ToMarkdown(a.Subject.NormalizeLineEndings()).Should().Be(markdown.NormalizeLineEndings().TrimEnd(), because, reasonArgs);
+ return new AndConstraint(a);
+ }
+ }
+}