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); + } + } +}