Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
path: root/Documents/api/CheckDocumentation.cs
blob: f6b2da8cee69cc6ccda58654e7deb5c3e564e09a (plain)
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
// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1

// Documentation coverage gate of the API reference.
//
// DocFX builds a reference from whatever comments exist and does not report a declaration that has none. This
// program reads the metadata DocFX generated and fails when a namespace, type or member is undocumented, so the
// reference cannot contain an entry without a description.
//
// Usage, after `dotnet docfx metadata Documents/api/docfx.json`:
//
//     dotnet run Documents/api/CheckDocumentation.cs -- Documents/api
//
// The exit code is 0 when everything is documented, 1 when something is not, and 2 when the metadata is missing.

using System.Text.RegularExpressions;

string root = args.Length > 0 ? args[0] : "Documents/api";
string referenceDirectory = Path.Combine(root, "reference");
string namespaceDirectory = Path.Combine(root, "namespaces");

if (!Directory.Exists(referenceDirectory))
{
    Console.Error.WriteLine($"No metadata in '{referenceDirectory}'. Run 'dotnet docfx metadata' first.");
    return 2;
}

// A namespace has no declaration that could carry a comment. Its description is an overwrite file named after it.
HashSet<string> describedNamespaces = Directory.Exists(namespaceDirectory)
    ? Directory.EnumerateFiles(namespaceDirectory, "*.md")
        .Where(HasDescription)
        .Select(path => Path.GetFileNameWithoutExtension(path)!)
        .ToHashSet(StringComparer.Ordinal)
    : [];

List<string> undocumented = [];
int checkedItems = 0;

foreach (string file in Directory.EnumerateFiles(referenceDirectory, "*.yml").Order(StringComparer.Ordinal))
{
    if (Path.GetFileName(file) == "toc.yml")
    {
        continue;
    }

    // The items a file defines come first; the `references` section after them repeats names of other files.
    string text = File.ReadAllText(file).ReplaceLineEndings("\n");
    int references = text.IndexOf("\nreferences:", StringComparison.Ordinal);
    string definitions = references < 0 ? text : text[..references];

    foreach (string item in ItemStart().Split(definitions).Skip(1))
    {
        string uid = item[..item.IndexOf('\n')].Trim();
        string type = ItemType().Match(item) is { Success: true } match ? match.Groups[1].Value : "Item";
        checkedItems++;

        bool documented = type == "Namespace"
            ? describedNamespaces.Contains(uid)
            : ItemSummary().IsMatch(item);
        if (!documented)
        {
            undocumented.Add($"{type} {uid}");
        }
    }
}

if (checkedItems == 0)
{
    Console.Error.WriteLine($"The metadata in '{referenceDirectory}' defines nothing; the gate would pass vacuously.");
    return 2;
}

if (undocumented.Count != 0)
{
    Console.Error.WriteLine($"{undocumented.Count} of {checkedItems} API items have no documentation:");
    foreach (string entry in undocumented)
    {
        Console.Error.WriteLine($"  {entry}");
    }

    Console.Error.WriteLine("Add a <summary> to each declaration, or a file in Documents/api/namespaces for a namespace.");
    return 1;
}

Console.WriteLine($"All {checkedItems} API items are documented.");
return 0;

// An overwrite file describes its namespace when it has text after its front matter and comment header.
static bool HasDescription(string path)
{
    string text = File.ReadAllText(path).ReplaceLineEndings("\n");
    int frontMatterEnd = text.IndexOf("\n---", 3, StringComparison.Ordinal);
    if (!text.StartsWith("---\n", StringComparison.Ordinal) || frontMatterEnd < 0)
    {
        return false;
    }

    string body = HtmlComment().Replace(text[(frontMatterEnd + 4)..], string.Empty);
    return text[..frontMatterEnd].Contains($"uid: {Path.GetFileNameWithoutExtension(path)}", StringComparison.Ordinal)
        && !string.IsNullOrWhiteSpace(body);
}

internal static partial class Program
{
    // Every defined item starts with its identifier at the left margin.
    [GeneratedRegex(@"^- uid: ", RegexOptions.Multiline)]
    private static partial Regex ItemStart();

    [GeneratedRegex(@"^  type: (\w+)", RegexOptions.Multiline)]
    private static partial Regex ItemType();

    // A summary with content: text on the same line, or a block scalar followed by an indented line.
    [GeneratedRegex(@"^  summary: (?:[^\s>|].*|[>|][-+]?\n    \S)", RegexOptions.Multiline)]
    private static partial Regex ItemSummary();

    [GeneratedRegex(@"<!--.*?-->", RegexOptions.Singleline)]
    private static partial Regex HtmlComment();
}