diff --git a/.vscode/launch.json b/.vscode/launch.json index 88bd5ca2..79a32d01 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -33,4 +33,4 @@ "processId": "${command:pickProcess}" } ] -} \ No newline at end of file +} diff --git a/ApiDoctor.Console/Program.cs b/ApiDoctor.Console/Program.cs index e4d823aa..7f721b41 100644 --- a/ApiDoctor.Console/Program.cs +++ b/ApiDoctor.Console/Program.cs @@ -332,7 +332,7 @@ private static Task GetDocSetAsync(DocSetOptions options, IssueLogger is DateTimeOffset end = DateTimeOffset.Now; TimeSpan duration = end.Subtract(start); - FancyConsole.WriteLine($"Took {duration.TotalSeconds} to parse {set.Files.Length} source files."); + FancyConsole.WriteLine($"Took {duration.TotalSeconds}s to parse {set.Files.Length} source files."); return Task.FromResult(set); } diff --git a/ApiDoctor.Validation/ApiDoctor.Validation.csproj b/ApiDoctor.Validation/ApiDoctor.Validation.csproj index 2c90af6f..5b3f4717 100644 --- a/ApiDoctor.Validation/ApiDoctor.Validation.csproj +++ b/ApiDoctor.Validation/ApiDoctor.Validation.csproj @@ -23,6 +23,7 @@ + diff --git a/ApiDoctor.Validation/Config/ConfigFile.cs b/ApiDoctor.Validation/Config/ConfigFile.cs index 7d7880c9..fa7089a5 100644 --- a/ApiDoctor.Validation/Config/ConfigFile.cs +++ b/ApiDoctor.Validation/Config/ConfigFile.cs @@ -31,7 +31,7 @@ public abstract class ConfigFile public string SourcePath { get; set; } /// - /// Provide oppertunity to post-process a valid configuration after the file is loaded. + /// Provide opportunity to post-process a valid configuration after the file is loaded. /// public virtual void LoadComplete() { diff --git a/ApiDoctor.Validation/Config/DocumentOutlineFile.cs b/ApiDoctor.Validation/Config/DocumentOutlineFile.cs index b239bd7c..c079c338 100644 --- a/ApiDoctor.Validation/Config/DocumentOutlineFile.cs +++ b/ApiDoctor.Validation/Config/DocumentOutlineFile.cs @@ -1,93 +1,184 @@ /* - * API Doctor - * Copyright (c) Microsoft Corporation - * All rights reserved. - * - * MIT License - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the ""Software""), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, - * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A - * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT - * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION - * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE - * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ +* API Doctor +* Copyright (c) Microsoft Corporation +* All rights reserved. +* +* MIT License +* +* Permission is hereby granted, free of charge, to any person obtaining a copy of +* this software and associated documentation files (the ""Software""), to deal in +* the Software without restriction, including without limitation the rights to use, +* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +* Software, and to permit persons to whom the Software is furnished to do so, +* subject to the following conditions: +* +* The above copyright notice and this permission notice shall be included in all +* copies or substantial portions of the Software. +* +* THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +* INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +* PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ namespace ApiDoctor.Validation.Config { + using Newtonsoft.Json; + using Newtonsoft.Json.Linq; using System; using System.Collections.Generic; - using Newtonsoft.Json; public class DocumentOutlineFile : ConfigFile { - [JsonProperty("allowedDocumentHeaders")] - public DocumentHeader[] AllowedHeaders { get; set; } + [JsonProperty("apiPageType"), JsonConverter(typeof(DocumentHeaderJsonConverter))] + public List ApiPageType { get; set; } = []; + + [JsonProperty("resourcePageType"), JsonConverter(typeof(DocumentHeaderJsonConverter))] + public List ResourcePageType { get; set; } = []; + + [JsonProperty("conceptualPageType"), JsonConverter(typeof(DocumentHeaderJsonConverter))] + public List ConceptualPageType { get; set; } = []; + + [JsonProperty("enumPageType"), JsonConverter(typeof(DocumentHeaderJsonConverter))] + public List EnumPageType { get; set; } = []; + + public override bool IsValid => ApiPageType.Count > 0 || ResourcePageType.Count > 0 || ConceptualPageType.Count > 0 || EnumPageType.Count > 0; + } + + + public class ExpectedDocumentHeader : DocumentHeader + { + /// + /// Indicates that a header pattern can be repeated multiple times e.g. in the case of multiple examples + /// + [JsonProperty("allowMultiple")] + public bool AllowMultiple { get; set; } - public override bool IsValid + /// + /// Specifies the headers that are allowed under this header. + /// + [JsonProperty("headers"), JsonConverter(typeof(DocumentHeaderJsonConverter))] + public new List ChildHeaders { get; set; } = []; + + public ExpectedDocumentHeader() { } + + public ExpectedDocumentHeader(ExpectedDocumentHeader original) : base(original) { - get + if (original == null) + return; + + AllowMultiple = original.AllowMultiple; + + ChildHeaders = CopyHeaders(original.ChildHeaders); + } + + public static List CopyHeaders(List headers) + { + if (headers == null) + return null; + + var headersCopy = new List(); + foreach (var header in headers) { - return this.AllowedHeaders != null; + headersCopy.Add(header switch + { + ConditionalDocumentHeader conditionalDocHeader => new ConditionalDocumentHeader(conditionalDocHeader), + ExpectedDocumentHeader expectedDocHeader => new ExpectedDocumentHeader(expectedDocHeader), + _ => throw new InvalidOperationException("Unexpected header type") + }); } + return headersCopy; } } - public class DocumentHeader + public class ConditionalDocumentHeader { - public DocumentHeader() + [JsonProperty("condition")] + public string Condition { get; set; } + + [JsonProperty("arguments"), JsonConverter(typeof(DocumentHeaderJsonConverter))] + public List Arguments { get; set; } + + public ConditionalOperator? Operator { - Level = 1; - ChildHeaders = new List(); + get + { + return Enum.TryParse(this.Condition, true, out ConditionalOperator op) ? op : null; + } } - /// - /// Represents the header level using markdown formatting (1=#, 2=##, 3=###, 4=####, 5=#####, 6=######) - /// - [JsonProperty("level")] - public int Level { get; set; } + public ConditionalDocumentHeader(ConditionalDocumentHeader original) + { + if (original == null) + return; - /// - /// Indicates that a header at this level is required. - /// - [JsonProperty("required")] - public bool Required { get; set; } + Condition = original.Condition; - /// - /// The expected value of a title or empty to indicate any value - /// - [JsonProperty("title")] - public string Title { get; set; } + Arguments = ExpectedDocumentHeader.CopyHeaders(original.Arguments); + } + } - /// - /// Specifies the headers that are allowed under this header. - /// - [JsonProperty("headers")] - public List ChildHeaders { get; set; } + public enum ConditionalOperator + { + OR, + AND + } + + public class DocumentHeaderJsonConverter : JsonConverter + { + public override bool CanConvert(Type objectType) + { + return objectType == typeof(ExpectedDocumentHeader) || objectType == typeof(ConditionalDocumentHeader); + } - internal bool Matches(DocumentHeader found) + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) { - return this.Level == found.Level && DoTitlesMatch(this.Title, found.Title); + if (reader.TokenType == JsonToken.StartArray) + { + var allowedHeaders = new List(); + var jArray = JArray.Load(reader); + foreach (var item in jArray) + { + if (item is JObject jObject) + { + object header; + if (jObject.ContainsKey("condition")) + { + header = jObject.ToObject(serializer); + } + else if (jObject.ContainsKey("title")) + { + header = jObject.ToObject(serializer); + } + else + { + throw new JsonReaderException("Invalid document header definition"); + } + allowedHeaders.Add(header); + } + else + { + throw new JsonReaderException("Invalid document header definition"); + } + } + return allowedHeaders; + } + else if (reader.TokenType == JsonToken.Null) + { + return null; + } + else + { + throw new JsonSerializationException($"Unexpected token: {existingValue}"); + } } - private static bool DoTitlesMatch(string expectedTitle, string foundTitle) + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) { - if (expectedTitle == foundTitle) return true; - if (string.IsNullOrEmpty(expectedTitle) || expectedTitle == "*") return true; - if (expectedTitle.StartsWith("* ") && foundTitle.EndsWith(expectedTitle.Substring(2))) return true; - if (expectedTitle.EndsWith(" *") && foundTitle.StartsWith(expectedTitle.Substring(0, expectedTitle.Length - 2))) return true; - return false; + serializer.Serialize(writer, value); } } -} +} \ No newline at end of file diff --git a/ApiDoctor.Validation/DocFile.cs b/ApiDoctor.Validation/DocFile.cs index 5735e697..56e8c912 100644 --- a/ApiDoctor.Validation/DocFile.cs +++ b/ApiDoctor.Validation/DocFile.cs @@ -1,27 +1,27 @@ /* - * API Doctor - * Copyright (c) Microsoft Corporation - * All rights reserved. - * - * MIT License - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the ""Software""), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, - * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A - * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT - * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION - * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE - * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ +* API Doctor +* Copyright (c) Microsoft Corporation +* All rights reserved. +* +* MIT License +* +* Permission is hereby granted, free of charge, to any person obtaining a copy of +* this software and associated documentation files (the ""Software""), to deal in +* the Software without restriction, including without limitation the rights to use, +* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +* Software, and to permit persons to whom the Software is furnished to do so, +* subject to the following conditions: +* +* The above copyright notice and this permission notice shall be included in all +* copies or substantial portions of the Software. +* +* THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +* INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +* PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ namespace ApiDoctor.Validation { @@ -35,6 +35,7 @@ namespace ApiDoctor.Validation using Tags; using MarkdownDeep; using Newtonsoft.Json; + using ApiDoctor.Validation.Config; /// /// A documentation file that may contain one more resources or API methods @@ -196,7 +197,7 @@ public string Namespace protected DocFile() { this.ContentOutline = new List(); - this.DocumentHeaders = new List(); + this.DocumentHeaders = new List(); } public DocFile(string basePath, string relativePath, DocSet parent) @@ -206,7 +207,7 @@ public DocFile(string basePath, string relativePath, DocSet parent) this.FullPath = Path.Combine(basePath, relativePath.Substring(1)); this.DisplayName = relativePath; this.Parent = parent; - this.DocumentHeaders = new List(); + this.DocumentHeaders = new List(); } #endregion @@ -487,7 +488,6 @@ private static bool IsHeaderBlock(Block block, int maxDepth = 2) return false; } - protected string PreviewOfBlockContent(Block block) { if (block == null) return string.Empty; @@ -502,9 +502,9 @@ protected string PreviewOfBlockContent(Block block) return contentPreview; } - protected static Config.DocumentHeader CreateHeaderFromBlock(Block block) + protected static DocumentHeader CreateHeaderFromBlock(Block block) { - var header = new Config.DocumentHeader(); + var header = new DocumentHeader(); switch (block.BlockType) { case BlockType.h1: @@ -529,7 +529,7 @@ protected static Config.DocumentHeader CreateHeaderFromBlock(Block block) /// /// Headers found in the markdown input (#, h1, etc) /// - public List DocumentHeaders + public List DocumentHeaders { get; set; } @@ -550,7 +550,7 @@ protected bool ParseMarkdownBlocks(IssueLogger issues) List foundElements = new List(); - Stack headerStack = new Stack(); + Stack headerStack = new Stack(); for (int i = 0; i < this.OriginalMarkdownBlocks.Length; i++) { var block = this.OriginalMarkdownBlocks[i]; @@ -581,7 +581,7 @@ protected bool ParseMarkdownBlocks(IssueLogger issues) { methodDescriptionsData.Add(block.Content); // make sure we omit the namespace description as well as the national cloud deployments paragraphs - methodDescription = string.Join(" ", methodDescriptionsData.Where(static x => !x.StartsWith("Namespace:",StringComparison.OrdinalIgnoreCase) && !x.Contains("[national cloud deployments](/graph/deployments)",StringComparison.OrdinalIgnoreCase))).ToStringClean(); + methodDescription = string.Join(" ", methodDescriptionsData.Where(static x => !x.StartsWith("Namespace:", StringComparison.OrdinalIgnoreCase) && !x.Contains("[national cloud deployments](/graph/deployments)", StringComparison.OrdinalIgnoreCase))).ToStringClean(); issues.Message($"Found description: {methodDescription}"); } } @@ -694,93 +694,275 @@ protected bool ParseMarkdownBlocks(IssueLogger issues) /// public void CheckDocumentStructure(IssueLogger issues) { - List errors = new List(); if (this.Parent.DocumentStructure != null) { - ValidateDocumentHeaders(this.Parent.DocumentStructure.AllowedHeaders, this.DocumentHeaders, issues); + var expectedHeaders = this.DocumentPageType switch + { + PageType.ApiPageType => ExpectedDocumentHeader.CopyHeaders(this.Parent.DocumentStructure.ApiPageType), + PageType.ResourcePageType => ExpectedDocumentHeader.CopyHeaders(this.Parent.DocumentStructure.ResourcePageType), + PageType.EnumPageType => ExpectedDocumentHeader.CopyHeaders(this.Parent.DocumentStructure.EnumPageType), + PageType.ConceptualPageType => ExpectedDocumentHeader.CopyHeaders(this.Parent.DocumentStructure.ConceptualPageType), + _ => [], + }; + if (expectedHeaders.Count != 0) + { + CheckDocumentHeaders(expectedHeaders, this.DocumentHeaders, issues); + } } ValidateTabStructure(issues); } - private static bool ContainsMatchingDocumentHeader(Config.DocumentHeader expectedHeader, IReadOnlyList collection) + private static bool ContainsMatchingDocumentHeader(DocumentHeader header, IReadOnlyList headerCollection, + bool ignoreCase = false, bool checkStringDistance = false) { - return collection.Any(h => h.Matches(expectedHeader)); + return headerCollection.Any(h => h.Matches(header, ignoreCase, checkStringDistance)); } - private void ValidateDocumentHeaders(IReadOnlyList expectedHeaders, IReadOnlyList foundHeaders, IssueLogger issues) + /// + /// Match headers found in doc against expected headers for doc type and report discrepancies + /// + /// Allowed headers to match against + /// Headers to evaluate + /// + private void CheckDocumentHeaders(List expectedHeaders, IReadOnlyList foundHeaders, IssueLogger issues) { int expectedIndex = 0; int foundIndex = 0; while (expectedIndex < expectedHeaders.Count && foundIndex < foundHeaders.Count) { - var expected = expectedHeaders[expectedIndex]; var found = foundHeaders[foundIndex]; - - if (expected.Matches(found)) + var result = ValidateDocumentHeader(expectedHeaders, foundHeaders, expectedIndex, foundIndex); + var expected = expectedHeaders[expectedIndex] as ExpectedDocumentHeader; // at this point, if header was conditional, the condition has been removed + switch (result) { - ValidateDocumentHeaders(expected.ChildHeaders, found.ChildHeaders, issues); + case DocumentHeaderValidationResult.Found: + CheckDocumentHeaders(expected.ChildHeaders, found.ChildHeaders, issues); + foundIndex++; - // Found an expected header, keep going! - expectedIndex++; - foundIndex++; - continue; - } + //if expecting multiple headers of the same pattern, do not increment expected until last header matching pattern is found + if (!expected.AllowMultiple || (expected.AllowMultiple && foundIndex == foundHeaders.Count)) + expectedIndex++; + + break; - if (!ContainsMatchingDocumentHeader(found, expectedHeaders)) - { - // This is an additional header that isn't in the expected header collection - issues.Warning(ValidationErrorCode.ExtraDocumentHeaderFound, $"A extra document header was found: {found.Title}"); - ValidateDocumentHeaders(new Config.DocumentHeader[0], found.ChildHeaders, issues); - foundIndex++; - continue; - } - else - { - // If the current expected header is optional, we can move past it - if (!expected.Required) - { + case DocumentHeaderValidationResult.FoundInWrongCase: + issues.Error(ValidationErrorCode.DocumentHeaderInWrongCase, $"Incorrect letter case in document header: {found.Title}"); expectedIndex++; - continue; - } + foundIndex++; + break; - bool expectedMatchesInFoundHeaders = ContainsMatchingDocumentHeader(expected, foundHeaders); - if (expectedMatchesInFoundHeaders) - { - // This header exists, but is in the wrong position + case DocumentHeaderValidationResult.MisspeltDocumentHeader: + issues.Error(ValidationErrorCode.MisspeltDocumentHeader, $"Found header: {found.Title}. Did you mean: {expected.Title}?"); + expectedIndex++; + foundIndex++; + break; + + case DocumentHeaderValidationResult.MisspeltDocumentHeaderInWrongPosition: + issues.Error(ValidationErrorCode.MisspeltDocumentHeader, $"An expected document header (possibly misspelt) was found in the wrong position: {found.Title}"); + foundIndex++; + break; + + case DocumentHeaderValidationResult.ExtraDocumentHeaderFound: + issues.Warning(ValidationErrorCode.ExtraDocumentHeaderFound, $"An extra document header was found: {found.Title}"); + foundIndex++; + break; + + case DocumentHeaderValidationResult.DocumentHeaderInWrongPosition: issues.Warning(ValidationErrorCode.DocumentHeaderInWrongPosition, $"An expected document header was found in the wrong position: {found.Title}"); foundIndex++; - continue; - } - else if (!expectedMatchesInFoundHeaders && expected.Required) - { - // Missing a required header! + break; + + case DocumentHeaderValidationResult.RequiredDocumentHeaderMissing: issues.Error(ValidationErrorCode.RequiredDocumentHeaderMissing, $"A required document header is missing from the document: {expected.Title}"); expectedIndex++; - } - else - { - // Expected wasn't found and is optional, that's fine. + break; + + case DocumentHeaderValidationResult.OptionalDocumentHeaderMissing: expectedIndex++; - continue; - } + break; + + default: + break; + + } + + //if expecting multiple headers of the same pattern, increment expected when last header matching pattern is found + if (expected.AllowMultiple && foundIndex == foundHeaders.Count) + { + expectedIndex++; } } for (int i = foundIndex; i < foundHeaders.Count; i++) { - issues.Warning(ValidationErrorCode.ExtraDocumentHeaderFound, $"A extra document header was found: {foundHeaders[i].Title}"); + issues.Warning(ValidationErrorCode.ExtraDocumentHeaderFound, $"An extra document header was found: {foundHeaders[i].Title}"); } + for (int i = expectedIndex; i < expectedHeaders.Count; i++) { - if (expectedHeaders[i].Required) + ExpectedDocumentHeader missingHeader; + if (expectedHeaders[i] is ExpectedDocumentHeader expectedMissingHeader) + { + missingHeader = expectedMissingHeader; + } + else + { + missingHeader = (expectedHeaders[i] as ConditionalDocumentHeader).Arguments.OfType().First(); + } + + if (!ContainsMatchingDocumentHeader(missingHeader, foundHeaders, true, true) && missingHeader.Required) { - issues.Error(ValidationErrorCode.RequiredDocumentHeaderMissing, $"A required document header is missing from the document: {expectedHeaders[i].Title}"); + issues.Error(ValidationErrorCode.RequiredDocumentHeaderMissing, $"A required document header is missing from the document: {missingHeader.Title}"); } } } - private void AddHeaderToHierarchy(Stack headerStack, Block block) + /// + /// Validates a document header against the found headers. + /// + /// The list of expected headers, which may include conditional headers. + /// The list of found headers. + /// Index of the expected header being validated. + /// Index of the found header being compared. + /// The validation result. + private DocumentHeaderValidationResult ValidateDocumentHeader(List expectedHeaders, IReadOnlyList foundHeaders, int expectedIndex, int foundIndex) + { + if (expectedHeaders[expectedIndex] is ConditionalDocumentHeader) + { + return ValidateConditionalDocumentHeader(expectedHeaders, foundHeaders, expectedIndex, foundIndex); + } + + var found = foundHeaders[foundIndex]; + var expected = expectedHeaders[expectedIndex] as ExpectedDocumentHeader; + + if (expected.Matches(found)) + { + return DocumentHeaderValidationResult.Found; + } + + // Try case insensitive match + if (expected.Matches(found, true)) + { + return DocumentHeaderValidationResult.FoundInWrongCase; + } + + // Check if header is misspelt + if (expected.IsMisspelt(found)) + { + return DocumentHeaderValidationResult.MisspeltDocumentHeader; + } + + // Check if expected header is in the list of found headers + if (!ContainsMatchingDocumentHeader(expected, foundHeaders, ignoreCase: true, checkStringDistance: true)) + { + if (expected.Required) + { + return DocumentHeaderValidationResult.RequiredDocumentHeaderMissing; + } + else + { + return DocumentHeaderValidationResult.OptionalDocumentHeaderMissing; + } + } + + // Check if found header is in wrong position or is an extra header + var mergedExpectedHeaders = FlattenDocumentHeaderHierarchy(expectedHeaders); + if (ContainsMatchingDocumentHeader(found, mergedExpectedHeaders, ignoreCase: true)) + { + return DocumentHeaderValidationResult.DocumentHeaderInWrongPosition; + } + else if (ContainsMatchingDocumentHeader(found, mergedExpectedHeaders, ignoreCase: true, checkStringDistance: true)) + { + return DocumentHeaderValidationResult.MisspeltDocumentHeaderInWrongPosition; + } + else + { + return DocumentHeaderValidationResult.ExtraDocumentHeaderFound; + } + } + + /// + /// Flattens a hierarchical structure of document headers into a single list. + /// + /// The list of headers, which may contain nested conditional headers. + /// A flat list containing all document headers. + private static List FlattenDocumentHeaderHierarchy(IReadOnlyList headers) + { + var mergedHeaders = new List(); + foreach (var header in headers) + { + if (header is ExpectedDocumentHeader expectedHeader) + { + mergedHeaders.Add(expectedHeader); + } + else if (header is ConditionalDocumentHeader conditionalHeader) + { + mergedHeaders.AddRange(FlattenDocumentHeaderHierarchy(conditionalHeader.Arguments)); + } + } + return mergedHeaders; + } + + /// + /// Validates a conditional document header against the found headers. + /// + /// The list of expected headers. + /// The list of found headers. + /// Index of the expected header being validated. + /// Index of the found header being compared. + /// The validation result. + private DocumentHeaderValidationResult ValidateConditionalDocumentHeader(List expectedHeaders, IReadOnlyList foundHeaders, int expectedIndex, int foundIndex) + { + var validationResult = DocumentHeaderValidationResult.None; + var expectedConditionalHeader = expectedHeaders[expectedIndex] as ConditionalDocumentHeader; + if (expectedConditionalHeader.Operator == ConditionalOperator.OR) + { + foreach (var header in expectedConditionalHeader.Arguments) + { + // Replace conditional header with this argument for validation + expectedHeaders[expectedIndex] = header; + validationResult = ValidateDocumentHeader(expectedHeaders, foundHeaders, expectedIndex, foundIndex); + + // If header has been found, stop looking + if (validationResult != DocumentHeaderValidationResult.RequiredDocumentHeaderMissing && + validationResult != DocumentHeaderValidationResult.OptionalDocumentHeaderMissing) + { + break; + } + } + } + else if (expectedConditionalHeader.Operator == ConditionalOperator.AND) + { + if (expectedConditionalHeader.Arguments != null && expectedConditionalHeader.Arguments.Count > 0) + { + expectedHeaders[expectedIndex] = expectedConditionalHeader.Arguments.First(); + expectedHeaders.InsertRange(expectedIndex + 1, expectedConditionalHeader.Arguments.Skip(1)); + + validationResult = ValidateDocumentHeader(expectedHeaders, foundHeaders, expectedIndex, foundIndex); + } + else + { + validationResult = DocumentHeaderValidationResult.ExtraDocumentHeaderFound; + } + } + return validationResult; + } + + private enum DocumentHeaderValidationResult + { + None, + Found, + FoundInWrongCase, + ExtraDocumentHeaderFound, + RequiredDocumentHeaderMissing, + OptionalDocumentHeaderMissing, + DocumentHeaderInWrongPosition, + MisspeltDocumentHeader, + MisspeltDocumentHeaderInWrongPosition + } + + private void AddHeaderToHierarchy(Stack headerStack, Block block) { var header = CreateHeaderFromBlock(block); if (header.Level == 1 || headerStack.Count == 0) @@ -855,7 +1037,7 @@ private void ValidateTabStructure(IssueLogger issues) } if (currentLine.Contains("# Example", StringComparison.OrdinalIgnoreCase)) - currentState = TabDetectionState.FindStartOfTabGroup; + currentState = TabDetectionState.FindStartOfTabGroup; break; case TabDetectionState.FindStartOfTabGroup: if (isTabHeader) @@ -865,7 +1047,7 @@ private void ValidateTabStructure(IssueLogger issues) if (foundTabIndex == 0 && !currentLine.Contains("#tab/http")) issues.Error(ValidationErrorCode.TabHeaderError, $"The first tab should be 'HTTP' in tab group #{foundTabGroups}"); - + if (foundTabGroups == 1) tabHeaders.Add(currentLine); @@ -914,7 +1096,7 @@ private void ValidateTabStructure(IssueLogger issues) } } - + if (currentState == TabDetectionState.FindEndOfTabGroup) issues.Error(ValidationErrorCode.TabHeaderError, $"Missing tab boundary in document for tab group #{foundTabGroups}"); } @@ -1491,7 +1673,7 @@ public List ParseCodeBlock(Block metadata, Block code, IssueLogg MethodDefinition pairedRequest = (from m in this.requests where m.Identifier == requestMethodName select m).FirstOrDefault(); if (pairedRequest != null) { - try + try { pairedRequest.AddExpectedResponse(GetBlockContent(code), annotation); responses.Add(pairedRequest); @@ -1832,4 +2014,4 @@ public override bool Equals(object obj) } } -} +} \ No newline at end of file diff --git a/ApiDoctor.Validation/DocSet.cs b/ApiDoctor.Validation/DocSet.cs index fa67e864..53118abb 100644 --- a/ApiDoctor.Validation/DocSet.cs +++ b/ApiDoctor.Validation/DocSet.cs @@ -97,7 +97,7 @@ public IEnumerable ErrorCodes public MetadataValidationConfigs MetadataValidationConfigs { get; internal set; } - public DocumentOutlineFile DocumentStructure { get; internal set; } + public DocumentOutlineFile DocumentStructure { get; internal set; } = new DocumentOutlineFile(); public LinkValidationConfigFile LinkValidationConfig { get; private set; } @@ -161,6 +161,10 @@ private void LoadRequirements() Console.WriteLine("Using document structure file: {0}", foundOutlines.SourcePath); this.DocumentStructure = foundOutlines; } + else + { + Console.WriteLine("Document structure file has been incorrectly formatted or is missing"); + } LinkValidationConfigFile[] linkConfigs = TryLoadConfigurationFiles(this.SourceFolderPath); var foundLinkConfig = linkConfigs.FirstOrDefault(); @@ -269,11 +273,11 @@ public static T[] TryLoadConfigurationFiles(string path) where T : ConfigFile } catch (JsonException ex) { - Logging.LogMessage(new ValidationWarning(ValidationErrorCode.JsonParserException, file.FullName, "JSON parser error: {0}", ex.Message)); + Logging.LogMessage(new ValidationError(ValidationErrorCode.JsonParserException, file.FullName, "JSON parser error: {0}", ex.Message)); } catch (Exception ex) { - Logging.LogMessage(new ValidationWarning(ValidationErrorCode.JsonParserException, file.FullName, "Exception reading file: {0}", ex.Message)); + Logging.LogMessage(new ValidationError(ValidationErrorCode.JsonParserException, file.FullName, "Exception reading file: {0}", ex.Message)); } } diff --git a/ApiDoctor.Validation/DocumentHeader.cs b/ApiDoctor.Validation/DocumentHeader.cs new file mode 100644 index 00000000..8c22f91b --- /dev/null +++ b/ApiDoctor.Validation/DocumentHeader.cs @@ -0,0 +1,89 @@ +using Fastenshtein; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; + +namespace ApiDoctor.Validation +{ + public class DocumentHeader + { + /// + /// Represents the header level using markdown formatting (1=#, 2=##, 3=###, 4=####, 5=#####, 6=######) + /// + [JsonProperty("level")] + public int Level { get; set; } + + /// + /// Indicates that a header at this level is required. + /// + [JsonProperty("required")] + public bool Required { get; set; } + + /// + /// The expected value of a title or empty to indicate any value + /// + [JsonProperty("title")] + public string Title { get; set; } + + /// + /// Specifies the headers that are allowed/found under this header. + /// + [JsonProperty("headers")] + public List ChildHeaders { get; set; } = new List(); + + public DocumentHeader() { } + + public DocumentHeader(DocumentHeader original) + { + Level = original.Level; + Required = original.Required; + Title = original.Title; + + if (original.ChildHeaders != null) + { + ChildHeaders = []; + foreach (var header in original.ChildHeaders) + { + ChildHeaders.Add(new DocumentHeader(header)); + } + } + } + + internal bool Matches(DocumentHeader found, bool ignoreCase = false, bool checkStringDistance = false) + { + if (checkStringDistance) + return IsMisspelt(found); + + return this.Level == found.Level && DoTitlesMatch(this.Title, found.Title, ignoreCase); + } + + private static bool DoTitlesMatch(string expectedTitle, string foundTitle, bool ignoreCase) + { + StringComparison comparisonType = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + if (expectedTitle.Equals(foundTitle, comparisonType)) + return true; + + if (string.IsNullOrEmpty(expectedTitle) || expectedTitle == "*") + return true; + + if (expectedTitle.StartsWith("* ") && foundTitle.EndsWith(expectedTitle[2..], comparisonType)) + return true; + + if (expectedTitle.EndsWith(" *") && foundTitle.StartsWith(expectedTitle[..^2], comparisonType)) + return true; + + return false; + } + + internal bool IsMisspelt(DocumentHeader found) + { + return this.Level == found.Level && Levenshtein.Distance(this.Title, found.Title) < 3; + } + + public override string ToString() + { + return this.Title; + } + } +} diff --git a/ApiDoctor.Validation/Error/validationerror.cs b/ApiDoctor.Validation/Error/validationerror.cs index 032190e1..5bdb3ed5 100644 --- a/ApiDoctor.Validation/Error/validationerror.cs +++ b/ApiDoctor.Validation/Error/validationerror.cs @@ -113,6 +113,8 @@ public enum ValidationErrorCode ExtraDocumentHeaderFound, RequiredDocumentHeaderMissing, DocumentHeaderInWrongPosition, + DocumentHeaderInWrongCase, + MisspeltDocumentHeader, RequiredYamlHeaderMissing, IncorrectYamlHeaderFormat, SkippedSimilarErrors, diff --git a/ApiDoctor.Validation/ExtensionMethods.cs b/ApiDoctor.Validation/ExtensionMethods.cs index a2e75e71..81127053 100644 --- a/ApiDoctor.Validation/ExtensionMethods.cs +++ b/ApiDoctor.Validation/ExtensionMethods.cs @@ -1,42 +1,42 @@ /* - * API Doctor - * Copyright (c) Microsoft Corporation - * All rights reserved. - * - * MIT License - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the ""Software""), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, - * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A - * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT - * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION - * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE - * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ +* API Doctor +* Copyright (c) Microsoft Corporation +* All rights reserved. +* +* MIT License +* +* Permission is hereby granted, free of charge, to any person obtaining a copy of +* this software and associated documentation files (the ""Software""), to deal in +* the Software without restriction, including without limitation the rights to use, +* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +* Software, and to permit persons to whom the Software is furnished to do so, +* subject to the following conditions: +* +* The above copyright notice and this permission notice shall be included in all +* copies or substantial portions of the Software. +* +* THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +* INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +* PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ namespace ApiDoctor.Validation { using System; using System.Collections.Generic; using System.Diagnostics; + using System.Globalization; using System.IO; using System.Linq; using System.Text; using System.Text.RegularExpressions; - using ApiDoctor.Validation.Error; - using ApiDoctor.Validation.Json; using MarkdownDeep; + using Newtonsoft.Json; using Newtonsoft.Json.Linq; - using System.Globalization; + using ApiDoctor.Validation.Error; using ApiDoctor.Validation.OData.Transformation; public static class ExtensionMethods @@ -50,9 +50,9 @@ public static class ExtensionMethods private static readonly Regex markdownLinkRegex = new(@"\[(.*?)\]((\(.*?\))|(\s*:\s*\w+))", RegexOptions.Compiled); private static readonly Regex markdownBoldRegex = new(@"\*\*(.*?)\*\*", RegexOptions.Compiled); - + private static readonly Regex markdownItalicRegex = new(@"_(.*?)_", RegexOptions.Compiled); - + private static readonly Regex markdownCodeRegex = new(@"`(.*?)`", RegexOptions.Compiled); private static readonly string[] Iso8601Formats = @@ -194,7 +194,7 @@ public static void IntersectInPlace(this IList source, IList otherSet) } } - public static bool TryGetPropertyValue(this JContainer container, string propertyName, out T value ) where T : class + public static bool TryGetPropertyValue(this JContainer container, string propertyName, out T value) where T : class { JProperty prop; if (TryGetProperty(container, propertyName, out prop)) @@ -238,14 +238,14 @@ public static string RemoveMarkdownStyling(this string markdownText) markdownText = markdownBoldRegex.Replace(markdownText, "$1"); // Remove italic (_text_) - markdownText = markdownItalicRegex .Replace(markdownText, "$1"); + markdownText = markdownItalicRegex.Replace(markdownText, "$1"); // Remove code (`code`) - markdownText = markdownCodeRegex .Replace(markdownText, "$1"); + markdownText = markdownCodeRegex.Replace(markdownText, "$1"); // Remove links [text](link_target) or [text]: link_reference markdownText = markdownLinkRegex.Replace(markdownText, "$1"); - + return markdownText; } @@ -308,7 +308,7 @@ public static string ValueForColumn(this string[] rowValues, IMarkdownTable tabl } } - Debug.WriteLine("Failed to find header matching '{0}' in table with headers: {1}", + Debug.WriteLine("Failed to find header matching '{0}' in table with headers: {1}", possibleHeaderNames.ComponentsJoinedByString(","), table.ColumnHeaders.ComponentsJoinedByString(",")); return null; @@ -330,7 +330,7 @@ public static int IndexOf(this string[] array, string value, StringComparison co /// /// /// - public static ParameterDataType ParseParameterDataType(this string value, bool isCollection = false, Action addErrorAction = null, ParameterDataType defaultValue = null ) + public static ParameterDataType ParseParameterDataType(this string value, bool isCollection = false, Action addErrorAction = null, ParameterDataType defaultValue = null) { const string collectionPrefix = "collection("; const string collectionOfPrefix = "collection of"; @@ -360,7 +360,7 @@ public static ParameterDataType ParseParameterDataType(this string value, bool i // Value could have markdown formatting in it, so we do some basic work to try and remove that if it exists if (value.IndexOf('[') != -1) { - value = value.TextBetweenCharacters('[', ']'); + value = value.TextBetweenCharacters('[', ']'); } SimpleDataType simpleType = ParseSimpleTypeString(value.ToLowerInvariant()); @@ -575,7 +575,6 @@ public static bool IsHeaderBlock(this Block block) } } - public static void SplitUrlComponents(this string inputUrl, out string path, out string queryString) { int index = inputUrl.IndexOf('?'); @@ -646,11 +645,11 @@ public static string TextBetweenCharacters(this string source, char first, char if (startIndex == -1) return source; - int endIndex = source.IndexOf(second, startIndex+1); + int endIndex = source.IndexOf(second, startIndex + 1); if (endIndex == -1) - return source.Substring(startIndex+1); + return source.Substring(startIndex + 1); else - return source.Substring(startIndex+1, endIndex - startIndex - 1); + return source.Substring(startIndex + 1, endIndex - startIndex - 1); } public static string TextBetweenCharacters(this string source, char character) @@ -671,7 +670,7 @@ public static string ReplaceTextBetweenCharacters( char first, char second, string replacement, - bool requireSecondChar = true, + bool requireSecondChar = true, bool removeTargetChars = false) { StringBuilder output = new StringBuilder(source); @@ -701,7 +700,7 @@ public static string ReplaceTextBetweenCharacters( output.Remove(i + 1, j - i - (foundLastChar ? 1 : 0)); output.Insert(i + 1, replacement); } - + i += replacement.Length; } } @@ -715,7 +714,7 @@ internal static ExpectedStringFormat StringFormat(this ParameterDefinition param if (param.Type != ParameterDataType.String) return ExpectedStringFormat.Generic; - if (param.OriginalValue == "timestamp" || param.OriginalValue == "datetime" || param.OriginalValue.Contains("timestamp") ) + if (param.OriginalValue == "timestamp" || param.OriginalValue == "datetime" || param.OriginalValue.Contains("timestamp")) return ExpectedStringFormat.Iso8601Date; if (param.OriginalValue == "url" || param.OriginalValue == "absolute url") return ExpectedStringFormat.AbsoluteUrl; diff --git a/ApiDoctor.Validation/TableSpec/tablespecconverter.cs b/ApiDoctor.Validation/TableSpec/tablespecconverter.cs index b6885653..dc03eeed 100644 --- a/ApiDoctor.Validation/TableSpec/tablespecconverter.cs +++ b/ApiDoctor.Validation/TableSpec/tablespecconverter.cs @@ -75,7 +75,7 @@ public static TableSpecConverter FromDefaultConfiguration() /// /// Convert a tablespec block into one of our internal object model representations /// - public TableDefinition ParseTableSpec(Block tableSpecBlock, Stack headerStack, IssueLogger issues) + public TableDefinition ParseTableSpec(Block tableSpecBlock, Stack headerStack, IssueLogger issues) { List discoveredErrors = new List(); List items = new List(); @@ -250,7 +250,7 @@ private static IEnumerable ParseAuthScopeTable(IMarkdownTab return records; } - private TableDecoder FindDecoderFromHeaderText(Stack headerStack) + private TableDecoder FindDecoderFromHeaderText(Stack headerStack) { foreach (var kvp in this.CommonHeaderContentMap) {