diff --git a/src/Documentation/DocumentationGenerator/DocumentationGeneration.cs b/src/Documentation/DocumentationGenerator/DocumentationGeneration.cs index 68aed7caa5..f9c498c2e0 100644 --- a/src/Documentation/DocumentationGenerator/DocumentationGeneration.cs +++ b/src/Documentation/DocumentationGenerator/DocumentationGeneration.cs @@ -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; @@ -16,6 +19,17 @@ namespace Microsoft.Quantum.Documentation /// public class DocumentationGeneration : IRewriteStep { + private static readonly ImmutableDictionary LintingRules; + + static DocumentationGeneration() + { + var rules = ImmutableDictionary.CreateBuilder(); + 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 diagnostics; @@ -92,11 +106,86 @@ public bool PreconditionVerification(QsCompilation compilation) /// 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 => { diff --git a/src/Documentation/DocumentationGenerator/Linting/IDocumentationLintingRule.cs b/src/Documentation/DocumentationGenerator/Linting/IDocumentationLintingRule.cs new file mode 100644 index 0000000000..ffca2d7715 --- /dev/null +++ b/src/Documentation/DocumentationGenerator/Linting/IDocumentationLintingRule.cs @@ -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 rules, + Func> invokeRule, + Action 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 OnTypeDeclaration(QsCustomType type, DocComment comment) + { + yield break; + } + + IEnumerable OnCallableDeclaration(QsCallable callable, DocComment comment) + { + yield break; + } + } + + public class RequireExamplesOnPublicDeclarations : IDocumentationLintingRule + { + public IEnumerable 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 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 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 OnCallableDeclaration(QsCallable callable, DocComment comment) => + this.OnComment(comment, $"{callable.FullName.Namespace}.{callable.FullName.Name}", callable.Source.AssemblyOrCodeFile); + + public IEnumerable OnTypeDeclaration(QsCustomType type, DocComment comment) => + this.OnComment(comment, $"{type.FullName.Namespace}.{type.FullName.Name}", type.Source.AssemblyOrCodeFile); + } + + public class RequireCorrectInputNames : IDocumentationLintingRule + { + public IEnumerable 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 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 ValidateNames( + string symbolName, + string nameKind, + Func isNameValid, + IEnumerable 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, + }; + } + } + } + } +} diff --git a/src/Documentation/DocumentationGenerator/ProcessDocComments.cs b/src/Documentation/DocumentationGenerator/ProcessDocComments.cs index 84af179cd8..474c298227 100644 --- a/src/Documentation/DocumentationGenerator/ProcessDocComments.cs +++ b/src/Documentation/DocumentationGenerator/ProcessDocComments.cs @@ -3,13 +3,15 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; +using Microsoft.Quantum.Documentation.Linting; using Microsoft.Quantum.QsCompiler; using Microsoft.Quantum.QsCompiler.Documentation; using Microsoft.Quantum.QsCompiler.SyntaxTokens; using Microsoft.Quantum.QsCompiler.SyntaxTree; using Microsoft.Quantum.QsCompiler.Transformations.Core; - +using DiagnosticSeverity = Microsoft.CodeAnalysis.DiagnosticSeverity; using Range = Microsoft.Quantum.QsCompiler.DataTypes.Range; namespace Microsoft.Quantum.Documentation @@ -45,7 +47,16 @@ public class TransformationState /// if the documentation to be written by this object does not /// relate to a particular package. /// - public ProcessDocComments(string outputPath, string? packageName = null) + /// + /// A dictionary of named linting rules that will be applied to + /// each different callable and UDT definition to yield additional + /// diagnostics, as well as the severity that will be applied to + /// each such diagnostic. + /// + public ProcessDocComments( + string outputPath, + string? packageName = null, + IDictionary? lintingRules = null) : base(new TransformationState(), TransformationOptions.Disabled) { this.Writer = new DocumentationWriter(outputPath, packageName); @@ -54,7 +65,7 @@ public ProcessDocComments(string outputPath, string? packageName = null) this.OnDiagnostic?.Invoke(diagnostic); // We provide our own custom namespace transformation, and expression kind transformation. - this.Namespaces = new NamespaceTransformation(this, this.Writer); + this.Namespaces = new NamespaceTransformation(this, this.Writer, lintingRules); } private class NamespaceTransformation @@ -62,35 +73,17 @@ private class NamespaceTransformation { private readonly DocumentationWriter? writer; - internal NamespaceTransformation(ProcessDocComments parent, DocumentationWriter? writer) + private readonly ImmutableDictionary lintingRules; + + internal NamespaceTransformation( + ProcessDocComments parent, + DocumentationWriter? writer, + IDictionary? lintingRules = null) : base(parent) { this.writer = writer; - } - - private void ValidateNames( - string symbolName, - string nameKind, - Func isNameValid, - IEnumerable actualNames, - Range? range = null, - string? source = null) - { - foreach (var name in actualNames) - { - if (!isNameValid(name)) - { - (this.Transformation as ProcessDocComments)?.OnDiagnostic?.Invoke( - new IRewriteStep.Diagnostic - { - Message = $"When documenting {symbolName}, found documentation for {nameKind} {name}, but no such {nameKind} exists.", - Severity = CodeAnalysis.DiagnosticSeverity.Warning, - Range = range, - Source = source, - Stage = IRewriteStep.Stage.Transformation, - }); - } - } + this.lintingRules = lintingRules?.ToImmutableDictionary() + ?? ImmutableDictionary.Empty; } public override QsNamespace OnNamespace(QsNamespace ns) @@ -134,15 +127,10 @@ public override QsCustomType OnTypeDeclaration(QsCustomType type) deprecated: isDeprecated, replacement: replacement); - // Validate named item names. - var inputDeclarations = type.TypeItems.ToDictionaryOfDeclarations(); - this.ValidateNames( - $"{type.FullName.Namespace}.{type.FullName.Name}", - "named item", - name => inputDeclarations.ContainsKey(name), - docComment.Input.Keys, - range: null, // TODO: provide more exact locations once supported by DocParser. - source: type.Source.AssemblyOrCodeFile); + this.lintingRules.InvokeRules( + rule => rule.OnTypeDeclaration(type, docComment), + (this.Transformation as ProcessDocComments)?.OnDiagnostic + ?? ((_) => { })); if (type.Access.IsPublic) { @@ -179,28 +167,11 @@ public override QsCallable OnCallableDeclaration(QsCallable callable) callable.FullName.Name, deprecated: isDeprecated, replacement: replacement); - var callableName = - $"{callable.FullName.Namespace}.{callable.FullName.Name}"; - - // Validate input and type parameter names. - var inputDeclarations = callable.ArgumentTuple.ToDictionaryOfDeclarations(); - this.ValidateNames( - callableName, - "input", - name => inputDeclarations.ContainsKey(name), - docComment.Input.Keys, - range: null, // TODO: provide more exact locations once supported by DocParser. - source: callable.Source.AssemblyOrCodeFile); - this.ValidateNames( - callableName, - "type parameter", - name => callable.Signature.TypeParameters.Any( - typeParam => - typeParam is QsLocalSymbol.ValidName validName && - validName.Item == name.TrimStart('\'')), - docComment.TypeParameters.Keys, - range: null, // TODO: provide more exact locations once supported by DocParser. - source: callable.Source.AssemblyOrCodeFile); + + this.lintingRules.InvokeRules( + rule => rule.OnCallableDeclaration(callable, docComment), + (this.Transformation as ProcessDocComments)?.OnDiagnostic + ?? ((_) => { })); if (callable.Access.IsPublic) { diff --git a/src/QsCompiler/DataStructures/ReservedKeywords.fs b/src/QsCompiler/DataStructures/ReservedKeywords.fs index 5c2da35c21..3aa57971e2 100644 --- a/src/QsCompiler/DataStructures/ReservedKeywords.fs +++ b/src/QsCompiler/DataStructures/ReservedKeywords.fs @@ -272,6 +272,7 @@ module AssemblyConstants = let PerfDataOutputPath = "PerfDataOutputPath" let DocsOutputPath = "DocsOutputPath" let DocsPackageId = "DocsPackageId" + let DocsLintingRules = "DocsLintingRules" let GenerateConcreteIntrinsic = "GenerateConcreteIntrinsic" /// The runtime capabilities supported by an execution target. The names of the capabilities here match the ones diff --git a/src/QuantumSdk/README.md b/src/QuantumSdk/README.md index 7d20207387..857f68deba 100644 --- a/src/QuantumSdk/README.md +++ b/src/QuantumSdk/README.md @@ -118,27 +118,27 @@ in the project that implements the compilation step. The Sdk defines the following properties for each project using it: -- `QSharpLangVersion`: +- `QSharpLangVersion`: The version of the Q# language specification. -- `QuantumSdkVersion`: +- `QuantumSdkVersion`: The NuGet version of the Sdk package. The following properties can be configured to customize the build: -- `AdditionalQscArguments`: +- `AdditionalQscArguments`: May contain additional arguments to pass to the Q# command line compiler. Valid additional arguments are `--emit-dll`, or `--no-warn` followed by any number of integers specifying the warnings to ignore. -- `CSharpGeneration`: +- `CSharpGeneration`: Specifies whether to generate C# code as part of the compilation process. Setting this property to false may prevent certain interoperability features or integration with other pieces of the Quantum Development Kit. -- `DefaultSimulator`: +- `DefaultSimulator`: Specifies the simulator to use by default for execution. Valid values are QuantumSimulator, ToffoliSimulator, ResourcesEstimator, or the fully qualified name of a custom simulator. -- `IncludeQSharpCorePackages`: +- `IncludeQSharpCorePackages`: Specifies whether the packages providing the basic language support for Q# are referenced. This property is set to true by default. If set to false, the Sdk will not reference any Q# libraries. -- `IncludeProviderPackages`: +- `IncludeProviderPackages`: Specifies whether the packages for specific hardware providers should be automatically included based on the specified `ExecutionTarget`. This property is set to true by default. If set to false, the Sdk will not automatically reference any provider packages. - `IncludeCSharpRuntime`: @@ -147,30 +147,40 @@ Specifies whether the packages with the C# Runtime should be automatically refer - `QscExe`: The command to invoke the Q# compiler. The value set by default invokes the Q# compiler that is packaged as tool with the Sdk. The default value can be accessed via the `DefaultQscExe` property. -- `QscVerbosity`: +- `QscVerbosity`: Defines the verbosity of the Q# compiler. Recognized values are: Quiet, Minimal, Normal, Detailed, and Diagnostic. -- `PerfDataGeneration`: +- `PerfDataGeneration`: Specifies whether to generate performance analysis data for the compilation. The default value is "true" if `PerfDataOutputPath` is specified and "false" otherwise. Note that setting this property to `"false"` will disable generating performance data, even if `PerfDataOutputPath` is also set. -- `PerfDataOutputPath`: +- `PerfDataOutputPath`: Directory where the generated performance analysis data will be saved. If no directory is specified and `PerfDataGeneration` is set to "true", it will be set to "$(MSBuildProjectDirectory)/perf". -- `QirGeneration`: +- `QirGeneration`: Specifies whether to generate QIR for the compiled Q# code. The default value is "true" if `QirOutputPath` is specified and "false" otherwise. Note that setting this property to `"false"` will disable generating QIR, even if `QirOutputPath` is also set. -- `QirOutputPath`: +- `QirOutputPath`: Directory where the generated QIR will be saved. If no directory is specified and `QirGeneration` is set to "true", it will be set to "$(MSBuildProjectDirectory)/qir". -- `QSharpDocsGeneration`: +- `QSharpDocsGeneration`: Specifies whether to generate yml documentation for the compiled Q# code. The default value is "true" if `QSharpDocsOutputPath` is specified and "false" otherwise. Note that setting this property to `"false"` will disable generating docs, even if `QSharpDocsOutputPath` is also set. -- `QSharpDocsOutputPath`: +- `QSharpDocsOutputPath`: Directory where the generated documentation will be saved. If no directory is specified and `QSharpDocsGeneration` is set to "true", it will be set to "$(MSBuildProjectDirectory)/docs". -- `QSharpDocsPackageId`: +- `QSharpDocsPackageId`: Specifies the package ID that should appear in generated documentation. Set to `PackageId` by default, but can be overridden to allow for documenting parts of metapackages. +- `QSharpDocsLintingRules`: + Specifies which documentation linting rules should be applied to documentation comments, and whether those rules should be treated as warnings or errors. + + For example, `warning:no-math-in-summary,error:require-examples` will raise a warning when summaries contain LaTeX notation, and an error when public API comments do not include examples. + + Currently supported rules: + + - `require-correct-input-names` (default: `warning`): Validates that input and type parameter subheadings correspond to valid input and type parameter names. + - `require-examples` (default: `ignore`): Requires that any public function, operation, or UDT includes at least one example. + - `no-math-in-summary` (default: `warning`): Requires that summary sections do not contain LaTeX notation. [comment]: # (TODO: document QscBuildConfigExe, QscBuildConfigOutputPath) @@ -178,16 +188,16 @@ Specifies the package ID that should appear in generated documentation. Set to ` The following configurable item groups are used by the Sdk: -- `PackageLoadFallbackFolder`: +- `PackageLoadFallbackFolder`: Contains the directories where the Q# compiler will look for a suitable dll if a qsc reference or one if its dependencies cannot be found. By default, the project output path is included in this item group. -- `PackageReference`: +- `PackageReference`: Contains all referenced NuGet packages. Package references for which the `IsQscReference` attribute is set to "true" may extend the Q# compiler and any implemented rewrite steps will be executed as part of the compilation process. See [this section](#extending-the-q#-compiler) for more details. -- `ProjectReference`: +- `ProjectReference`: Contains all referenced projects. Project references for which the `IsQscReference` attribute is set to "true" may extend the Q# compiler and any implemented rewrite steps will be executed as part of the compilation process. See [this section](#extending-the-q#-compiler) for more details. -- `QSharpCompile`: +- `QSharpCompile`: Contains all Q# source files included in the compilation. # Sdk Packages # @@ -212,6 +222,6 @@ To avoid issues with conflicting packages, we load each Q# compiler extension in ## Known Issues ## The following issues and PRs may be of interest when using the Sdk: -> https://github.com/NuGet/Home/issues/8692 -> https://github.com/dotnet/runtime/issues/949 -> https://github.com/NuGet/NuGet.Client/pull/3170 +> https://github.com/NuGet/Home/issues/8692 +> https://github.com/dotnet/runtime/issues/949 +> https://github.com/NuGet/NuGet.Client/pull/3170 diff --git a/src/QuantumSdk/Sdk/Sdk.targets b/src/QuantumSdk/Sdk/Sdk.targets index 76d89ae6dc..69e0676ed2 100644 --- a/src/QuantumSdk/Sdk/Sdk.targets +++ b/src/QuantumSdk/Sdk/Sdk.targets @@ -104,6 +104,7 @@ <_QscCommandPredefinedAssemblyProperties Condition="$(QSharpDocsGeneration)">$(_NewLineIndent)DocsOutputPath:"$(QSharpDocsOutputPath)"$(_QscCommandPredefinedAssemblyProperties) <_QscCommandPredefinedAssemblyProperties Condition="$(PerfDataGeneration)">$(_NewLineIndent)PerfDataOutputPath:"$(PerfDataOutputPath)"$(_QscCommandPredefinedAssemblyProperties) <_QscCommandPredefinedAssemblyProperties Condition="$(GenerateConcreteIntrinsic)">$(_NewLineIndent)GenerateConcreteIntrinsic:$(GenerateConcreteIntrinsic)$(_QscCommandPredefinedAssemblyProperties) + <_QscCommandPredefinedAssemblyProperties Condition="'$(QSharpDocsLintingRules)' != ''">$(_QscCommandPredefinedAssemblyProperties)$(_NewLineIndent)DocsLintingRules:$(QSharpDocsLintingRules) <_QscCommandAssemblyPropertiesFlag>$(_NewLine)--assembly-properties$(_QscCommandPredefinedAssemblyProperties) <_QscCommandAssemblyPropertiesFlag Condition="'$(QscCommandAssemblyProperties)' != ''">$(_QscCommandAssemblyPropertiesFlag)$(_NewLineIndent)$(QscCommandAssemblyProperties) $(_NewLine)$(AdditionalQscArguments)