Skip to content

Commit c8dc95c

Browse files
authored
Add automated XML documentation validation test for FSharp.Core .fsi files (#18789)
1 parent 4410428 commit c8dc95c

File tree

2 files changed

+111
-0
lines changed

2 files changed

+111
-0
lines changed

tests/FSharp.Core.UnitTests/FSharp.Core.UnitTests.fsproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
<Compile Include="FSharp.Core\Microsoft.FSharp.Control\EventModule.fs" />
8989
<Compile Include="FSharp.Core\Microsoft.FSharp.Reflection\FSharpReflection.fs" />
9090
<Compile Include="FSharp.Core\Microsoft.FSharp.Quotations\FSharpQuotations.fs" />
91+
<Compile Include="FSharp.Core\XmlDocumentationValidation.fs" />
9192
<Compile Include="Interop\CSharpCollectionExpressions.fs" />
9293
<Compile Include="StructTuples.fs" />
9394
<Compile Include="SurfaceArea.fs" />
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.
2+
3+
module FSharp.Core.UnitTests.XmlDocumentationValidation
4+
5+
open System
6+
open System.IO
7+
open System.Text.RegularExpressions
8+
open System.Xml
9+
open Xunit
10+
11+
/// Extracts XML documentation blocks from F# signature files
12+
let extractXmlDocBlocks (content: string) =
13+
// Regex to match XML documentation comments (/// followed by XML content)
14+
let xmlDocPattern = @"^\s*///\s*(.*)$"
15+
let regex = Regex(xmlDocPattern, RegexOptions.Multiline)
16+
17+
let lines = content.Split([|'\n'; '\r'|], StringSplitOptions.RemoveEmptyEntries)
18+
let mutable xmlBlocks = []
19+
let mutable currentBlock = []
20+
let mutable lineNumber = 0
21+
22+
for line in lines do
23+
lineNumber <- lineNumber + 1
24+
let trimmedLine = line.Trim()
25+
if trimmedLine.StartsWith("///") then
26+
let xmlContent = trimmedLine.Substring(3).Trim()
27+
currentBlock <- (xmlContent, lineNumber) :: currentBlock
28+
else
29+
if not (List.isEmpty currentBlock) then
30+
xmlBlocks <- List.rev currentBlock :: xmlBlocks
31+
currentBlock <- []
32+
33+
// Don't forget the last block if file ends with XML comments
34+
if not (List.isEmpty currentBlock) then
35+
xmlBlocks <- List.rev currentBlock :: xmlBlocks
36+
37+
List.rev xmlBlocks
38+
39+
/// Validates that XML content is well-formed
40+
let validateXmlBlock (xmlLines: (string * int) list) =
41+
if List.isEmpty xmlLines then
42+
Ok ()
43+
else
44+
let xmlContent = xmlLines |> List.map fst |> String.concat "\n"
45+
let firstLineNumber = xmlLines |> List.head |> snd
46+
47+
// Skip empty or whitespace-only blocks
48+
if String.IsNullOrWhiteSpace(xmlContent) then
49+
Ok ()
50+
else
51+
try
52+
// Wrap content in a root element to make it valid XML document
53+
let wrappedXml = sprintf "<root>%s</root>" xmlContent
54+
let doc = XmlDocument()
55+
doc.LoadXml(wrappedXml)
56+
Ok ()
57+
with
58+
| :? XmlException as ex ->
59+
Error (sprintf "Line %d: Invalid XML - %s" firstLineNumber ex.Message)
60+
| ex ->
61+
Error (sprintf "Line %d: XML parsing error - %s" firstLineNumber ex.Message)
62+
63+
/// Gets all .fsi files in FSharp.Core directory
64+
let getFSharpCoreFsiFiles () =
65+
let coreDir = Path.Combine(__SOURCE_DIRECTORY__, "..", "..", "..", "src", "FSharp.Core")
66+
let fullPath = Path.GetFullPath(coreDir)
67+
if Directory.Exists(fullPath) then
68+
Directory.GetFiles(fullPath, "*.fsi", SearchOption.AllDirectories)
69+
|> Array.toList
70+
else
71+
[]
72+
73+
[<Fact>]
74+
let ``XML documentation in FSharp.Core fsi files should be well-formed`` () =
75+
let fsiFiles = getFSharpCoreFsiFiles()
76+
77+
Assert.False(List.isEmpty fsiFiles, "No .fsi files found in FSharp.Core directory")
78+
79+
let mutable errors = []
80+
let mutable totalBlocks = 0
81+
82+
for fsiFile in fsiFiles do
83+
let relativePath = Path.GetFileName(fsiFile)
84+
try
85+
let content = File.ReadAllText(fsiFile)
86+
let xmlBlocks = extractXmlDocBlocks content
87+
88+
for xmlBlock in xmlBlocks do
89+
totalBlocks <- totalBlocks + 1
90+
match validateXmlBlock xmlBlock with
91+
| Ok () -> ()
92+
| Error errorMsg ->
93+
let error = sprintf "%s: %s" relativePath errorMsg
94+
errors <- error :: errors
95+
with
96+
| ex ->
97+
let error = sprintf "%s: Failed to read file - %s" relativePath ex.Message
98+
errors <- error :: errors
99+
100+
// Report statistics
101+
let validBlocks = totalBlocks - List.length errors
102+
let message = sprintf "Validated %d XML documentation blocks in %d .fsi files. %d valid, %d invalid."
103+
totalBlocks (List.length fsiFiles) validBlocks (List.length errors)
104+
105+
if not (List.isEmpty errors) then
106+
let errorDetails = errors |> List.rev |> String.concat "\n"
107+
Assert.Fail(sprintf "%s\n\nErrors:\n%s" message errorDetails)
108+
else
109+
// This will show in test output for successful runs
110+
Assert.True(true, message)

0 commit comments

Comments
 (0)