diff --git a/src/FsAutoComplete.Core/TipFormatter.fs b/src/FsAutoComplete.Core/TipFormatter.fs index ab56a3efa..43262f7fd 100644 --- a/src/FsAutoComplete.Core/TipFormatter.fs +++ b/src/FsAutoComplete.Core/TipFormatter.fs @@ -8,12 +8,15 @@ open System.IO open System.Xml open System.Text.RegularExpressions open FSharp.Compiler.SourceCodeServices +open FsAutoComplete.Logging -module private Section = +let inline nl<'T> = Environment.NewLine + +let logger = LogProvider.getLoggerByName "TipFormatter" - let inline nl<'T> = Environment.NewLine +module private Section = - let inline private addSection (name : string) (content : string) = + let inline addSection (name : string) (content : string) = if name <> "" then nl + nl + "**" + name + "**" @@ -25,7 +28,23 @@ module private Section = if content.Count = 0 then "" else - addSection name (content |> Seq.map (fun kv -> "* `" + kv.Key + "`" + ": " + kv.Value) |> String.concat nl) + content + |> Seq.map (fun kv -> + let text = + if kv.Value.Contains("\n") then + kv.Value.Split('\n') + |> Seq.map (fun line -> + "> " + line.TrimStart() + ) + |> String.concat "\n" + |> (+) nl // Start the quote block on a new line + else + kv.Value + + "* `" + kv.Key + "`" + ": " + text + ) + |> String.concat nl + |> addSection name let fromOption (name : string) (content : string option) = if content.IsNone then @@ -34,42 +53,460 @@ module private Section = addSection name content.Value let fromList (name : string) (content : string seq) = - if Seq.length content = 0 then + if Seq.isEmpty content then "" else addSection name (content |> String.concat nl) -// TODO: Improve this parser. Is there any other XmlDoc parser available? -type private XmlDocMember(doc: XmlDocument, indentationSize : int, columnOffset : int) = - let nl = Environment.NewLine - /// References used to detect if we should remove meaningless spaces - let tabsOffset = String.replicate (columnOffset + indentationSize) " " - let readContentForTooltip (node: XmlNode) = - match node with - | null -> null +module private Format = + + let tagPattern (tagName : string) = + sprintf """(?'void_element'<%s(?'void_attributes'\s+[^\/>]+)?\/>)|(?'non_void_element'<%s(?'non_void_attributes'\s+[^>]+)?>(?'non_void_innerText'(?:(?!<%s>)(?!<\/%s>)[\s\S])*)<\/%s\s*>)""" tagName tagName tagName tagName tagName + + type TagInfo = + | VoidElement of attributes : Map + | NonVoidElement of innerText : string * attributes : Map + + type FormatterInfo = + { + TagName : string + Formatter : TagInfo -> string option + } + + let private extractTextFromQuote (quotedText : string) = + quotedText.Substring(1, quotedText.Length - 2) + + + let extractMemberText (text : string) = + let pattern = "(?'member_type'[a-z]{1}:)?(?'member_text'.*)" + let m = Regex.Match(text, pattern, RegexOptions.IgnoreCase) + + if m.Groups.["member_text"].Success then + m.Groups.["member_text"].Value + else + text + + let private getAttributes (attributes : Group) = + if attributes.Success then + let pattern = """(?'key'\S+)=(?'value''[^']*'|"[^"]*")""" + Regex.Matches(attributes.Value, pattern, RegexOptions.IgnoreCase) + |> Seq.cast + |> Seq.map (fun m -> + m.Groups.["key"].Value, extractTextFromQuote m.Groups.["value"].Value + ) + |> Map.ofSeq + else + Map.empty + + let rec private applyFormatter (info : FormatterInfo) text = + let pattern = tagPattern info.TagName + match Regex.Match(text, pattern, RegexOptions.IgnoreCase) with + | m when m.Success -> + if m.Groups.["void_element"].Success then + let attributes = getAttributes m.Groups.["void_attributes"] + + let replacement = + VoidElement attributes + |> info.Formatter + + match replacement with + | Some replacement -> + text.Replace(m.Groups.["void_element"].Value, replacement) + // Re-apply the formatter, because perhaps there is more + // of the current tag to convert + |> applyFormatter info + + | None -> + // The formatter wasn't able to convert the tag + // Return as it is and don't re-apply the formatter + // otherwise it will create an infinity loop + text + + else if m.Groups.["non_void_element"].Success then + let innerText = m.Groups.["non_void_innerText"].Value + let attributes = getAttributes m.Groups.["non_void_attributes"] + + let replacement = + NonVoidElement (innerText, attributes) + |> info.Formatter + + match replacement with + | Some replacement -> + // Re-apply the formatter, because perhaps there is more + // of the current tag to convert + text.Replace(m.Groups.["non_void_element"].Value, replacement) + |> applyFormatter info + + | None -> + // The formatter wasn't able to convert the tag + // Return as it is and don't re-apply the formatter + // otherwise it will create an infinity loop + text + else + // Should not happend but like that we are sure to handle all possible cases + text | _ -> - // Many definitions contain references like or - // Replace them by the attribute content (keyName and System.IO.Exception in the samples above) - // Put content in single quotes for possible formatting improvements on editor side. - let text = node.InnerXml.Replace("", "\n\n") - .Replace("", "\n") - let c = Regex.Replace(text, """(.*?)<\/a>""", "[$2]($1)" ) - let c = Regex.Replace(c,"""(.*?)<\/code>""", "`$1`") - let c = Regex.Replace(c,"""<\w+ \w+="(?:\w:){0,1}(.+?)">.*<\/\w+>""", "`$1`") - let c = Regex.Replace(c,"""<\w+ \w+="(?:\w:){0,1}(.+?)" />""", "`$1`") - let tableIndex = c.IndexOf("") - let s = - if tableIndex > 0 then - let start = c.Substring(0, tableIndex) - let table = c.Substring(tableIndex) - let rows = Regex.Matches(c, " cannot be converted into a multine string + /// and so we prefer to remove the 'or' block instead of having some weird markdown artefacts + /// + /// For now, we only consider text between to be invalid + /// We can add more in the future if needed, but I want to keep this as minimal as possible to avoid capturing false positive + /// / + let private removeInvalidOrBlock (text : string) = + let invalidOrBlockPattern = """])*>(?'or_text'(?:(?!])*>""" + + Regex.Matches(text, invalidOrBlockPattern, RegexOptions.Multiline) + |> Seq.cast + |> Seq.fold (fun (state : string) (m : Match) -> + let orText = m.Groups.["or_text"] + + if orText.Success then + let replacement = + orText.Value.Replace("-or-", "or") + + state.Replace(orText.Value, replacement) + else + state + ) text + + + let private convertTable = + { + TagName = "table" + Formatter = + function + | VoidElement _ -> + None + + | NonVoidElement (innerText, _) -> + + let rowCount = Regex.Matches(innerText, "").Count + let convertedTable = + innerText + .Replace(nl, "") .Replace("\n", "") .Replace("
").Count - let table = - table.Replace(nl, "") + text + + let private codeBlock = + { + TagName = "code" + Formatter = + function + | VoidElement _ -> + None + + | NonVoidElement (innerText, attributes) -> + let lang = + match Map.tryFind "lang" attributes with + | Some lang -> + lang + + | None -> + "forceNoHighlight" + + let formattedText = + if innerText.Contains("\n") then + + if innerText.StartsWith("\n") then + + sprintf "```%s%s\n```" lang innerText + + else + sprintf "```%s\n%s\n```" lang innerText + + else + sprintf "`%s`" innerText + + Some formattedText + + } + |> applyFormatter + + let private codeInline = + { + TagName = "c" + Formatter = + function + | VoidElement _ -> + None + | NonVoidElement (innerText, _) -> + "`" + innerText + "`" + |> Some + } + |> applyFormatter + + let private anchor = + { + TagName = "a" + Formatter = + function + | VoidElement _ -> + None + + | NonVoidElement (innerText, attributes) -> + let href = + match Map.tryFind "href" attributes with + | Some href -> + href + + | None -> + "" + + sprintf "[%s](%s)" innerText href + |> Some + } + |> applyFormatter + + let private paragraph = + { + TagName = "para" + Formatter = + function + | VoidElement _ -> + None + + | NonVoidElement (innerText, _) -> + nl + innerText + nl + |> Some + } + |> applyFormatter + + let private block = + { + TagName = "block" + Formatter = + function + | VoidElement _ -> + None + + | NonVoidElement (innerText, _) -> + nl + innerText + nl + |> Some + } + |> applyFormatter + + let private see = + let getCRef (attributes : Map) = Map.tryFind "cref" attributes + { + TagName = "see" + Formatter = + function + | VoidElement attributes -> + match getCRef attributes with + | Some cref -> + // TODO: Add config to generates command + "`" + extractMemberText cref + "`" + |> Some + + | None -> + None + + | NonVoidElement (innerText, attributes) -> + if String.IsNullOrWhiteSpace innerText then + match getCRef attributes with + | Some cref -> + // TODO: Add config to generates command + "`" + extractMemberText cref + "`" + |> Some + + | None -> + None + else + "`" + innerText + "`" + |> Some + } + |> applyFormatter + + let private xref = + let getHRef (attributes : Map) = Map.tryFind "href" attributes + { + TagName = "xref" + Formatter = + function + | VoidElement attributes -> + match getHRef attributes with + | Some href -> + // TODO: Add config to generates command + "`" + extractMemberText href + "`" + |> Some + + | None -> + None + + | NonVoidElement (innerText, attributes) -> + if String.IsNullOrWhiteSpace innerText then + match getHRef attributes with + | Some href -> + // TODO: Add config to generates command + "`" + extractMemberText href + "`" + |> Some + + | None -> + None + else + "`" + innerText + "`" + |> Some + } + |> applyFormatter + + let private paramRef = + let getName (attributes : Map) = Map.tryFind "name" attributes + + { + TagName = "paramref" + Formatter = + function + | VoidElement attributes -> + match getName attributes with + | Some name -> + "`" + name + "`" + |> Some + + | None -> + None + + | NonVoidElement (innerText, attributes) -> + if String.IsNullOrWhiteSpace innerText then + match getName attributes with + | Some name -> + // TODO: Add config to generates command + "`" + name + "`" + |> Some + + | None -> + None + else + "`" + innerText + "`" + |> Some + + } + |> applyFormatter + + let private typeParamRef = + let getName (attributes : Map) = Map.tryFind "name" attributes + + { + TagName = "typeparamref" + Formatter = + function + | VoidElement attributes -> + match getName attributes with + | Some name -> + "`" + name + "`" + |> Some + + | None -> + None + + | NonVoidElement (innerText, attributes) -> + if String.IsNullOrWhiteSpace innerText then + match getName attributes with + | Some name -> + // TODO: Add config to generates command + "`" + name + "`" + |> Some + + | None -> + None + else + "`" + innerText + "`" + |> Some + } + |> applyFormatter + + let private fixPortableClassLibrary (text : string) = + text.Replace( + "~/docs/standard/cross-platform/cross-platform-development-with-the-portable-class-library.md", + "https://docs.microsoft.com/en-gb/dotnet/standard/cross-platform/cross-platform-development-with-the-portable-class-library" + ) + + /// Handle Microsoft 'or' formatting blocks + /// + /// We don't use the formatter API here because we are not handling a "real XML element" + /// We don't use regex neither because I am not able to create one covering all the possible case + /// + /// There are 2 types of 'or' blocks: + /// + /// - Inlined: [...] -or- [...] -or- [...] + /// - Blocked: + /// [...] + /// -or- + /// [...] + /// -or- + /// [...] + /// + /// + /// This function can convert both styles. If an 'or' block is encounter the whole section will always result in a multiline output + /// + /// + /// If we pass any of the 2 previous example, it will generate the same Markdown string as a result (because they have the same number of 'or' section). The result will be: + /// + /// + /// > [...] + /// + /// *or* + /// + /// > [...] + /// + /// *or* + /// + /// > [...] + /// + /// + let private handleMicrosoftOrList (text : string) = + let splitResult = text.Split([|"-or-"|], StringSplitOptions.RemoveEmptyEntries) + + // If text doesn't contains any `-or-` then we just forward it + if Seq.length splitResult = 1 then + text + else + splitResult + |> Seq.map (fun orText -> + let orText = orText.Trim() + let lastParagraphStartIndex = orText.LastIndexOf("\n") + + // We make the assumption that an 'or' section should always be defined on a single line + // From testing against different 'or' block written by Microsoft it seems to be always the case + // By doing this assumption this allow us to correctly handle comments like: + // + // + // Some text goes here + // + // CaseA of the or section + // -or- + // CaseB of the or section + // -or- + // CaseC of the or section + // + // The original comments is for `System.Uri("")` + // By making the assumption that an 'or' section is always single line this allows us the detact the "" section + + // orText is on a single line, we just add quotation syntax + if lastParagraphStartIndex = -1 then + sprintf "> %s" orText + + // orText is on multiple lines + // 1. We first extract the everything until the last line + // 2. We extract on the last line + // 3. We return the start of the section and the end of the section marked using quotation + else + let startText = orText.Substring(0, lastParagraphStartIndex) + let endText = orText.Substring(lastParagraphStartIndex) + + sprintf "%s\n> %s" startText endText + ) + // Force a new `-or-` paragraph between each orSection + // In markdown a new paragraph is define by using 2 empty lines + |> String.concat "\n\n*or*\n\n" + + /// Remove all invalid 'or' block found + /// + /// If an 'or' block is found between 2 elements then we remove it as we can't generate a valid markdown for it + /// + /// For example, Some text -or- another text
", "") .Replace("
", "") .Replace("", "") - .Replace("", (String.replicate rows "| --- ")) + .Replace("", (String.replicate rowCount "| --- ")) .Replace("", nl) .Replace("", "") .Replace("", "") @@ -78,12 +515,265 @@ type private XmlDocMember(doc: XmlDocument, indentationSize : int, columnOffset .Replace("", "") .Replace("", "|") .Replace("", "") - start + nl + nl + nl + nl + table + + nl + nl + convertedTable + nl + |> Some + + } + |> applyFormatter + + type private Term = string + type private Definition = string + + type private ListStyle = + | Bulleted + | Numbered + | Tablered + + /// ItemList allow a permissive representation of an Item. + /// In theory, TermOnly should not exist but we added it so part of the documentation doesn't disappear + /// TODO: Allow direct text support wihtout and tags + type private ItemList = + /// A list where the items are just contains in a element + | DescriptionOnly of string + /// A list where the items are just contains in a element + | TermOnly of string + /// A list where the items are a term followed by a definition (ie in markdown: * - ) + | Definitions of Term * Definition + + let private itemListToStringAsMarkdownList (prefix : string) (item : ItemList) = + match item with + | DescriptionOnly description -> + prefix + " " + description + | TermOnly term -> + prefix + " " + "**" + term + "**" + | Definitions (term, description) -> + prefix + " " + "**" + term + "** - " + description + + let private list = + let getType (attributes : Map) = Map.tryFind "type" attributes + + let tryGetInnerTextOnNonVoidElement (text : string) (tagName : string) = + match Regex.Match(text, tagPattern tagName, RegexOptions.IgnoreCase) with + | m when m.Success -> + if m.Groups.["non_void_element"].Success then + Some m.Groups.["non_void_innerText"].Value + else + None + | _ -> + None + + let tryGetNonVoidElement (text : string) (tagName : string) = + match Regex.Match(text, tagPattern tagName, RegexOptions.IgnoreCase) with + | m when m.Success -> + if m.Groups.["non_void_element"].Success then + Some (m.Groups.["non_void_element"].Value, m.Groups.["non_void_innerText"].Value) + else + None + | _ -> + None + + let tryGetDescription (text : string) = tryGetInnerTextOnNonVoidElement text "description" + + let tryGetTerm (text : string) = tryGetInnerTextOnNonVoidElement text "term" + + let rec extractItemList (res : ItemList list) (text : string) = + match Regex.Match(text, tagPattern "item", RegexOptions.IgnoreCase) with + | m when m.Success -> + let newText = text.Substring(m.Value.Length) + if m.Groups.["non_void_element"].Success then + let innerText = m.Groups.["non_void_innerText"].Value + let description = tryGetDescription innerText + let term = tryGetTerm innerText + + let currentItem : ItemList option = + match description, term with + | Some description, Some term -> + Definitions (term, description) + |> Some + | Some description, None -> + DescriptionOnly description + |> Some + | None, Some term -> + TermOnly term + |> Some + | None, None -> + None + + match currentItem with + | Some currentItem -> + extractItemList (res @ [ currentItem ]) newText + | None -> + extractItemList res newText else - c + extractItemList res newText + | _ -> + res + + let rec extractColumnHeader (res : string list) (text : string) = + match Regex.Match(text, tagPattern "listheader", RegexOptions.IgnoreCase) with + | m when m.Success -> + let newText = text.Substring(m.Value.Length) + if m.Groups.["non_void_element"].Success then + let innerText = m.Groups.["non_void_innerText"].Value + + let rec extractAllTerms (res : string list) (text : string) = + match tryGetNonVoidElement text "term" with + | Some (fullString, innerText) -> + let escapedRegex = Regex(Regex.Escape(fullString)) + let newText = escapedRegex.Replace(text, "", 1) + extractAllTerms (res @ [ innerText ]) newText + | None -> + res + + extractColumnHeader (extractAllTerms [] innerText) newText + else + extractColumnHeader res newText + | _ -> + res + + + let rec extractRowsForTable (res : (string list) list) (text : string) = + match Regex.Match(text, tagPattern "item", RegexOptions.IgnoreCase) with + | m when m.Success -> + let newText = text.Substring(m.Value.Length) + if m.Groups.["non_void_element"].Success then + let innerText = m.Groups.["non_void_innerText"].Value + + let rec extractAllTerms (res : string list) (text : string) = + match tryGetNonVoidElement text "term" with + | Some (fullString, innerText) -> + let escapedRegex = Regex(Regex.Escape(fullString)) + let newText = escapedRegex.Replace(text, "", 1) + extractAllTerms (res @ [ innerText ]) newText + | None -> + res + + extractRowsForTable (res @ [extractAllTerms [] innerText]) newText + else + extractRowsForTable res newText + | _ -> + res + + { + TagName = "list" + Formatter = + function + | VoidElement _ -> + None + + | NonVoidElement (innerText, attributes) -> + let listStyle = + match getType attributes with + | Some "bullet" -> Bulleted + | Some "number" -> Numbered + | Some "table" -> Tablered + | Some _ | None -> Bulleted + + match listStyle with + | Bulleted -> + let items = extractItemList [] innerText + + items + |> List.map (itemListToStringAsMarkdownList "*") + |> String.concat "\n" + + | Numbered -> + let items = extractItemList [] innerText + + items + |> List.map (itemListToStringAsMarkdownList "1.") + |> String.concat "\n" + + | Tablered -> + let columnHeaders = extractColumnHeader [] innerText + let rows = extractRowsForTable [] innerText + + let columnHeadersText = + columnHeaders + |> List.mapi (fun index header -> + if index = 0 then + "| " + header + elif index = columnHeaders.Length - 1 then + " | " + header + " |" + else + " | " + header + ) + |> String.concat "" + let seprator = + columnHeaders + |> List.mapi (fun index _ -> + if index = 0 then + "| ---" + elif index = columnHeaders.Length - 1 then + " | --- |" + else + " | ---" + ) + |> String.concat "" - s.Replace("\r\n", "\n").Split('\n') + let itemsText = + rows + |> List.map (fun columns -> + columns + |> List.mapi (fun index column -> + if index = 0 then + "| " + column + elif index = columnHeaders.Length - 1 then + " | " + column + " |" + else + " | " + column + ) + |> String.concat "" + ) + |> String.concat "\n" + + "\n" + + columnHeadersText + + "\n" + + seprator + + "\n" + + itemsText + |> Some + } + |> applyFormatter + + let applyAll (text : string) = + text + // Remove invalid syntax first + // It's easier to identify invalid patterns when no transformation has been done yet + |> removeInvalidOrBlock + // Start the transformation process + |> paragraph + |> block + |> codeInline + |> codeBlock + |> see + |> xref + |> paramRef + |> typeParamRef + |> anchor + |> list + |> convertTable + |> fixPortableClassLibrary + |> handleMicrosoftOrList + +// TODO: Improve this parser. Is there any other XmlDoc parser available? +type private XmlDocMember(doc: XmlDocument, indentationSize : int, columnOffset : int) = + /// References used to detect if we should remove meaningless spaces + let tabsOffset = String.replicate (columnOffset + indentationSize) " " + let readContentForTooltip (node: XmlNode) = + match node with + | null -> null + | _ -> + let content = + // Normale the EOL + // This make it easier to work with line splittig + node.InnerXml.Replace("\r\n", "\n") + |> Format.applyAll + + content.Split('\n') |> Array.map(fun line -> if not (String.IsNullOrWhiteSpace line) && line.StartsWith(tabsOffset) then line.Substring(columnOffset + indentationSize) @@ -95,7 +785,7 @@ type private XmlDocMember(doc: XmlDocument, indentationSize : int, columnOffset let readChildren name (doc: XmlDocument) = doc.DocumentElement.GetElementsByTagName name |> Seq.cast - |> Seq.map (fun node -> node.Attributes.[0].InnerText.Replace("T:",""), node) + |> Seq.map (fun node -> Format.extractMemberText node.Attributes.[0].InnerText, node) |> Map.ofSeq let readRemarks (doc : XmlDocument) = @@ -103,47 +793,77 @@ type private XmlDocMember(doc: XmlDocument, indentationSize : int, columnOffset |> Seq.cast let rawSummary = doc.DocumentElement.ChildNodes.[0] - let rawPars = readChildren "param" doc + let rawParameters = readChildren "param" doc let rawRemarks = readRemarks doc let rawExceptions = readChildren "exception" doc let rawTypeParams = readChildren "typeparam" doc - let rawReturs = + let rawReturns = doc.DocumentElement.GetElementsByTagName "returns" |> Seq.cast |> Seq.tryHead - + let rawExamples = + doc.DocumentElement.GetElementsByTagName "example" + |> Seq.cast let summary = readContentForTooltip rawSummary - let pars = rawPars |> Map.map (fun _ n -> readContentForTooltip n) + let parameters = rawParameters |> Map.map (fun _ n -> readContentForTooltip n) let remarks = rawRemarks |> Seq.map readContentForTooltip let exceptions = rawExceptions |> Map.map (fun _ n -> readContentForTooltip n) let typeParams = rawTypeParams |> Map.map (fun _ n -> readContentForTooltip n) - let returns = rawReturs |> Option.map readContentForTooltip + let examples = rawExamples |> Seq.map readContentForTooltip + let returns = rawReturns |> Option.map readContentForTooltip + let seeAlso = + doc.DocumentElement.GetElementsByTagName "seealso" + |> Seq.cast + |> Seq.map (fun node -> + "* `" + Format.extractMemberText node.Attributes.[0].InnerText + "`" + ) override x.ToString() = summary + nl + nl + - (pars |> Seq.map (fun kv -> "`" + kv.Key + "`" + ": " + kv.Value) |> String.concat nl) + + (parameters |> Seq.map (fun kv -> "`" + kv.Key + "`" + ": " + kv.Value) |> String.concat nl) + (if exceptions.Count = 0 then "" else nl + nl + "Exceptions:" + nl + (exceptions |> Seq.map (fun kv -> "\t" + "`" + kv.Key + "`" + ": " + kv.Value) |> String.concat nl)) - member __.ToEnhancedString() = - "**Description**" + nl + nl - + summary - + Section.fromList "" remarks - + Section.fromMap "Type parameters" typeParams - + Section.fromMap "Parameters" pars - + Section.fromOption "Returns" returns - + Section.fromMap "Exceptions" exceptions + member __.ToSummaryOnlyString() = + // If we where unable to process the doc comment, then just output it as it is + // For example, this cover the keywords' tooltips + if String.IsNullOrEmpty summary then + doc.InnerText + else + "**Description**" + nl + nl + + summary + + member __.ToFullEnhancedString() = + let content = + summary + + Section.fromList "" remarks + + Section.fromMap "Type parameters" typeParams + + Section.fromMap "Parameters" parameters + + Section.fromOption "Returns" returns + + Section.fromMap "Exceptions" exceptions + + Section.fromList "Examples" examples + + Section.fromList "See also" seeAlso + + // If we where unable to process the doc comment, then just output it as it is + // For example, this cover the keywords' tooltips + if String.IsNullOrEmpty content then + doc.InnerText + else + "**Description**" + nl + nl + + content member __.ToDocumentationString() = "**Description**" + nl + nl + summary + Section.fromList "" remarks + Section.fromMap "Type parameters" typeParams - + Section.fromMap "Parameters" pars + + Section.fromMap "Parameters" parameters + Section.fromOption "Returns" returns + Section.fromMap "Exceptions" exceptions + + Section.fromList "Examples" examples + + Section.fromList "See also" seeAlso let rec private readXmlDoc (reader: XmlReader) (indentationSize : int) (acc: Map) = let acc' = @@ -213,6 +933,7 @@ let private getXmlDoc dllFile = cnt.Replace("

", "").Replace("

", "").Replace("
", "") else cnt + use stringReader = new StringReader(cnt) use reader = XmlReader.Create stringReader let xmlDoc = readXmlDoc reader 0 Map.empty @@ -221,36 +942,85 @@ let private getXmlDoc dllFile = with ex -> None // TODO: Remove the empty map from cache to try again in the next request? +[] +type FormatCommentStyle = + | Legacy + | FullEnhanced + | SummaryOnly + | Documentation + // -------------------------------------------------------------------------------------- // Formatting of tool-tip information displayed in F# IntelliSense // -------------------------------------------------------------------------------------- -let private buildFormatComment cmt (isEnhanced : bool) (typeDoc: string option) = +let private buildFormatComment cmt (formatStyle : FormatCommentStyle) (typeDoc: string option) = match cmt with - | FSharpXmlDoc.Text s -> s + | FSharpXmlDoc.Text s -> + try + // We create a "fake" XML document in order to use the same parser for both libraries and user code + let xml = sprintf "%s" s + let doc = XmlDocument() + doc.LoadXml(xml) + + // This try to mimic how we found the indentation size when working a real XML file + let rec findIndentationSize (lines : string list) = + match lines with + | head::tail -> + let lesserThanIndex = head.IndexOf("<") + if lesserThanIndex <> - 1 then + lesserThanIndex + else + findIndentationSize tail + | [] -> 0 + + let indentationSize = + s.Replace("\r\n", "\n").Split('\n') + |> Array.toList + |> findIndentationSize + + let xmlDoc = XmlDocMember(doc, indentationSize, 0) + + match formatStyle with + | FormatCommentStyle.Legacy -> + xmlDoc.ToString() + | FormatCommentStyle.SummaryOnly -> + xmlDoc.ToSummaryOnlyString() + | FormatCommentStyle.FullEnhanced -> + xmlDoc.ToFullEnhancedString() + | FormatCommentStyle.Documentation -> + xmlDoc.ToDocumentationString() + + with + | ex -> + logger.warn (Log.setMessage "TipFormatter - Error while parsing the doc comment" >> Log.addExn ex) + sprintf "An error occured when parsing the doc comment, please check that your doc comment is valid.\n\nMore info can be found LSP output" + | FSharpXmlDoc.XmlDocFileSignature(dllFile, memberName) -> match getXmlDoc dllFile with | Some doc when doc.ContainsKey memberName -> let typeDoc = match typeDoc with | Some s when doc.ContainsKey s -> - if isEnhanced then doc.[s].ToEnhancedString() else string doc.[s] + match formatStyle with + | FormatCommentStyle.Legacy -> + doc.[s].ToString() + | FormatCommentStyle.SummaryOnly -> + doc.[s].ToSummaryOnlyString() + | FormatCommentStyle.FullEnhanced -> + doc.[s].ToFullEnhancedString() + | FormatCommentStyle.Documentation -> + doc.[s].ToDocumentationString() | _ -> "" - let typeDoc = typeDoc.Replace("**Description**", "**Type Description**") - if isEnhanced then - doc.[memberName].ToEnhancedString() + (if typeDoc <> "" then "\n\n" + typeDoc else "") - else - string doc.[memberName] + (if typeDoc <> "" then "\n\n" + typeDoc else "") - | _ -> "" - | _ -> "" -let private buildFormatDocumentation cmt = - match cmt with - | FSharpXmlDoc.Text s -> s - | FSharpXmlDoc.XmlDocFileSignature(dllFile, memberName) -> - match getXmlDoc dllFile with - | Some doc when doc.ContainsKey memberName -> - doc.[memberName].ToDocumentationString() - | _ -> "" + match formatStyle with + | FormatCommentStyle.Legacy -> + doc.[memberName].ToString() + (if typeDoc <> "" then "\n\n" + typeDoc else "") + | FormatCommentStyle.SummaryOnly -> + doc.[memberName].ToSummaryOnlyString() + (if typeDoc <> "" then "\n\n" + typeDoc else "") + | FormatCommentStyle.FullEnhanced -> + doc.[memberName].ToFullEnhancedString() + (if typeDoc <> "" then "\n\n" + typeDoc else "") + | FormatCommentStyle.Documentation -> + doc.[memberName].ToDocumentationString() + (if typeDoc <> "" then "\n\n" + typeDoc else "") + | _ -> "" | _ -> "" @@ -267,23 +1037,22 @@ let formatTip (FSharpToolTipText tips) : (string * string) list list = |> List.choose (function | FSharpToolTipElement.Group items -> let getRemarks (it : FSharpToolTipElementData) = defaultArg (it.Remarks |> Option.map (fun n -> if String.IsNullOrWhiteSpace n then n else "\n\n" + n)) "" - Some (items |> List.map (fun (it) -> (it.MainDescription + getRemarks it, buildFormatComment it.XmlDoc false None))) + Some (items |> List.map (fun (it) -> (it.MainDescription + getRemarks it, buildFormatComment it.XmlDoc FormatCommentStyle.Legacy None))) | FSharpToolTipElement.CompositionError (error) -> Some [("", error)] | _ -> None) -let formatTipEnhanced (FSharpToolTipText tips) (signature : string) (footer : string) (typeDoc: string option) : (string * string * string) list list = +let formatTipEnhanced (FSharpToolTipText tips) (signature : string) (footer : string) (typeDoc: string option) (formatCommentStyle : FormatCommentStyle) : (string * string * string) list list = tips |> List.choose (function | FSharpToolTipElement.Group items -> Some (items |> List.map (fun i -> let comment = if i.TypeMapping.IsEmpty then - buildFormatComment i.XmlDoc true typeDoc + buildFormatComment i.XmlDoc formatCommentStyle typeDoc else - buildFormatComment i.XmlDoc true typeDoc + buildFormatComment i.XmlDoc formatCommentStyle typeDoc + "\n\n**Generic parameters**\n\n" + (i.TypeMapping |> List.map formatGenericParamInfo |> String.concat "\n") - (signature, comment, footer))) | FSharpToolTipElement.CompositionError (error) -> Some [("", error, "")] | _ -> None) @@ -295,9 +1064,9 @@ let formatDocumentation (FSharpToolTipText tips) ((signature, (constructors, fie Some (items |> List.map (fun i -> let comment = if i.TypeMapping.IsEmpty then - buildFormatComment i.XmlDoc true None + buildFormatComment i.XmlDoc FormatCommentStyle.Documentation None else - buildFormatComment i.XmlDoc true None + buildFormatComment i.XmlDoc FormatCommentStyle.Documentation None + "\n\n**Generic parameters**\n\n" + (i.TypeMapping |> List.map formatGenericParamInfo |> String.concat "\n") @@ -307,7 +1076,7 @@ let formatDocumentation (FSharpToolTipText tips) ((signature, (constructors, fie let formatDocumentationFromXmlSig (xmlSig: string) (assembly: string) ((signature, (constructors, fields, functions, interfaces, attrs, ts)) : string * (string [] * string [] * string [] * string[]* string[]* string[])) (footer : string) (cn: string) = let xmlDoc = FSharpXmlDoc.XmlDocFileSignature(assembly, xmlSig) - let comment = buildFormatComment xmlDoc true None + let comment = buildFormatComment xmlDoc FormatCommentStyle.Documentation None [[(signature, constructors, fields, functions, interfaces, attrs, ts, comment, footer, cn)]] let extractSignature (FSharpToolTipText tips) = diff --git a/src/FsAutoComplete/FsAutoComplete.Lsp.fs b/src/FsAutoComplete/FsAutoComplete.Lsp.fs index fbe2ee78d..f4399c8f5 100644 --- a/src/FsAutoComplete/FsAutoComplete.Lsp.fs +++ b/src/FsAutoComplete/FsAutoComplete.Lsp.fs @@ -747,7 +747,15 @@ type FsharpLspServer(commands: Commands, lspClient: FSharpLspClient) = | CoreResponse.InfoRes msg | CoreResponse.ErrorRes msg -> LspResult.internalError msg | CoreResponse.Res(tip, signature, footer, typeDoc) -> - match TipFormatter.formatTipEnhanced tip signature footer typeDoc with + let formatCommentStyle = + if config.TooltipMode = "full" then + TipFormatter.FormatCommentStyle.FullEnhanced + else if config.TooltipMode = "summary" then + TipFormatter.FormatCommentStyle.SummaryOnly + else + TipFormatter.FormatCommentStyle.Legacy + + match TipFormatter.formatTipEnhanced tip signature footer typeDoc formatCommentStyle with | (sigCommentFooter::_)::_ -> let signature, comment, footer = sigCommentFooter let markStr lang (value:string) = MarkedString.WithLanguage { Language = lang ; Value = value } @@ -767,7 +775,6 @@ type FsharpLspServer(commands: Commands, lspClient: FSharpLspClient) = let commentContent = comment - |> Markdown.createCommentBlock |> MarkedString.String let footerContent = diff --git a/src/FsAutoComplete/LspHelpers.fs b/src/FsAutoComplete/LspHelpers.fs index 8a96dd588..03329c5ef 100644 --- a/src/FsAutoComplete/LspHelpers.fs +++ b/src/FsAutoComplete/LspHelpers.fs @@ -301,111 +301,6 @@ module internal GlyphConversions = | FSharpGlyph.Error | _ -> [||]) -module Markdown = - - open System.Text.RegularExpressions - - let internal stringReplacePatterns = - [ "<", "<" - ">", ">" - """, "\"" - "'", "'" - "&", "&" - "", "**Description**\n\n" - "", "\n" - "", "\n" - "", "\n" - "", "" - "", "\n" ] - - let internal regexReplacePatterns = - let r pat = Regex(pat, RegexOptions.IgnoreCase) - - let code (strings : string array) = - let str = strings.[0] - if str.Contains("\n") then - "```forceNoHighlight" + str + "```" - else - "`" + str + "`" - let returns = Array.item 0 >> sprintf "\n**Returns**\n\n%s" - - let param (s : string[]) = sprintf "* `%s`: %s"(s.[0].Substring(1, s.[0].Length - 2)) s.[1] - - [ r"((?:(?!)(?!<\/c>)[\s\S])*)<\/c>", code - r"""((?:(?!<\/see>)[\s\S])*)<\/see>""", code - r"""((?:(?!<\/param>)[\s\S])*)<\/param>""", param - r"""((?:(?!<\/typeparam>)[\s\S])*)<\/typeparam>""", param - r"""((?:(?!<\/exception>)[\s\S])*)<\/exception>""", param - r"""((?:(?!<\/a>)[\s\S])*)<\/a>""", fun s -> (s.[0].Substring(1, s.[0].Length - 2)) - r"((?:(?!<\/returns>)[\s\S])*)<\/returns>", returns ] - - /// Helpers to create a new section in the markdown comment - let internal suffixXmlKey (tag : string) (value : string) (str : string) = - match str.IndexOf(tag) with - | x when x <> -1 -> - let insertAt = - if str.Chars(x - 1) = ' ' then - x - 1 - else - x - str.Insert(insertAt, value) - | _ -> str - - let internal suffixTypeparam = suffixXmlKey " suffixTypeparam - |> suffixException - |> suffixParam - - let res = - regexReplacePatterns - |> List.fold (fun res (regex : Regex, formatter : string[] -> string) -> - // repeat replacing with same pattern to handle nested tags, like `......` - let rec loop res : string = - match regex.Match res with - | m when m.Success -> - let firstGroup, otherGroups = - m.Groups - |> Seq.cast - |> Seq.map (fun g -> g.Value) - |> Seq.toArray - |> Array.splitAt 1 - loop <| res.Replace(firstGroup.[0], formatter otherGroups) - | _ -> res - loop res - ) str - - stringReplacePatterns - |> List.fold (fun (res : string) (oldValue, newValue) -> - res.Replace(oldValue, newValue) - ) res - - let internal normalizeLeadingSpace (content : string) = - content - .Replace("\r\n", "\n") - .Split('\n') - |> Array.map(fun line -> - if line.Length > 1 && line.[0] = ' ' then - line.[1..] - else - line - ) - |> String.concat "\n" - - let createCommentBlock (comment : string) : string = - comment - |> replaceXml - |> normalizeLeadingSpace - module Workspace = open ProjectSystem.WorkspacePeek open FsAutoComplete.CommandResponse @@ -534,6 +429,7 @@ type FSharpConfigDto = { UseSdkScripts: bool option DotNetRoot: string option FSIExtraParameters: string [] option + TooltipMode : string option } type FSharpConfigRequest = { @@ -567,6 +463,7 @@ type FSharpConfig = { UseSdkScripts: bool DotNetRoot: string FSIExtraParameters: string [] + TooltipMode : string } with static member Default = @@ -600,6 +497,7 @@ with UseSdkScripts = false DotNetRoot = Environment.dotnetSDKRoot.Value FSIExtraParameters = [||] + TooltipMode = "full" } static member FromDto(dto: FSharpConfigDto) = @@ -636,6 +534,7 @@ with |> Option.bind (fun s -> if String.IsNullOrEmpty s then None else Some s) |> Option.defaultValue Environment.dotnetSDKRoot.Value FSIExtraParameters = defaultArg dto.FSIExtraParameters FSharpConfig.Default.FSIExtraParameters + TooltipMode = defaultArg dto.TooltipMode "full" } /// called when a configuration change takes effect, so empty members here should revert options @@ -674,6 +573,7 @@ with |> Option.bind (fun s -> if String.IsNullOrEmpty s then None else Some s) |> Option.defaultValue FSharpConfig.Default.DotNetRoot FSIExtraParameters = defaultArg dto.FSIExtraParameters FSharpConfig.Default.FSIExtraParameters + TooltipMode = defaultArg dto.TooltipMode x.TooltipMode } member x.ScriptTFM = diff --git a/test/FsAutoComplete.Tests.Lsp/Helpers.fs b/test/FsAutoComplete.Tests.Lsp/Helpers.fs index bf8ef6772..85f0c588f 100644 --- a/test/FsAutoComplete.Tests.Lsp/Helpers.fs +++ b/test/FsAutoComplete.Tests.Lsp/Helpers.fs @@ -45,7 +45,8 @@ let defaultConfigDto : FSharpConfigDto = LineLens = None UseSdkScripts = Some true DotNetRoot = None - FSIExtraParameters = None } + FSIExtraParameters = None + TooltipMode = None } let clientCaps : ClientCapabilities = let dynCaps : DynamicCapabilities = { DynamicRegistration = Some true}