-
Notifications
You must be signed in to change notification settings - Fork 156
/
TipFormatter.fs
1358 lines (1140 loc) · 46.5 KB
/
TipFormatter.fs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// --------------------------------------------------------------------------------------
// (c) Tomas Petricek, http://tomasp.net/blog
// --------------------------------------------------------------------------------------
module FsAutoComplete.TipFormatter
open System
open System.IO
open System.Xml
open System.Collections.Generic
open System.Text.RegularExpressions
open FSharp.Compiler.EditorServices
open FSharp.Compiler.Symbols
open FsAutoComplete.Logging
open FSharp.Compiler.Text
open Ionide.LanguageServerProtocol.Types
let inline nl<'T> = Environment.NewLine
let logger = LogProvider.getLoggerByName "TipFormatter"
module private Section =
let inline addSection (name: string) (content: string) =
if name <> "" then
nl + nl + "**" + name + "**" + nl + nl + content
else
nl + nl + content
let fromKeyValueList (name: string) (content: list<KeyValuePair<string, string>>) =
if List.isEmpty content then
""
else
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 Environment.NewLine
|> (+) 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 "" else addSection name content.Value
let fromList (name: string) (content: string seq) =
if Seq.isEmpty content then
""
else
addSection name (content |> String.concat nl)
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<string, string>
| NonVoidElement of innerText: string * attributes: Map<string, string>
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<Match>
|> Seq.map (fun m -> m.Groups.["key"].Value, extractTextFromQuote m.Groups.["value"].Value)
|> Map.ofSeq
else
Map.empty
type AttrLookup = Map<string, string> -> Option<string>
let private cref: AttrLookup = Map.tryFind "cref"
let private langword: AttrLookup = Map.tryFind "langword"
let private href: AttrLookup = Map.tryFind "href"
let private lang: AttrLookup = Map.tryFind "lang"
let private name: AttrLookup = Map.tryFind "name"
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 happened but like that we are sure to handle all possible cases
text
| _ -> text
let private codeBlock =
{ TagName = "code"
Formatter =
function
| VoidElement _ -> None
| NonVoidElement(innerText, attributes) ->
let lang =
match lang attributes with
| Some lang -> lang
| None -> "forceNoHighlight"
// We need to trim the end of the text because the
// user write XML comments with a space between the '///'
// and the '<code>' tag. Then it mess up identification of new lines
// at the end of the code snippet.
// Example:
// /// <code>
// /// var x = 1;
// /// </code>
// ^ This space is the one we need to remove
let innerText = innerText.TrimEnd()
// Try to detect how the code snippet is formatted
// so render the markdown code block the best way
// by avoid empty lines at the beginning or the end
let formattedText =
match
innerText.StartsWith("\n", StringComparison.Ordinal), innerText.EndsWith("\n", StringComparison.Ordinal)
with
| true, true -> sprintf "```%s%s```" lang innerText
| true, false -> sprintf "```%s%s\n```" lang innerText
| false, true -> sprintf "```%s\n%s```" lang innerText
| false, false -> sprintf "```%s\n%s\n```" lang innerText
Some formattedText
}
|> applyFormatter
let private example =
{ TagName = "example"
Formatter =
function
| VoidElement _ -> None
| NonVoidElement(innerText, _) ->
let formattedText =
nl
+ nl
// This try to keep a visual consistency and indicate that this
// "Example section" is part of it parent section (summary, remarks, etc.)
+ """Example:"""
+ nl
+ nl
+ innerText
Some formattedText
}
|> applyFormatter
let private codeInline =
{ TagName = "c"
Formatter =
function
| VoidElement _ -> None
| NonVoidElement(innerText, _) -> "`" + innerText + "`" |> Some }
|> applyFormatter
let private link text uri = $"[`%s{text}`](%s{uri})"
let private code text = $"`%s{text}`"
let private anchor =
{ TagName = "a"
Formatter =
function
| VoidElement attributes ->
match href attributes with
| Some href -> Some(link href href)
| None -> None
| NonVoidElement(innerText, attributes) ->
match href attributes with
| Some href -> Some(link innerText href)
| None -> Some(code innerText) }
|> 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 formatFromAttributes (attrs: Map<string, string>) =
match cref attrs with
// crefs can have backticks in them, which mess with formatting.
// for safety we can just double-backtick and markdown is ok with that.
| Some cref -> Some $"``{extractMemberText cref}``"
| None ->
match langword attrs with
| Some langword -> Some(code langword)
| None -> None
{ TagName = "see"
Formatter =
function
| VoidElement attributes -> formatFromAttributes attributes
| NonVoidElement(innerText, attributes) ->
if String.IsNullOrWhiteSpace innerText then
formatFromAttributes attributes
else
match href attributes with
| Some externalUrl -> Some(link innerText externalUrl)
| None -> Some $"`{innerText}`" }
|> applyFormatter
let private xref =
{ TagName = "xref"
Formatter =
function
| VoidElement attributes ->
match href attributes with
| Some href -> Some(link href href)
| None -> None
| NonVoidElement(innerText, attributes) ->
if String.IsNullOrWhiteSpace innerText then
match href attributes with
| Some href -> Some(link innerText href)
| None -> None
else
Some(code innerText) }
|> applyFormatter
let private paramRef =
{ TagName = "paramref"
Formatter =
function
| VoidElement attributes ->
match name attributes with
| Some name -> Some(code name)
| None -> None
| NonVoidElement(innerText, attributes) ->
if String.IsNullOrWhiteSpace innerText then
match name attributes with
| Some name ->
// TODO: Add config to generates command
Some(code name)
| None -> None
else
Some(code innerText)
}
|> applyFormatter
let private typeParamRef =
{ TagName = "typeparamref"
Formatter =
function
| VoidElement attributes ->
match name attributes with
| Some name -> Some(code name)
| None -> None
| NonVoidElement(innerText, attributes) ->
if String.IsNullOrWhiteSpace innerText then
match name attributes with
| Some name ->
// TODO: Add config to generates command
Some(code name)
| None -> None
else
Some(code innerText) }
|> 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"
)
/// <summary>Handle Microsoft 'or' formatting blocks</summary>
/// <remarks>
/// <para>We don't use the formatter API here because we are not handling a "real XML element"</para>
/// <para>We don't use regex neither because I am not able to create one covering all the possible case</para>
/// <para>
/// There are 2 types of 'or' blocks:
///
/// - Inlined: [...] -or- [...] -or- [...]
/// - Blocked:
/// [...]
/// -or-
/// [...]
/// -or-
/// [...]
/// </para>
/// <para>
/// This function can convert both styles. If an 'or' block is encounter the whole section will always result in a multiline output
/// </para>
/// <para>
/// 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:
/// </para>
/// <para>
/// > [...]
///
/// *or*
///
/// > [...]
///
/// *or*
///
/// > [...]
/// </para>
/// </remarks>
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:
//
// <block>
// Some text goes here
// </block>
// 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 to detect the "<block></block>" 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"
/// <summary>Remove all invalid 'or' block found</summary>
/// <remarks>
/// 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, <td> Some text -or- another text </td> cannot be converted into a multiline string
/// and so we prefer to remove the 'or' block instead of having some weird markdown artifacts
///
/// For now, we only consider text between <td></td> 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
/// </remarks>
let private removeInvalidOrBlock (text: string) =
let invalidOrBlockPattern =
"""<td(\s+[^>])*>(?'or_text'(?:(?!<td)[\s\S])*-or-(?:(?!<\/td)[\s\S])*)<\/td(\s+[^>])*>"""
Regex.Matches(text, invalidOrBlockPattern, RegexOptions.Multiline)
|> Seq.cast<Match>
|> 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, "<th\s?>").Count
let convertedTable =
innerText
.Replace(nl, "")
.Replace("\n", "")
.Replace("<table>", "")
.Replace("</table>", "")
.Replace("<thead>", "")
.Replace("</thead>", (String.replicate rowCount "| --- "))
.Replace("<tbody>", nl)
.Replace("</tbody>", "")
.Replace("<tr>", "")
.Replace("</tr>", "|" + nl)
.Replace("<th>", "|")
.Replace("</th>", "")
.Replace("<td>", "|")
.Replace("</td>", "")
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 without <description> and <term> tags
type private ItemList =
/// A list where the items are just contains in a <description> element
| DescriptionOnly of string
/// A list where the items are just contains in a <term> element
| TermOnly of string
/// A list where the items are a term followed by a definition (ie in markdown: * <TERM> - <DEFINITION>)
| 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<string, string>) = 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
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 Environment.NewLine
| Numbered ->
let items = extractItemList [] innerText
items
|> List.map (itemListToStringAsMarkdownList "1.")
|> String.concat Environment.NewLine
| 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 separator =
columnHeaders
|> List.mapi (fun index _ ->
if index = 0 then "| ---"
elif index = columnHeaders.Length - 1 then " | --- |"
else " | ---")
|> String.concat ""
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 Environment.NewLine
Environment.NewLine
+ columnHeadersText
+ Environment.NewLine
+ separator
+ Environment.NewLine
+ itemsText)
|> Some }
|> applyFormatter
/// <summary>
/// Unescape XML special characters
///
/// For example, this allows to print '>' in the tooltip instead of '>'
/// </summary>
let private unescapeSpecialCharacters (text: string) =
text
.Replace("<", "<")
.Replace(">", ">")
.Replace(""", "\"")
.Replace("'", "'")
.Replace("&", "&")
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
|> example
|> block
|> codeInline
|> codeBlock
|> see
|> xref
|> paramRef
|> typeParamRef
|> anchor
|> list
|> convertTable
|> fixPortableClassLibrary
|> handleMicrosoftOrList
|> unescapeSpecialCharacters
[<RequireQualifiedAccess>]
type FormatCommentStyle =
| Legacy
| FullEnhanced
| SummaryOnly
| Documentation
// 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 splitting
node.InnerXml.Replace("\r\n", "\n") |> Format.applyAll
content.Split('\n')
|> Array.map (fun line ->
if
not (String.IsNullOrWhiteSpace line)
&& line.StartsWith(tabsOffset, StringComparison.Ordinal)
then
line.Substring(columnOffset + indentationSize)
else
line)
|> String.concat Environment.NewLine
let readChildren name (doc: XmlDocument) =
doc.DocumentElement.GetElementsByTagName name
|> Seq.cast<XmlNode>
|> Seq.map (fun node -> Format.extractMemberText node.Attributes.[0].InnerText, node)
|> Seq.toList
let readRemarks (doc: XmlDocument) = doc.DocumentElement.GetElementsByTagName "remarks" |> Seq.cast<XmlNode>
let rawSummary = doc.DocumentElement.ChildNodes.[0]
let rawParameters = readChildren "param" doc
let rawRemarks = readRemarks doc
let rawExceptions = readChildren "exception" doc
let rawTypeParams = readChildren "typeparam" doc
let rawReturns =
doc.DocumentElement.GetElementsByTagName "returns"
|> Seq.cast<XmlNode>
|> Seq.tryHead
let rawExamples =
doc.DocumentElement.GetElementsByTagName "example"
|> Seq.cast<XmlNode>
// We need to filter out the examples node that are children
// of another "main" node
// This is because if the example node is inside a "main" node
// then we render it in place.
// So we don't need to render it independently in the Examples section
|> Seq.filter (fun node ->
[ "summary"; "param"; "returns"; "exception"; "remarks"; "typeparam" ]
|> List.contains node.ParentNode.Name
|> not)
let readNamedContentAsKvPair (key, content) = KeyValuePair(key, readContentForTooltip content)
let summary = readContentForTooltip rawSummary
let parameters = rawParameters |> List.map readNamedContentAsKvPair
let remarks = rawRemarks |> Seq.map readContentForTooltip
let exceptions = rawExceptions |> List.map readNamedContentAsKvPair
let typeParams = rawTypeParams |> List.map readNamedContentAsKvPair
let examples = rawExamples |> Seq.map readContentForTooltip
let returns = rawReturns |> Option.map readContentForTooltip
let seeAlso =
doc.DocumentElement.GetElementsByTagName "seealso"
|> Seq.cast<XmlNode>
|> Seq.map (fun node -> "* `" + Format.extractMemberText node.Attributes.[0].InnerText + "`")
override x.ToString() =
summary
+ nl
+ nl
+ (parameters
|> Seq.map (fun kv -> "`" + kv.Key + "`" + ": " + kv.Value)
|> String.concat nl)
+ (if exceptions.Length = 0 then
""
else
nl
+ nl
+ "Exceptions:"
+ nl
+ (exceptions
|> Seq.map (fun kv -> "\t" + "`" + kv.Key + "`" + ": " + kv.Value)
|> String.concat nl))
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 __.HasTruncatedExamples = examples |> Seq.isEmpty |> not
member __.ToFullEnhancedString() =
let content =
summary
+ Section.fromList "Remarks" remarks
+ Section.fromKeyValueList "Type parameters" typeParams
+ Section.fromKeyValueList "Parameters" parameters
+ Section.fromOption "Returns" returns
+ Section.fromKeyValueList "Exceptions" exceptions
+ 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" remarks
+ Section.fromKeyValueList "Type parameters" typeParams
+ Section.fromKeyValueList "Parameters" parameters
+ Section.fromOption "Returns" returns
+ Section.fromKeyValueList "Exceptions" exceptions
+ Section.fromList "Examples" examples
+ Section.fromList "See also" seeAlso
member this.FormatComment(formatStyle: FormatCommentStyle) =
match formatStyle with
| FormatCommentStyle.Legacy -> this.ToString()
| FormatCommentStyle.SummaryOnly -> this.ToSummaryOnlyString()
| FormatCommentStyle.FullEnhanced -> this.ToFullEnhancedString()
| FormatCommentStyle.Documentation -> this.ToDocumentationString()
let rec private readXmlDoc (reader: XmlReader) (indentationSize: int) (acc: Map<string, XmlDocMember>) =
let acc' =
match reader.Read() with
| false -> indentationSize, None
// Assembly is the first node in the XML and is at least always intended by 1 "tab"
// So we used it as a reference to detect the tabs sizes
// This is needed because `netstandard.xml` use 2 spaces tabs
// Where when building a C# classlib, the xml file use 4 spaces size for example
| true when reader.Name = "assembly" && reader.NodeType = XmlNodeType.Element ->
let xli: IXmlLineInfo = (box reader) :?> IXmlLineInfo
// - 2 : allow us to detect the position before the < char
xli.LinePosition - 2, Some acc
| true when reader.Name = "member" && reader.NodeType = XmlNodeType.Element ->
try
// We detect the member LinePosition so we can calculate the meaningless spaces later
let xli: IXmlLineInfo = (box reader) :?> IXmlLineInfo
let key = reader.GetAttribute("name")
use subReader = reader.ReadSubtree()
let doc = XmlDocument()
doc.Load(subReader)
// - 3 : allow us to detect the last indentation position
// This isn't intuitive but from my tests this is what works
indentationSize,
acc
|> Map.add key (XmlDocMember(doc, indentationSize, xli.LinePosition - 3))
|> Some
with ex ->
indentationSize, Some acc
| _ -> indentationSize, Some acc
match acc' with
| _, None -> acc
| indentationSize, Some acc' -> readXmlDoc reader indentationSize acc'
let private xmlDocCache =
Collections.Concurrent.ConcurrentDictionary<string, Map<string, XmlDocMember>>()
let private findCultures v =
let rec loop state (v: System.Globalization.CultureInfo) =
let state' = v.Name :: state
if v.Parent = System.Globalization.CultureInfo.InvariantCulture then
"" :: state' |> List.rev
else
loop state' v.Parent
loop [] v
let private findLocalizedXmlFile (xmlFile: string) =
let xmlName = Path.GetFileName xmlFile
let path = Path.GetDirectoryName xmlFile
findCultures System.Globalization.CultureInfo.CurrentUICulture
|> List.map (fun culture -> Path.Combine(path, culture, xmlName))
|> List.tryFind (fun i -> File.Exists i)
|> Option.defaultValue xmlFile
let private getXmlDoc dllFile =
let xmlFile = Path.ChangeExtension(dllFile, ".xml")
//Workaround for netstandard.dll
let xmlFile =
if
xmlFile.Contains "packages"
&& xmlFile.Contains "netstandard.library"
&& xmlFile.Contains "netstandard2.0"
then
Path.Combine(Path.GetDirectoryName(xmlFile), "netstandard.xml")
else
xmlFile
let xmlFile = findLocalizedXmlFile xmlFile
if xmlDocCache.ContainsKey xmlFile then
Some xmlDocCache.[xmlFile]
else
let rec exists filePath tryAgain =
match File.Exists filePath, tryAgain with
| true, _ -> Some filePath
| false, false -> None
| false, true ->
// In Linux, we need to check for upper case extension separately
let filePath = Path.ChangeExtension(filePath, Path.GetExtension(filePath).ToUpper())
exists filePath false
match exists xmlFile true with
| None -> None
| Some actualXmlFile ->
// Prevent other threads from tying to add the same doc simultaneously
xmlDocCache.AddOrUpdate(xmlFile, Map.empty, (fun _ _ -> Map.empty)) |> ignore
try
let cnt = File.ReadAllText actualXmlFile
//Workaround for netstandard xmlDoc
let cnt =
if actualXmlFile.Contains "netstandard.xml" then
let cnt = Regex.Replace(cnt, """(<p .*?>)+(.*)(<\/?p>)*""", "$2")
cnt.Replace("<p>", "").Replace("</p>", "").Replace("<br>", "")
else
cnt
use stringReader = new StringReader(cnt)
use reader = XmlReader.Create stringReader
let xmlDoc = readXmlDoc reader 0 Map.empty
xmlDocCache.AddOrUpdate(xmlFile, xmlDoc, (fun _ _ -> xmlDoc)) |> ignore
Some xmlDoc
with _ ->
None // TODO: Remove the empty map from cache to try again in the next request?
// --------------------------------------------------------------------------------------
// Formatting of tool-tip information displayed in F# IntelliSense
// --------------------------------------------------------------------------------------
[<RequireQualifiedAccess>]
type private TryGetXmlDocMemberResult =
| Some of XmlDocMember
| None
| Error
[<RequireQualifiedAccess>]
type TipFormatterResult<'T> =
| Success of 'T
| Error of string
| None
let private tryGetXmlDocMember (xmlDoc: FSharpXmlDoc) =
try
match xmlDoc with
| FSharpXmlDoc.FromXmlText xmldoc ->
let document = xmldoc.GetXmlText()
// We create a "fake" XML document in order to use the same parser for both libraries and user code
let xml = sprintf "<fake>%s</fake>" document
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) =