Skip to content
This repository has been archived by the owner on Jan 12, 2024. It is now read-only.

Add API documentation linting to docgen step #1045

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.Quantum.Documentation.Linting;
using Microsoft.Quantum.QsCompiler;
using Microsoft.Quantum.QsCompiler.SyntaxTree;
using QsAssemblyConstants = Microsoft.Quantum.QsCompiler.ReservedKeywords.AssemblyConstants;
Expand All @@ -16,6 +19,17 @@ namespace Microsoft.Quantum.Documentation
/// </summary>
public class DocumentationGeneration : IRewriteStep
{
private static readonly ImmutableDictionary<string, (bool EnableByDefault, IDocumentationLintingRule Rule)> LintingRules;

static DocumentationGeneration()
{
var rules = ImmutableDictionary.CreateBuilder<string, (bool EnableByDefault, IDocumentationLintingRule Rule)>();
rules.Add("require-correct-input-names", (true, new RequireCorrectInputNames()));
rules.Add("require-examples", (EnableByDefault: false, new RequireExamplesOnPublicDeclarations()));
rules.Add("no-math-in-summary", (true, new NoMathInSummary()));
LintingRules = rules.ToImmutableDictionary();
}

private string docsOutputPath = "";
private readonly List<IRewriteStep.Diagnostic> diagnostics;

Expand Down Expand Up @@ -92,11 +106,86 @@ public bool PreconditionVerification(QsCompilation compilation)
/// <inheritdoc/>
public bool Transformation(QsCompilation compilation, out QsCompilation transformed)
{
// Find a list of linting rules to be enabled and disabled by
// by looking at the relevant assembly constant.
// We expect linting rule configurations to be formatted as a comma-separated
// list of rules, each one prefaced with either "ignore:", "warn:"
// or "error:", indicating the level of severity for each.
var lintingRulesConfig = (
this.AssemblyConstants
.TryGetValue(QsAssemblyConstants.DocsLintingRules, out var config)
? config ?? ""
: "")
.Split(",")
.Where(rule => !string.IsNullOrWhiteSpace(rule))
.Select(rule =>
{
var ruleParts = rule.Split(":", 2);
if (ruleParts.Length != 2)
{
throw new Exception($"Error parsing documentation linting rule specification \"{rule}\"; expected a specification of the form \"severity:rule-name\".");
}

return (severity: ruleParts[0], ruleName: ruleParts[1]);
})
.ToDictionary(
config => config.ruleName,
config => config.severity);

// If any rules were specified that aren't present, warn about that
// now.
foreach ((var ruleName, var severity) in lintingRulesConfig)
{
if (!LintingRules.ContainsKey(ruleName))
{
this.diagnostics.Add(new IRewriteStep.Diagnostic
{
Severity = DiagnosticSeverity.Info,
Message = $"Documentation linting rule {ruleName} was set to {severity}, but no such linting rule exists.",
Stage = IRewriteStep.Stage.Transformation,
});
}
}

// Actually populate the rules now.
var lintingRules = LintingRules
.Select(
rule => (
Name: rule.Key,
Severity:
(
rule.Value.EnableByDefault,
lintingRulesConfig.TryGetValue(rule.Key, out var severity) ? severity : null)
switch
{
// We handle should happen when the user didn't
// override here.
(true, null) => DiagnosticSeverity.Warning,
(false, null) => DiagnosticSeverity.Hidden,

// If the user did override, we can ignore
// EnableByDefault.
(_, "ignore") => DiagnosticSeverity.Hidden,
(_, "warning") => DiagnosticSeverity.Warning,
(_, "error") => DiagnosticSeverity.Error,

// If we get down to this point, something went
// wrong; the given severity wasn't recognized.
(_, var unknown) => throw new Exception(
$"Documentation linting severity for rule {rule.Key} was set to {unknown}, but expected one of \"error\", \"warning\", or \"ignore\""),
},
Rule: rule.Value.Rule))
.Where(rule => rule.Severity != DiagnosticSeverity.Hidden)
.ToDictionary(
rule => rule.Name,
rule => (rule.Severity, rule.Rule));

var docProcessor = new ProcessDocComments(
this.docsOutputPath,
this.AssemblyConstants.TryGetValue(QsAssemblyConstants.DocsPackageId, out var packageName)
? packageName
: null);
? packageName
: null,
lintingRules);

docProcessor.OnDiagnostic += diagnostic =>
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.Quantum.QsCompiler;
using Microsoft.Quantum.QsCompiler.Documentation;
using Microsoft.Quantum.QsCompiler.SyntaxTree;
using Range = Microsoft.Quantum.QsCompiler.DataTypes.Range;

namespace Microsoft.Quantum.Documentation.Linting
{
internal static class LintingExtensions
{
internal static void InvokeRules(
this IDictionary<string, (DiagnosticSeverity, IDocumentationLintingRule)> rules,
Func<IDocumentationLintingRule, IEnumerable<LintingMessage>> invokeRule,
Action<IRewriteStep.Diagnostic> onDiagnostic)
{
foreach ((var lintName, (var severity, var lintRule)) in rules)
{
foreach (var raisedDiagnostic in invokeRule(lintRule))
{
onDiagnostic(
raisedDiagnostic.AsDiagnostic(severity, lintName));
}
}
}
}

public class LintingMessage
{
public string? Message { get; set; }

public Range? Range { get; set; }

public string? Source { get; set; }

public IRewriteStep.Diagnostic AsDiagnostic(
DiagnosticSeverity severity = DiagnosticSeverity.Warning,
string? ruleName = null)
=> new IRewriteStep.Diagnostic
{
Message = $"{(ruleName == null ? "" : $"({ruleName}) ")}{this.Message}",
Range = this.Range,
Severity = severity,
Stage = IRewriteStep.Stage.Transformation,
Source = this.Source,
};
}

public interface IDocumentationLintingRule
{
IEnumerable<LintingMessage> OnTypeDeclaration(QsCustomType type, DocComment comment)
{
yield break;
}

IEnumerable<LintingMessage> OnCallableDeclaration(QsCallable callable, DocComment comment)
{
yield break;
}
}

public class RequireExamplesOnPublicDeclarations : IDocumentationLintingRule
{
public IEnumerable<LintingMessage> OnCallableDeclaration(QsCallable callable, DocComment comment)
{
if (!callable.Access.IsPublic)
{
yield break;
}

if (comment.Examples.IsEmpty)
{
yield return new LintingMessage
{
Message = $"Public callable {callable.FullName} does not have any examples.",
Source = callable.Source.AssemblyOrCodeFile,
Range = null, // TODO: provide more exact locations once supported by DocParser.
};
}
}

public IEnumerable<LintingMessage> OnTypeDeclaration(QsCustomType type, DocComment comment)
{
if (!type.Access.IsPublic)
{
yield break;
}

if (comment.Examples.IsEmpty)
{
yield return new LintingMessage
{
Message = $"Public user-defined type {type.FullName} does not have any examples.",
Source = type.Source.AssemblyOrCodeFile,
Range = null, // TODO: provide more exact locations once supported by DocParser.
};
}
}
}

public class NoMathInSummary : IDocumentationLintingRule
{
private IEnumerable<LintingMessage> OnComment(DocComment comment, string name, string? source)
{
if (comment.Summary.Contains("$"))
{
yield return new LintingMessage
{
Message = $"Summary for {name} should not contain LaTeX notation.",
Source = source,
Range = null, // TODO: provide more exact locations once supported by DocParser.
};
}
}

public IEnumerable<LintingMessage> OnCallableDeclaration(QsCallable callable, DocComment comment) =>
this.OnComment(comment, $"{callable.FullName.Namespace}.{callable.FullName.Name}", callable.Source.AssemblyOrCodeFile);

public IEnumerable<LintingMessage> OnTypeDeclaration(QsCustomType type, DocComment comment) =>
this.OnComment(comment, $"{type.FullName.Namespace}.{type.FullName.Name}", type.Source.AssemblyOrCodeFile);
}

public class RequireCorrectInputNames : IDocumentationLintingRule
{
public IEnumerable<LintingMessage> OnCallableDeclaration(QsCallable callable, DocComment comment)
{
var callableName =
$"{callable.FullName.Namespace}.{callable.FullName.Name}";

// Validate input and type parameter names.
var inputDeclarations = callable.ArgumentTuple.ToDictionaryOfDeclarations();
var inputMessages = this.ValidateNames(
callableName,
"input",
name => inputDeclarations.ContainsKey(name),
comment.Input.Keys,
range: null, // TODO: provide more exact locations once supported by DocParser.
source: callable.Source.AssemblyOrCodeFile);
var typeParamMessages = this.ValidateNames(
callableName,
"type parameter",
name => callable.Signature.TypeParameters.Any(
typeParam =>
typeParam is QsLocalSymbol.ValidName validName &&
validName.Item == name.TrimStart('\'')),
comment.TypeParameters.Keys,
range: null, // TODO: provide more exact locations once supported by DocParser.
source: callable.Source.AssemblyOrCodeFile);

return inputMessages.Concat(typeParamMessages);
}

public IEnumerable<LintingMessage> OnTypeDeclaration(QsCustomType type, DocComment comment)
{
// Validate named item names.
var inputDeclarations = type.TypeItems.ToDictionaryOfDeclarations();
return this.ValidateNames(
$"{type.FullName.Namespace}.{type.FullName.Name}",
"named item",
name => inputDeclarations.ContainsKey(name),
comment.Input.Keys,
range: null, // TODO: provide more exact locations once supported by DocParser.
source: type.Source.AssemblyOrCodeFile);
}

private IEnumerable<LintingMessage> ValidateNames(
string symbolName,
string nameKind,
Func<string, bool> isNameValid,
IEnumerable<string> actualNames,
Range? range = null,
string? source = null)
{
foreach (var name in actualNames)
{
if (!isNameValid(name))
{
yield return new LintingMessage
{
Message = $"When documenting {symbolName}, found documentation for {nameKind} {name}, but no such {nameKind} exists.",
Range = range,
Source = source,
};
}
}
}
}
}
Loading