diff --git a/.gitignore b/.gitignore index 968a077..29cdd6b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ bin obj .ionide/ artifacts/ -*/*.user \ No newline at end of file +*/*.user +*.received.* diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..c411604 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/DocsPortingTool.sln b/DocsPortingTool.sln index b39f52d..bd0be87 100644 --- a/DocsPortingTool.sln +++ b/DocsPortingTool.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.28705.295 +# Visual Studio Version 17 +VisualStudioVersion = 17.1.31926.61 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Libraries", "Libraries\Libraries.csproj", "{87BBF4FD-260C-4AC4-802B-7D2B29629C07}" EndProject @@ -11,9 +11,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution ProjectSection(SolutionItems) = preProject .gitignore = .gitignore BackportInstructions.md = BackportInstructions.md + Directory.Build.targets = Directory.Build.targets .github\workflows\dotnet.yml = .github\workflows\dotnet.yml global.json = global.json install-as-tool.ps1 = install-as-tool.ps1 + Packages.props = Packages.props README.md = README.md EndProjectSection EndProject diff --git a/Libraries/Docs/APIKind.cs b/Libraries/Docs/APIKind.cs index 00f554e..5781f84 100644 --- a/Libraries/Docs/APIKind.cs +++ b/Libraries/Docs/APIKind.cs @@ -1,6 +1,6 @@ namespace Libraries.Docs { - internal enum APIKind + public enum APIKind { Type, Member diff --git a/Libraries/Docs/DocsAPI.cs b/Libraries/Docs/DocsAPI.cs index 6ba7b8f..c78169d 100644 --- a/Libraries/Docs/DocsAPI.cs +++ b/Libraries/Docs/DocsAPI.cs @@ -6,8 +6,11 @@ namespace Libraries.Docs { - internal abstract class DocsAPI : IDocsAPI + public abstract class DocsAPI : IDocsAPI { + private DocsSummary? _summary; + private DocsRemarks? _remarks; + private List? _examples; private List? _params; private List? _parameters; private List? _typeParameters; @@ -156,7 +159,7 @@ public List AltMembers { if (Docs != null) { - _altMemberCrefs = Docs.Elements("altmember").Select(x => XmlHelper.GetAttributeValue(x, "cref").DocIdEscaped()).ToList(); + _altMemberCrefs = Docs.Elements("altmember").Select(x => XmlHelper.GetAttributeValue(x, "cref")).ToList(); } else { @@ -190,8 +193,71 @@ public List Relateds public abstract string ReturnType { get; } public abstract string Returns { get; set; } + public DocsSummary SummaryElement + { + get + { + if (_summary == null) + { + XElement? xe = Docs?.Element("summary"); + + if (xe != null) + { + _summary = new(xe); + } + else + { + throw new InvalidOperationException($"There was no element. Doc ID: {DocId}"); + } + } + + return _summary; + } + } + public abstract string Remarks { get; set; } + public DocsRemarks RemarksElement + { + get + { + if (_remarks == null) + { + XElement? xe = Docs?.Element("remarks"); + + if (xe != null) + { + _remarks = new(xe); + + if (!_remarks.ExampleContent?.ParsedText?.IsDocsEmpty() ?? false) + { + ExampleElements.Add(_remarks.ExampleContent!); + } + } + else + { + _remarks = new(new XElement("remarks")); + } + } + + return _remarks; + } + } + + public List ExampleElements + { + get + { + if (_examples == null) + { + IEnumerable elems = Docs.Elements("example"); + _examples = elems.Select(e => new DocsExample(e)).ToList(); + } + + return _examples; + } + } + public List AssemblyInfos { get @@ -206,10 +272,10 @@ public List AssemblyInfos public DocsParam SaveParam(XElement xeIntelliSenseXmlParam) { - XElement xeDocsParam = new XElement(xeIntelliSenseXmlParam.Name); + XElement xeDocsParam = new(xeIntelliSenseXmlParam.Name); xeDocsParam.ReplaceAttributes(xeIntelliSenseXmlParam.Attributes()); XmlHelper.SaveFormattedAsXml(xeDocsParam, xeIntelliSenseXmlParam.Value); - DocsParam docsParam = new DocsParam(this, xeDocsParam); + DocsParam docsParam = new(this, xeDocsParam); Changed = true; return docsParam; } @@ -229,7 +295,7 @@ public APIKind Kind public DocsTypeParam AddTypeParam(string name, string value) { - XElement typeParam = new XElement("typeparam"); + XElement typeParam = new("typeparam"); typeParam.SetAttributeValue("name", name); XmlHelper.AddChildFormattedAsXml(Docs, typeParam, value); Changed = true; diff --git a/Libraries/Docs/DocsApiReference.cs b/Libraries/Docs/DocsApiReference.cs new file mode 100644 index 0000000..733916c --- /dev/null +++ b/Libraries/Docs/DocsApiReference.cs @@ -0,0 +1,129 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text.RegularExpressions; +using static System.Net.WebUtility; + +namespace Libraries.Docs +{ + public class DocsApiReference + { + public bool IsOverload { get; private init; } + + public char? Prefix { get; private init; } + + public string Api { get; private init; } + + // Generic parameters need to support both single and double backtick conventions + private const string GenericParameterPattern = @"`{1,2}(?\d+)"; + private const string ApiChars = @"[A-Za-z0-9\-\._~:\/#\[\]\{\}@!\$&'\(\)\*\+,;`%]"; + private const string ApiReferencePattern = @"((?[A-Za-z]):)?(?(" + ApiChars + @")+)?(?\?(" + ApiChars + @")+=(" + ApiChars + @")+)?"; + private static readonly Regex XrefPattern = new("" + ApiReferencePattern + ")\\s*>", RegexOptions.Compiled); + + public DocsApiReference(string apiReference) + { + Api = UrlDecode(apiReference); + var match = Regex.Match(Api, ApiReferencePattern); + + if (match.Success) + { + Api = match.Groups["api"].Value; + + if (match.Groups["prefix"].Success) + { + Prefix = match.Groups["prefix"].Value[0]; + IsOverload = Prefix == 'O'; + } + } + + if (Api.EndsWith('*')) + { + IsOverload = true; + Api = Api[..^1]; + } + + Api = ReplacePrimitivesWithShorthands(Api); + Api = ParseGenericTypes(Api); + } + + public override string ToString() + { + if (Prefix is not null) + { + return $"{Prefix}:{Api}"; + } + + return Api; + } + + private static readonly Dictionary PrimitiveTypes = new() + { + { "System.Boolean", "bool" }, + { "System.Byte", "byte" }, + { "System.Char", "char" }, + { "System.Decimal", "decimal" }, + { "System.Double", "double" }, + { "System.Int16", "short" }, + { "System.Int32", "int" }, + { "System.Int64", "long" }, + { "System.Object", "object" }, // Ambiguous: could be 'object' or 'dynamic' https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/built-in-types + { "System.SByte", "sbyte" }, + { "System.Single", "float" }, + { "System.String", "string" }, + { "System.UInt16", "ushort" }, + { "System.UInt32", "uint" }, + { "System.UInt64", "ulong" }, + { "System.Void", "void" } + }; + + public static string ReplacePrimitivesWithShorthands(string apiReference) + { + foreach ((string key, string value) in PrimitiveTypes) + { + apiReference = Regex.Replace(apiReference, key, value); + } + + return apiReference; + } + + public static string ParseGenericTypes(string apiReference) + { + int genericParameterArity = 0; + return Regex.Replace(apiReference, GenericParameterPattern, MapGenericParameter); + + string MapGenericParameter(Match match) + { + int arity = int.Parse(match.Groups["arity"].Value); + + if (genericParameterArity == 0) + { + // This is the first match that declares the generic parameter arity of the method + // e.g. GenericMethod``3 ---> GenericMethod{T1,T2,T3}(...); + Debug.Assert(arity > 0); + genericParameterArity = arity; + return WrapInCurlyBrackets(string.Join(",", Enumerable.Range(0, arity).Select(CreateGenericParameterName))); + } + + // Subsequent matches are references to generic parameters in the method signature, + // e.g. GenericMethod{T1,T2,T3}(..., List{``1} parameter, ...); ---> List{T2} parameter + return CreateGenericParameterName(arity); + + // This naming scheme does not map to the exact generic parameter names; + // however this is still accepted by intellisense and backporters can rename + // manually with the help of tooling. + string CreateGenericParameterName(int index) => genericParameterArity == 1 ? "T" : $"T{index + 1}"; + + static string WrapInCurlyBrackets(string input) => $"{{{input}}}"; + } + } + + public static string ReplaceMarkdownXrefWithSeeCref(string markdown) + { + return XrefPattern.Replace(markdown, match => + { + var api = new DocsApiReference(match.Groups["api"].Value); + return @$""; + }); + } + } +} diff --git a/Libraries/Docs/DocsAssemblyInfo.cs b/Libraries/Docs/DocsAssemblyInfo.cs index 7840315..6b0c0af 100644 --- a/Libraries/Docs/DocsAssemblyInfo.cs +++ b/Libraries/Docs/DocsAssemblyInfo.cs @@ -4,7 +4,7 @@ namespace Libraries.Docs { - internal class DocsAssemblyInfo + public class DocsAssemblyInfo { private readonly XElement XEAssemblyInfo; public string AssemblyName diff --git a/Libraries/Docs/DocsAttribute.cs b/Libraries/Docs/DocsAttribute.cs index a07ad42..2c83588 100644 --- a/Libraries/Docs/DocsAttribute.cs +++ b/Libraries/Docs/DocsAttribute.cs @@ -1,8 +1,9 @@ -using System.Xml.Linq; +using System.Linq; +using System.Xml.Linq; namespace Libraries.Docs { - internal class DocsAttribute + public class DocsAttribute { private readonly XElement XEAttribute; @@ -13,12 +14,15 @@ public string FrameworkAlternate return XmlHelper.GetAttributeValue(XEAttribute, "FrameworkAlternate"); } } - public string AttributeName + + public string? AttributeName { - get - { - return XmlHelper.GetChildElementValue(XEAttribute, "AttributeName"); - } + get => GetAttributeName("C#"); + } + + public string? GetAttributeName(string language) + { + return XEAttribute.Elements("AttributeName").Where(x => XmlHelper.GetAttributeValue(x, "Language") == language).SingleOrDefault()?.Value; } public DocsAttribute(XElement xeAttribute) diff --git a/Libraries/Docs/DocsCommentsContainer.cs b/Libraries/Docs/DocsCommentsContainer.cs index a8ab30a..880e7ae 100644 --- a/Libraries/Docs/DocsCommentsContainer.cs +++ b/Libraries/Docs/DocsCommentsContainer.cs @@ -8,14 +8,14 @@ namespace Libraries.Docs { - internal class DocsCommentsContainer + public class DocsCommentsContainer { private Configuration Config { get; set; } private XDocument? xDoc = null; - public readonly Dictionary Types = new(); - public readonly Dictionary Members = new(); + internal readonly Dictionary Types = new(); + internal readonly Dictionary Members = new(); public DocsCommentsContainer(Configuration config) { diff --git a/Libraries/Docs/DocsExample.cs b/Libraries/Docs/DocsExample.cs new file mode 100644 index 0000000..ebe0a01 --- /dev/null +++ b/Libraries/Docs/DocsExample.cs @@ -0,0 +1,21 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace Libraries.Docs +{ + public class DocsExample : DocsMarkdownElement + { + public DocsExample(XElement xeExample) : base(xeExample) + { + } + + protected override string ExtractElements(string markdown) + { + markdown = base.ExtractElements(markdown); + markdown = RemoveMarkdownHeading(markdown, "Examples?"); + + return markdown; + } + } +} diff --git a/Libraries/Docs/DocsException.cs b/Libraries/Docs/DocsException.cs index d5b26cd..0266840 100644 --- a/Libraries/Docs/DocsException.cs +++ b/Libraries/Docs/DocsException.cs @@ -5,7 +5,7 @@ namespace Libraries.Docs { - internal class DocsException + public class DocsException { private readonly XElement XEException; diff --git a/Libraries/Docs/DocsMarkdownElement.cs b/Libraries/Docs/DocsMarkdownElement.cs new file mode 100644 index 0000000..b971dc6 --- /dev/null +++ b/Libraries/Docs/DocsMarkdownElement.cs @@ -0,0 +1,141 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace Libraries.Docs +{ + public abstract class DocsMarkdownElement : DocsTextElement + { + public DocsMarkdownElement(XElement xeRemarks) : base(xeRemarks) + { + } + + public IEnumerable? Params { get; init; } + public IEnumerable? TypeParams { get; init; } + + private static readonly Regex IncludeFilePattern = new(@"\[!INCLUDE", RegexOptions.Compiled); + private static readonly Regex CalloutPattern = new(@"\[!NOTE|\[!IMPORTANT|\[!TIP", RegexOptions.Compiled); + private static readonly Regex CodeIncludePattern = new(@"\[!code-cpp|\[!code-csharp|\[!code-vb", RegexOptions.Compiled); + + private static readonly Regex MarkdownLinkPattern = new(@"\[(?.+)\]\((?(http|www)([A-Za-z0-9\-\._~:\/#\[\]\{\}@!\$&'\(\)\*\+,;\?=%])+)\)", RegexOptions.Compiled); + private const string MarkdownLinkReplacement = "${linkText}"; + + private static readonly Regex MarkdownBoldPattern = new(@"\*\*(?[A-Za-z0-9\-\._~:\/#\[\]@!\$&'\(\)\+,;%` ]+)\*\*", RegexOptions.Compiled); + private const string MarkdownBoldReplacement = @"${content}"; + + private static readonly Regex MarkdownCodeStartPattern = new(@"```(?(cs|csharp|cpp|vb|visualbasic))(?\s+)", RegexOptions.Compiled); + private const string MarkdownCodeStartReplacement = "${spaces}"; + + private static readonly Regex MarkdownCodeEndPattern = new(@"```(?\s+)", RegexOptions.Compiled); + private const string MarkdownCodeEndReplacement = "${spaces}"; + + private static readonly Regex UnparseableMarkdown = new(string.Join('|', new[] { + IncludeFilePattern.ToString(), + CalloutPattern.ToString(), + CodeIncludePattern.ToString() + }), RegexOptions.Compiled); + + protected override string? ParseNode(XNode node) + { + if (node is XElement element && element.Name == "format" && element.Attribute("type")?.Value == "text/markdown") + { + var cdata = element.FirstNode as XCData; + var markdown = cdata?.Value ?? element.Value; + + markdown = ExtractElements(markdown); + + // If we're able to fully parse the markdown, then + // we can just return the parsed text + if (TryParseMarkdown(markdown, out var parsedText)) + { + return parsedText; + } + else + { + // But if the markdown couldn't be fully parsed, + // then update the element with the markdown + // that remains after extracting other elements, + // retaining the CDATA wrapper if it was present + if (cdata is not null) + { + cdata.Value = markdown; + } + else + { + element.Value = markdown; + } + } + } + + return base.ParseNode(node); + } + + protected string RemoveMarkdownHeading(string markdown, string heading) + { + Regex HeadingPattern = new(@$"^\s*##\s*{heading}\s*$", RegexOptions.IgnoreCase | RegexOptions.Multiline); + return HeadingPattern.Replace(markdown, "", 1); + } + + protected virtual string ExtractElements(string markdown) => markdown; + + protected virtual bool TryParseMarkdown(string markdown, [NotNullWhen(true)] out string? parsed) + { + if (UnparseableMarkdown.IsMatch(markdown)) + { + parsed = null; + return false; + } + + parsed = DocsApiReference.ReplaceMarkdownXrefWithSeeCref(markdown); + parsed = MarkdownLinkPattern.Replace(parsed, MarkdownLinkReplacement); + parsed = MarkdownBoldPattern.Replace(parsed, MarkdownBoldReplacement); + parsed = MarkdownCodeStartPattern.Replace(parsed, MarkdownCodeStartReplacement); + parsed = MarkdownCodeEndPattern.Replace(parsed, MarkdownCodeEndReplacement); + parsed = ReplaceBacktickReferences(parsed); + + return true; + } + + private static readonly string[] ReservedKeywords = new[] { "abstract", "async", "await", "false", "null", "sealed", "static", "true", "virtual" }; + + private string ReplaceBacktickReferences(string markdown) + { + // langwords|parameters|typeparams and other type references within markdown backticks + MatchCollection collection = Regex.Matches(markdown, @"(?`(?[a-zA-Z0-9_\.]+(?\<(?[a-zA-Z0-9_,]+)\>){0,1})`)"); + foreach (Match match in collection) + { + string backtickContent = match.Groups["backtickContent"].Value; + string backtickedApi = match.Groups["backtickedApi"].Value; + Group genericType = match.Groups["genericType"]; + Group typeParam = match.Groups["typeParam"]; + + if (genericType.Success && typeParam.Success) + { + backtickedApi = backtickedApi.Replace(genericType.Value, $"{{{typeParam.Value}}}"); + } + + if (ReservedKeywords.Any(x => x == backtickedApi)) + { + markdown = Regex.Replace(markdown, $"{backtickContent}", $""); + } + else if (TypeParams?.Any(x => x.Name == backtickedApi) == true) + { + markdown = Regex.Replace(markdown, $"{backtickContent}", $""); + } + else if (Params?.Any(x => x.Name == backtickedApi) == true) + { + markdown = Regex.Replace(markdown, $"{backtickContent}", $""); + } + else + { + markdown = Regex.Replace(markdown, $"{backtickContent}", $""); + } + } + + return markdown; + } + } +} + diff --git a/Libraries/Docs/DocsMember.cs b/Libraries/Docs/DocsMember.cs index c7a5304..19ccf52 100644 --- a/Libraries/Docs/DocsMember.cs +++ b/Libraries/Docs/DocsMember.cs @@ -1,11 +1,12 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Xml.Linq; namespace Libraries.Docs { - internal class DocsMember : DocsAPI + public class DocsMember : DocsAPI { private string? _memberName; private List? _memberSignatures; @@ -65,7 +66,9 @@ public override string DocId string message = string.Format("Could not find a DocId MemberSignature for '{0}'", MemberName); throw new Exception(message); } - _docId = ms.Value.DocIdEscaped(); + + // This value must not be unescaped + _docId = ms.Value; } return _docId; } diff --git a/Libraries/Docs/DocsMemberSignature.cs b/Libraries/Docs/DocsMemberSignature.cs index f9eff57..0a2dc46 100644 --- a/Libraries/Docs/DocsMemberSignature.cs +++ b/Libraries/Docs/DocsMemberSignature.cs @@ -2,7 +2,7 @@ namespace Libraries.Docs { - internal class DocsMemberSignature + public class DocsMemberSignature { private readonly XElement XEMemberSignature; diff --git a/Libraries/Docs/DocsParam.cs b/Libraries/Docs/DocsParam.cs index c5a09b2..6a1591e 100644 --- a/Libraries/Docs/DocsParam.cs +++ b/Libraries/Docs/DocsParam.cs @@ -2,7 +2,7 @@ namespace Libraries.Docs { - internal class DocsParam + public class DocsParam : DocsTextElement { private readonly XElement XEDocsParam; public IDocsAPI ParentAPI @@ -28,7 +28,7 @@ public string Value ParentAPI.Changed = true; } } - public DocsParam(IDocsAPI parentAPI, XElement xeDocsParam) + public DocsParam(IDocsAPI parentAPI, XElement xeDocsParam) : base(xeDocsParam) { ParentAPI = parentAPI; XEDocsParam = xeDocsParam; diff --git a/Libraries/Docs/DocsParameter.cs b/Libraries/Docs/DocsParameter.cs index ec598b8..a4a090b 100644 --- a/Libraries/Docs/DocsParameter.cs +++ b/Libraries/Docs/DocsParameter.cs @@ -2,7 +2,7 @@ namespace Libraries.Docs { - internal class DocsParameter + public class DocsParameter : DocsTextElement { private readonly XElement XEParameter; public string Name @@ -19,7 +19,7 @@ public string Type return XmlHelper.GetAttributeValue(XEParameter, "Type"); } } - public DocsParameter(XElement xeParameter) + public DocsParameter(XElement xeParameter) : base(xeParameter) { XEParameter = xeParameter; } diff --git a/Libraries/Docs/DocsRelated.cs b/Libraries/Docs/DocsRelated.cs index 360b1f6..fabf748 100644 --- a/Libraries/Docs/DocsRelated.cs +++ b/Libraries/Docs/DocsRelated.cs @@ -2,7 +2,7 @@ namespace Libraries.Docs { - internal class DocsRelated + public class DocsRelated { private readonly XElement XERelatedArticle; diff --git a/Libraries/Docs/DocsRemarks.cs b/Libraries/Docs/DocsRemarks.cs new file mode 100644 index 0000000..d1a85f8 --- /dev/null +++ b/Libraries/Docs/DocsRemarks.cs @@ -0,0 +1,60 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace Libraries.Docs +{ + public class DocsRemarks : DocsMarkdownElement + { + public DocsRemarks(XElement xeRemarks) : base(xeRemarks) + { + } + + private DocsExample? _exampleContent; + + public DocsExample? ExampleContent + { + get + { + EnsureParsed(); + return _exampleContent; + } + set + { + _exampleContent = value; + } + } + + private static readonly Regex ExampleSectionPattern = new(@"^\s*##\s*Examples?\s*(?.*)", RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Compiled); + + protected override string ExtractElements(string markdown) + { + markdown = base.ExtractElements(markdown); + markdown = RemoveMarkdownHeading(markdown, "Remarks"); + markdown = ExtractExamples(markdown); + + return markdown; + } + + private string ExtractExamples(string markdown) + { + var match = ExampleSectionPattern.Match(markdown); + + if (match.Success) + { + string exampleContent = match.Groups["examples"].Value; + string exampleXml = $@""; + + // Extract the examples (as a side effect) + ExampleContent = new DocsExample(XElement.Parse(exampleXml)); + + // Return all of the markdown content before the examples begin + return markdown.Substring(0, match.Index); + } + + return markdown; + } + } +} diff --git a/Libraries/Docs/DocsSummary.cs b/Libraries/Docs/DocsSummary.cs new file mode 100644 index 0000000..45e4ade --- /dev/null +++ b/Libraries/Docs/DocsSummary.cs @@ -0,0 +1,13 @@ +using System; +using System.Linq; +using System.Xml.Linq; + +namespace Libraries.Docs +{ + public class DocsSummary : DocsTextElement + { + public DocsSummary(XElement xeSummary) : base(xeSummary) + { + } + } +} diff --git a/Libraries/Docs/DocsTextElement.cs b/Libraries/Docs/DocsTextElement.cs new file mode 100644 index 0000000..be4cf08 --- /dev/null +++ b/Libraries/Docs/DocsTextElement.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.RegularExpressions; +using System.Xml; +using System.Xml.Linq; + +namespace Libraries.Docs +{ + public abstract class DocsTextElement + { + private readonly XElement Element; + private IEnumerable? _parsedNodes; + private string[]? _parsedLines; + private string? _parsedText; + + public string RawText { get; private init; } + + public IEnumerable RawNodes { get; private init; } + + public IEnumerable ParsedNodes + { + get + { + EnsureParsed(); + return _parsedNodes; + } + } + + public string ParsedText + { + get + { + EnsureParsed(); + return _parsedText; + } + } + + public string[] ParsedTextLines + { + get + { + EnsureParsed(); + return _parsedLines; + } + } + + [MemberNotNull(nameof(_parsedNodes), nameof(_parsedText), nameof(_parsedLines))] + protected void EnsureParsed() + { + if (_parsedNodes is null || _parsedText is null || _parsedLines is null) + { + // Clone the element for non-mutating parsing + var cloned = XElement.Parse(Element.ToString()).Nodes(); + + // Parse each node and filter out nulls, building a block of text + _parsedNodes = cloned.Select(ParseNode).OfType().ToArray(); + var allNodeContent = string.Join("", _parsedNodes); + + // Normalize line endings, trim lines, remove empty lines, and join back into 1 string + allNodeContent = Regex.Replace(allNodeContent, "\r?\n", Environment.NewLine); + _parsedLines = allNodeContent.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + _parsedText = string.Join(Environment.NewLine, _parsedLines); + } + } + + public DocsTextElement(XElement element) + { + Element = element; + RawNodes = element.Nodes(); + RawText = string.Join("", RawNodes); + } + + protected virtual string? ParseNode(XNode node) => + RewriteDocReferences(node).ToString(); + + protected static XNode RewriteDocReferences(XNode node) + { + if (node is XElement element && element.Name == "see") + { + var cref = element.Attribute("cref"); + + if (cref is not null) + { + var apiReference = new DocsApiReference(cref.Value); + cref.SetValue(apiReference.Api); + } + } + + return node; + } + } +} diff --git a/Libraries/Docs/DocsTextFormat.cs b/Libraries/Docs/DocsTextFormat.cs new file mode 100644 index 0000000..8ee2782 --- /dev/null +++ b/Libraries/Docs/DocsTextFormat.cs @@ -0,0 +1,8 @@ +namespace Libraries.Docs +{ + public enum DocsTextFormat + { + PlainText, + Markdown + } +} diff --git a/Libraries/Docs/DocsType.cs b/Libraries/Docs/DocsType.cs index 010faa9..ce9f833 100644 --- a/Libraries/Docs/DocsType.cs +++ b/Libraries/Docs/DocsType.cs @@ -9,7 +9,7 @@ namespace Libraries.Docs /// /// Represents the root xml element (unique) of a Docs xml file, called Type. /// - internal class DocsType : DocsAPI + public class DocsType : DocsAPI { private string? _typeName; private string? _name; diff --git a/Libraries/Docs/DocsTypeParam.cs b/Libraries/Docs/DocsTypeParam.cs index af2ea7a..cf5f23d 100644 --- a/Libraries/Docs/DocsTypeParam.cs +++ b/Libraries/Docs/DocsTypeParam.cs @@ -6,7 +6,7 @@ namespace Libraries.Docs /// /// Each one of these typeparam objects live inside the Docs section inside the Member object. /// - internal class DocsTypeParam + public class DocsTypeParam : DocsTextElement { private readonly XElement XEDocsTypeParam; public IDocsAPI ParentAPI @@ -35,7 +35,7 @@ public string Value } } - public DocsTypeParam(IDocsAPI parentAPI, XElement xeDocsTypeParam) + public DocsTypeParam(IDocsAPI parentAPI, XElement xeDocsTypeParam) : base(xeDocsTypeParam) { ParentAPI = parentAPI; XEDocsTypeParam = xeDocsTypeParam; diff --git a/Libraries/Docs/DocsTypeParameter.cs b/Libraries/Docs/DocsTypeParameter.cs index 73a2e1e..70364a1 100644 --- a/Libraries/Docs/DocsTypeParameter.cs +++ b/Libraries/Docs/DocsTypeParameter.cs @@ -7,7 +7,7 @@ namespace Libraries.Docs /// /// Each one of these TypeParameter objects islocated inside the TypeParameters section inside the Member. /// - internal class DocsTypeParameter + public class DocsTypeParameter { private readonly XElement XETypeParameter; public string Name @@ -56,6 +56,18 @@ public string ConstraintsBaseTypeName } } + public string ConstraintsInterfaceName + { + get + { + if (Constraints != null) + { + return XmlHelper.GetChildElementValue(Constraints, "InterfaceName"); + } + return string.Empty; + } + } + public DocsTypeParameter(XElement xeTypeParameter) { XETypeParameter = xeTypeParameter; diff --git a/Libraries/Docs/DocsTypeSignature.cs b/Libraries/Docs/DocsTypeSignature.cs index 5ca5c46..1f78c72 100644 --- a/Libraries/Docs/DocsTypeSignature.cs +++ b/Libraries/Docs/DocsTypeSignature.cs @@ -2,7 +2,7 @@ namespace Libraries.Docs { - internal class DocsTypeSignature + public class DocsTypeSignature { private readonly XElement XETypeSignature; diff --git a/Libraries/Docs/IDocsAPI.cs b/Libraries/Docs/IDocsAPI.cs index afd4dd6..50bde84 100644 --- a/Libraries/Docs/IDocsAPI.cs +++ b/Libraries/Docs/IDocsAPI.cs @@ -3,7 +3,7 @@ namespace Libraries.Docs { - internal interface IDocsAPI + public interface IDocsAPI { public abstract APIKind Kind { get; } public abstract bool IsUndocumented { get; } diff --git a/Libraries/IntelliSenseXml/IntelliSenseXmlMember.cs b/Libraries/IntelliSenseXml/IntelliSenseXmlMember.cs index 79970d2..f048be5 100644 --- a/Libraries/IntelliSenseXml/IntelliSenseXmlMember.cs +++ b/Libraries/IntelliSenseXml/IntelliSenseXmlMember.cs @@ -165,8 +165,8 @@ public string Returns { if (_returns == null) { - XElement? xElement = XEMember.Element("returns"); - _returns = (xElement != null) ? XmlHelper.GetNodesInPlainText(xElement) : string.Empty; + XElement? xElement = XEMember.Element("returns"); + _returns = (xElement != null) ? XmlHelper.GetNodesInPlainText(xElement) : string.Empty; } return _returns; } @@ -179,8 +179,8 @@ public string Remarks { if (_remarks == null) { - XElement? xElement = XEMember.Element("remarks"); - _remarks = (xElement != null) ? XmlHelper.GetNodesInPlainText(xElement) : string.Empty; + XElement? xElement = XEMember.Element("remarks"); + _remarks = (xElement != null) ? XmlHelper.GetNodesInPlainText(xElement) : string.Empty; } return _remarks; } diff --git a/Libraries/Libraries.csproj b/Libraries/Libraries.csproj index c898e37..75183c6 100644 --- a/Libraries/Libraries.csproj +++ b/Libraries/Libraries.csproj @@ -6,21 +6,19 @@ Microsoft carlossanlop enable + enable - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - + + + + + + + + + + diff --git a/Libraries/Log.cs b/Libraries/Log.cs index 2574a79..16ac859 100644 --- a/Libraries/Log.cs +++ b/Libraries/Log.cs @@ -18,10 +18,12 @@ public static void Print(bool endline, ConsoleColor foregroundColor, string form string msg = args != null ? (args.Length > 0 ? string.Format(format, args) : format) : format; if (endline) { + Debug.WriteLine($"[DPT]: {msg}"); Console.WriteLine(msg); } else { + Debug.Write(msg); Console.Write(msg); } Console.ForegroundColor = originalColor; diff --git a/Libraries/RoslynTripleSlash/LeadingTriviaRewriter.cs b/Libraries/RoslynTripleSlash/LeadingTriviaRewriter.cs new file mode 100644 index 0000000..7630c99 --- /dev/null +++ b/Libraries/RoslynTripleSlash/LeadingTriviaRewriter.cs @@ -0,0 +1,181 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System; +using System.Collections.Generic; +using System.Linq; +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; + +namespace Libraries.RoslynTripleSlash +{ + public static class LeadingTriviaRewriter + { + private static int[] TriviaAboveDocComments = new[] + { + (int)SyntaxKind.RegionDirectiveTrivia, + (int)SyntaxKind.PragmaWarningDirectiveTrivia, + (int)SyntaxKind.IfDirectiveTrivia, + (int)SyntaxKind.EndIfDirectiveTrivia, + }; + + public static int[] TriviaBelowDocComments = new[] + { + (int)SyntaxKind.SingleLineCommentTrivia, + (int)SyntaxKind.MultiLineCommentTrivia + }; + + private static bool IsDocumentationCommentTrivia(this SyntaxTrivia trivia) => + trivia.IsKind(SyntaxKind.SingleLineDocumentationCommentTrivia) || + trivia.IsKind(SyntaxKind.MultiLineDocumentationCommentTrivia); + + private static bool IsDocumentationCommentTriviaContinuation(this SyntaxTrivia trivia) => + trivia.IsDocumentationCommentTrivia() || + trivia.IsKind(SyntaxKind.EndOfLineTrivia) || + trivia.IsKind(SyntaxKind.WhitespaceTrivia); + + public static SyntaxTriviaList WithoutDocumentationComments(this SyntaxTriviaList trivia) + { + return trivia.WithoutDocumentationComments(out int? _); + } + + public static SyntaxTriviaList GetFinalWhitespace(this SyntaxTriviaList trivia) + { + SyntaxTriviaList indentation = new(); + int index = trivia.Count; + + while (index > 0 && trivia[index - 1].IsKind(SyntaxKind.WhitespaceTrivia)) + { + index--; + indentation = indentation.Insert(0, trivia[index]); + } + + return indentation; + } + + public static SyntaxTriviaList WithoutDocumentationComments(this SyntaxTriviaList trivia, out int? existingDocsPosition) + { + int i = 0; + existingDocsPosition = null; + + // Before we start removing the doc comments, we need to capture any whitespace at + // the very end of the trivia, because it could represent indentation of the API. + SyntaxTriviaList indentation = trivia.GetFinalWhitespace(); + + while (i < trivia.Count) + { + if (trivia[i].IsDocumentationCommentTrivia()) + { + var commentStart = i; + var commentEnd = i; + + // Walk backward through whitespace to find the beginning of the doc comment + // Now walk the doc comment position backward through any of its indentation trivia + while (commentStart > 0 && trivia[commentStart - 1].IsKind(SyntaxKind.WhitespaceTrivia)) + { + commentStart--; + } + + // Walk forward to find the end of the doc comment, but do not go past the + // beginning of the API documentation. + while (commentEnd < trivia.Count - indentation.Count && trivia[commentEnd + 1].IsDocumentationCommentTriviaContinuation()) + { + commentEnd++; + } + + // Finally, walk the end position backthrough any indentation (for other + // lines before the API itself). + while (commentEnd >= commentStart && trivia[commentEnd].IsKind(SyntaxKind.WhitespaceTrivia)) + { + commentEnd--; + } + + // Remove the trivia from beginning to end of this doc comment + while (commentEnd >= commentStart) + { + trivia = trivia.RemoveAt(commentStart); + commentEnd--; + } + + // Capture the first documentation comment position + // If there were disjoint doc comments, we will + // anchor on the first occurrence + existingDocsPosition ??= commentStart; + } + + i++; + } + + return trivia; + } + + public static SyntaxNode ApplyXmlComments(SyntaxNode node, IEnumerable xmlComments) + { + if (!node.HasLeadingTrivia) + { + return node.WithLeadingTrivia(GetXmlCommentLines(xmlComments)); + } + + SyntaxTriviaList leading = node.GetLeadingTrivia().WithoutDocumentationComments(out int? docsPosition); + + if (docsPosition is null) + { + // We will determine the position at which to insert the XML + // comments. We want to find the spot closest to the declaration + // that makes sense, so we walk upward through the leading trivia + // until we find nodes we need to stay beneath. Then, we walk back + // downward until we find the first node to stay above. + docsPosition = leading.Count; + + while (docsPosition > 0 && !TriviaAboveDocComments.Contains(leading[docsPosition.Value - 1].RawKind)) + { + docsPosition--; + } + + while (docsPosition < leading.Count && !TriviaBelowDocComments.Contains(leading[docsPosition.Value].RawKind)) + { + docsPosition++; + } + + // The last step is to walk backwards again through any whitespace + // to get back to the beginning of the line. + while (docsPosition > 0 && leading[docsPosition.Value - 1].IsKind(SyntaxKind.WhitespaceTrivia)) + { + docsPosition--; + } + } + + // We know where the doc comments will be inserted, but they could go in adjacent to + // pragmas or other trivia where the indentation might not match the API being + // documented. Look at the end of the trivia (just before the API), and clone the + // indentation for use in front of each line of documentation comments. + SyntaxTriviaList indentation = leading.GetFinalWhitespace(); + + // Insert the XML comment lines with the collected indentation + return node.WithLeadingTrivia( + leading.InsertRange(docsPosition.Value, GetXmlCommentLines(xmlComments, indentation)) + ); + } + + public static SyntaxTriviaList GetXmlCommentLines(IEnumerable xmlComments, SyntaxTriviaList indentation = new()) + { + SyntaxTriviaList xmlTrivia = new(); + + foreach (var xmlComment in xmlComments) + { + var lines = xmlComment.ToString().Split(Environment.NewLine); + + var commentLines = lines.Select((l, i) => { + var text = XmlText(XmlTextLiteral(l, l)); + var comment = DocumentationComment(text); + var leadingTrivia = comment.GetLeadingTrivia().InsertRange(0, indentation); + + return Trivia(comment.WithLeadingTrivia(leadingTrivia).WithTrailingTrivia(CarriageReturnLineFeed)); + }); + + xmlTrivia = xmlTrivia.AddRange(commentLines); + } + + return xmlTrivia; + } + } +} diff --git a/Libraries/RoslynTripleSlash/TripleSlashSyntaxRewriter.cs b/Libraries/RoslynTripleSlash/TripleSlashSyntaxRewriter.cs index ec8264f..7aa8271 100644 --- a/Libraries/RoslynTripleSlash/TripleSlashSyntaxRewriter.cs +++ b/Libraries/RoslynTripleSlash/TripleSlashSyntaxRewriter.cs @@ -20,7 +20,7 @@ namespace Libraries.RoslynTripleSlash /// My param description. /// My remarks. public ... - + translates to this syntax tree structure: PublicKeyword (SyntaxToken) -> The public keyword including its trivia. @@ -88,63 +88,9 @@ public ... */ internal class TripleSlashSyntaxRewriter : CSharpSyntaxRewriter { - #region Private members - - private static readonly string[] ReservedKeywords = new[] { "abstract", "async", "await", "false", "null", "sealed", "static", "true", "virtual" }; - - private static readonly string[] MarkdownUnconvertableStrings = new[] { "](~/includes", "[!INCLUDE" }; - - private static readonly string[] MarkdownCodeIncludes = new[] { "[!code-cpp", "[!code-csharp", "[!code-vb", }; - - private static readonly string[] MarkdownExamples = new[] { "## Examples", "## Example" }; - - private static readonly string[] MarkdownHeaders = new[] { "[!NOTE]", "[!IMPORTANT]", "[!TIP]" }; - - // Note that we need to support generics that use the ` literal as well as the escaped %60 - private static readonly string ValidRegexChars = @"[A-Za-z0-9\-\._~:\/#\[\]\{\}@!\$&'\(\)\*\+,;]|(%60|`)\d+"; - private static readonly string ValidExtraChars = @"\?="; - - private static readonly string RegexDocIdPattern = @"(?[A-Za-z]{1}:)?(?(" + ValidRegexChars + @")+)(?%2[aA])?(?\?(" + ValidRegexChars + @")+=(" + ValidRegexChars + @")+)?"; - private static readonly string RegexXmlCrefPattern = "cref=\"" + RegexDocIdPattern + "\""; - private static readonly string RegexMarkdownXrefPattern = @"(?)"; - - private static readonly string RegexMarkdownBoldPattern = @"\*\*(?[A-Za-z0-9\-\._~:\/#\[\]@!\$&'\(\)\+,;%` ]+)\*\*"; - private static readonly string RegexXmlBoldReplacement = @"${content}"; - - private static readonly string RegexMarkdownLinkPattern = @"\[(?.+)\]\((?(http|www)(" + ValidRegexChars + "|" + ValidExtraChars + @")+)\)"; - private static readonly string RegexHtmlLinkReplacement = "${linkValue}"; - - private static readonly string RegexMarkdownCodeStartPattern = @"```(?(cs|csharp|cpp|vb|visualbasic))(?\s+)"; - private static readonly string RegexXmlCodeStartReplacement = "${spaces}"; - - private static readonly string RegexMarkdownCodeEndPattern = @"```(?\s+)"; - private static readonly string RegexXmlCodeEndReplacement = "${spaces}"; - - private static readonly Dictionary PrimitiveTypes = new() - { - { "System.Boolean", "bool" }, - { "System.Byte", "byte" }, - { "System.Char", "char" }, - { "System.Decimal", "decimal" }, - { "System.Double", "double" }, - { "System.Int16", "short" }, - { "System.Int32", "int" }, - { "System.Int64", "long" }, - { "System.Object", "object" }, // Ambiguous: could be 'object' or 'dynamic' https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/built-in-types - { "System.SByte", "sbyte" }, - { "System.Single", "float" }, - { "System.String", "string" }, - { "System.UInt16", "ushort" }, - { "System.UInt32", "uint" }, - { "System.UInt64", "ulong" }, - { "System.Void", "void" } - }; - private DocsCommentsContainer DocsComments { get; } private SemanticModel Model { get; } - #endregion - public TripleSlashSyntaxRewriter(DocsCommentsContainer docsComments, SemanticModel model) : base(visitIntoStructuredTrivia: true) { DocsComments = docsComments; @@ -234,17 +180,17 @@ public TripleSlashSyntaxRewriter(DocsCommentsContainer docsComments, SemanticMod return node; } - SyntaxTriviaList leadingWhitespace = GetLeadingWhitespace(node); + List xmlComments = new(); + xmlComments.Add(XmlDocComments.GetSummary(member.SummaryElement)); + xmlComments.Add(XmlDocComments.GetValue(member.Value)); + xmlComments.AddRange(XmlDocComments.GetExceptions(member.Exceptions)); + xmlComments.Add(XmlDocComments.GetRemarks(member.RemarksElement)); + xmlComments.AddRange(XmlDocComments.GetExamples(member.ExampleElements)); + xmlComments.AddRange(XmlDocComments.GetSeeAlsos(member.SeeAlsoCrefs)); + xmlComments.AddRange(XmlDocComments.GetAltMembers(member.AltMembers)); + xmlComments.AddRange(XmlDocComments.GetRelateds(member.Relateds)); - SyntaxTriviaList summary = GetSummary(member, leadingWhitespace); - SyntaxTriviaList value = GetValue(member, leadingWhitespace); - SyntaxTriviaList exceptions = GetExceptions(member.Exceptions, leadingWhitespace); - SyntaxTriviaList remarks = GetRemarks(member, leadingWhitespace); - SyntaxTriviaList seealsos = GetSeeAlsos(member.SeeAlsoCrefs, leadingWhitespace); - SyntaxTriviaList altmembers = GetAltMembers(member.AltMembers, leadingWhitespace); - SyntaxTriviaList relateds = GetRelateds(member.Relateds, leadingWhitespace); - - return GetNodeWithTrivia(leadingWhitespace, node, summary, value, exceptions, remarks, seealsos, altmembers, relateds); + return LeadingTriviaRewriter.ApplyXmlComments(node, xmlComments.Where(c => c is not null)!); } public override SyntaxNode? VisitRecordDeclaration(RecordDeclarationSyntax node) @@ -293,23 +239,22 @@ public TripleSlashSyntaxRewriter(DocsCommentsContainer docsComments, SemanticMod return node; } - SyntaxTriviaList leadingWhitespace = GetLeadingWhitespace(node); - if (!TryGetType(symbol, out DocsType? type)) { return node; } - SyntaxTriviaList summary = GetSummary(type, leadingWhitespace); - SyntaxTriviaList typeParameters = GetTypeParameters(type, leadingWhitespace); - SyntaxTriviaList parameters = GetParameters(type, leadingWhitespace); - SyntaxTriviaList remarks = GetRemarks(type, leadingWhitespace); - SyntaxTriviaList seealsos = GetSeeAlsos(type.SeeAlsoCrefs, leadingWhitespace); - SyntaxTriviaList altmembers = GetAltMembers(type.AltMembers, leadingWhitespace); - SyntaxTriviaList relateds = GetRelateds(type.Relateds, leadingWhitespace); - + List xmlComments = new(); + xmlComments.Add(XmlDocComments.GetSummary(type.SummaryElement)); + xmlComments.AddRange(XmlDocComments.GetTypeParameters(type.TypeParams)); + xmlComments.AddRange(XmlDocComments.GetParameters(type.Params)); + xmlComments.Add(XmlDocComments.GetRemarks(type.RemarksElement)); + xmlComments.AddRange(XmlDocComments.GetExamples(type.ExampleElements)); + xmlComments.AddRange(XmlDocComments.GetSeeAlsos(type.SeeAlsoCrefs)); + xmlComments.AddRange(XmlDocComments.GetAltMembers(type.AltMembers)); + xmlComments.AddRange(XmlDocComments.GetRelateds(type.Relateds)); - return GetNodeWithTrivia(leadingWhitespace, node, summary, typeParameters, parameters, remarks, seealsos, altmembers, relateds); + return LeadingTriviaRewriter.ApplyXmlComments(node, xmlComments.Where(c => c is not null)!); } private SyntaxNode? VisitBaseMethodDeclaration(BaseMethodDeclarationSyntax node) @@ -321,19 +266,19 @@ public TripleSlashSyntaxRewriter(DocsCommentsContainer docsComments, SemanticMod return node; } - SyntaxTriviaList leadingWhitespace = GetLeadingWhitespace(node); + List xmlComments = new(); + xmlComments.Add(XmlDocComments.GetSummary(member.SummaryElement)); + xmlComments.AddRange(XmlDocComments.GetTypeParameters(member.TypeParams)); + xmlComments.AddRange(XmlDocComments.GetParameters(member.Params)); + xmlComments.Add(XmlDocComments.GetReturns(member.Returns)); + xmlComments.AddRange(XmlDocComments.GetExceptions(member.Exceptions)); + xmlComments.Add(XmlDocComments.GetRemarks(member.RemarksElement)); + xmlComments.AddRange(XmlDocComments.GetExamples(member.ExampleElements)); + xmlComments.AddRange(XmlDocComments.GetSeeAlsos(member.SeeAlsoCrefs)); + xmlComments.AddRange(XmlDocComments.GetAltMembers(member.AltMembers)); + xmlComments.AddRange(XmlDocComments.GetRelateds(member.Relateds)); - SyntaxTriviaList summary = GetSummary(member, leadingWhitespace); - SyntaxTriviaList typeParameters = GetTypeParameters(member, leadingWhitespace); - SyntaxTriviaList parameters = GetParameters(member, leadingWhitespace); - SyntaxTriviaList returns = GetReturns(member, leadingWhitespace); - SyntaxTriviaList exceptions = GetExceptions(member.Exceptions, leadingWhitespace); - SyntaxTriviaList remarks = GetRemarks(member, leadingWhitespace); - SyntaxTriviaList seealsos = GetSeeAlsos(member.SeeAlsoCrefs, leadingWhitespace); - SyntaxTriviaList altmembers = GetAltMembers(member.AltMembers, leadingWhitespace); - SyntaxTriviaList relateds = GetRelateds(member.Relateds, leadingWhitespace); - - return GetNodeWithTrivia(leadingWhitespace, node, summary, typeParameters, parameters, returns, exceptions, remarks, seealsos, altmembers, relateds); + return LeadingTriviaRewriter.ApplyXmlComments(node, xmlComments.Where(c => c is not null)!); } private SyntaxNode? VisitMemberDeclaration(MemberDeclarationSyntax node) @@ -343,16 +288,16 @@ public TripleSlashSyntaxRewriter(DocsCommentsContainer docsComments, SemanticMod return node; } - SyntaxTriviaList leadingWhitespace = GetLeadingWhitespace(node); - - SyntaxTriviaList summary = GetSummary(member, leadingWhitespace); - SyntaxTriviaList exceptions = GetExceptions(member.Exceptions, leadingWhitespace); - SyntaxTriviaList remarks = GetRemarks(member, leadingWhitespace); - SyntaxTriviaList seealsos = GetSeeAlsos(member.SeeAlsoCrefs, leadingWhitespace); - SyntaxTriviaList altmembers = GetAltMembers(member.AltMembers, leadingWhitespace); - SyntaxTriviaList relateds = GetRelateds(member.Relateds, leadingWhitespace); + List xmlComments = new(); + xmlComments.Add(XmlDocComments.GetSummary(member.SummaryElement)); + xmlComments.AddRange(XmlDocComments.GetExceptions(member.Exceptions)); + xmlComments.Add(XmlDocComments.GetRemarks(member.RemarksElement)); + xmlComments.AddRange(XmlDocComments.GetExamples(member.ExampleElements)); + xmlComments.AddRange(XmlDocComments.GetSeeAlsos(member.SeeAlsoCrefs)); + xmlComments.AddRange(XmlDocComments.GetAltMembers(member.AltMembers)); + xmlComments.AddRange(XmlDocComments.GetRelateds(member.Relateds)); - return GetNodeWithTrivia(leadingWhitespace, node, summary, exceptions, remarks, seealsos, altmembers, relateds); + return LeadingTriviaRewriter.ApplyXmlComments(node, xmlComments.Where(c => c is not null)!); } private SyntaxNode? VisitVariableDeclaration(BaseFieldDeclarationSyntax node) @@ -368,15 +313,15 @@ public TripleSlashSyntaxRewriter(DocsCommentsContainer docsComments, SemanticMod return node; } - SyntaxTriviaList leadingWhitespace = GetLeadingWhitespace(node); + List xmlComments = new(); + xmlComments.Add(XmlDocComments.GetSummary(member.SummaryElement)); + xmlComments.Add(XmlDocComments.GetRemarks(member.RemarksElement)); + xmlComments.AddRange(XmlDocComments.GetExamples(member.ExampleElements)); + xmlComments.AddRange(XmlDocComments.GetSeeAlsos(member.SeeAlsoCrefs)); + xmlComments.AddRange(XmlDocComments.GetAltMembers(member.AltMembers)); + xmlComments.AddRange(XmlDocComments.GetRelateds(member.Relateds)); - SyntaxTriviaList summary = GetSummary(member, leadingWhitespace); - SyntaxTriviaList remarks = GetRemarks(member, leadingWhitespace); - SyntaxTriviaList seealsos = GetSeeAlsos(member.SeeAlsoCrefs, leadingWhitespace); - SyntaxTriviaList altmembers = GetAltMembers(member.AltMembers, leadingWhitespace); - SyntaxTriviaList relateds = GetRelateds(member.Relateds, leadingWhitespace); - - return GetNodeWithTrivia(leadingWhitespace, node, summary, remarks, seealsos, altmembers, relateds); + return LeadingTriviaRewriter.ApplyXmlComments(node, xmlComments.Where(c => c is not null)!); } return node; @@ -411,594 +356,5 @@ private bool TryGetType(ISymbol symbol, [NotNullWhen(returnValue: true)] out Doc } #endregion - - #region Syntax manipulation - - private static SyntaxNode GetNodeWithTrivia(SyntaxTriviaList leadingWhitespace, SyntaxNode node, params SyntaxTriviaList[] trivias) - { - SyntaxTriviaList leadingDoubleSlashComments = GetLeadingDoubleSlashComments(node, leadingWhitespace); - - SyntaxTriviaList finalTrivia = new(); - foreach (SyntaxTriviaList t in trivias) - { - finalTrivia = finalTrivia.AddRange(t); - } - finalTrivia = finalTrivia.AddRange(leadingDoubleSlashComments); - - if (finalTrivia.Count > 0) - { - finalTrivia = finalTrivia.AddRange(leadingWhitespace); - - var leadingTrivia = node.GetLeadingTrivia(); - if (leadingTrivia.Any()) - { - if (leadingTrivia[0].IsKind(SyntaxKind.EndOfLineTrivia)) - { - // Ensure the endline that separates nodes is respected - finalTrivia = new SyntaxTriviaList(SyntaxFactory.ElasticCarriageReturnLineFeed) - .AddRange(finalTrivia); - } - } - - return node.WithLeadingTrivia(finalTrivia); - } - - // If there was no new trivia, return untouched - return node; - } - - // Finds the last set of whitespace characters that are to the left of the public|protected keyword of the node. - private static SyntaxTriviaList GetLeadingWhitespace(SyntaxNode node) - { - SyntaxTriviaList triviaList = GetLeadingTrivia(node); - - if (triviaList.Any() && - triviaList.LastOrDefault(t => t.IsKind(SyntaxKind.WhitespaceTrivia)) is SyntaxTrivia last) - { - return new(last); - } - - return new(); - } - - private static SyntaxTriviaList GetLeadingDoubleSlashComments(SyntaxNode node, SyntaxTriviaList leadingWhitespace) - { - SyntaxTriviaList triviaList = GetLeadingTrivia(node); - - SyntaxTriviaList doubleSlashComments = new(); - - foreach (SyntaxTrivia trivia in triviaList) - { - if (trivia.IsKind(SyntaxKind.SingleLineCommentTrivia)) - { - doubleSlashComments = doubleSlashComments - .AddRange(leadingWhitespace) - .Add(trivia) - .Add(SyntaxFactory.CarriageReturnLineFeed); - } - } - - return doubleSlashComments; - } - - private static SyntaxTriviaList GetLeadingTrivia(SyntaxNode node) - { - if (node is MemberDeclarationSyntax memberDeclaration) - { - if ((memberDeclaration.Modifiers.FirstOrDefault(x => x.IsKind(SyntaxKind.PublicKeyword) || x.IsKind(SyntaxKind.ProtectedKeyword)) is SyntaxToken modifier) && - !modifier.IsKind(SyntaxKind.None)) - { - return modifier.LeadingTrivia; - } - - return node.GetLeadingTrivia(); - } - - return new(); - } - - private static SyntaxTriviaList GetSummary(DocsAPI api, SyntaxTriviaList leadingWhitespace) - { - if (!api.Summary.IsDocsEmpty()) - { - XmlTextSyntax contents = GetTextAsCommentedTokens(api.Summary, leadingWhitespace); - XmlElementSyntax element = SyntaxFactory.XmlSummaryElement(contents); - return GetXmlTrivia(element, leadingWhitespace); - } - - return new(); - } - - private static SyntaxTriviaList GetRemarks(DocsAPI api, SyntaxTriviaList leadingWhitespace) - { - if (!api.Remarks.IsDocsEmpty()) - { - return GetFormattedRemarks(api, leadingWhitespace); - } - - return new(); - } - - private static SyntaxTriviaList GetValue(DocsMember api, SyntaxTriviaList leadingWhitespace) - { - if (!api.Value.IsDocsEmpty()) - { - XmlTextSyntax contents = GetTextAsCommentedTokens(api.Value, leadingWhitespace); - XmlElementSyntax element = SyntaxFactory.XmlValueElement(contents); - return GetXmlTrivia(element, leadingWhitespace); - } - - return new(); - } - - private static SyntaxTriviaList GetParameter(string name, string text, SyntaxTriviaList leadingWhitespace) - { - if (!text.IsDocsEmpty()) - { - XmlTextSyntax contents = GetTextAsCommentedTokens(text, leadingWhitespace); - XmlElementSyntax element = SyntaxFactory.XmlParamElement(name, contents); - return GetXmlTrivia(element, leadingWhitespace); - } - - return new(); - } - - private static SyntaxTriviaList GetParameters(DocsAPI api, SyntaxTriviaList leadingWhitespace) - { - SyntaxTriviaList parameters = new(); - foreach (SyntaxTriviaList parameterTrivia in api.Params - .Where(param => !param.Value.IsDocsEmpty()) - .Select(param => GetParameter(param.Name, param.Value, leadingWhitespace))) - { - parameters = parameters.AddRange(parameterTrivia); - } - return parameters; - } - - private static SyntaxTriviaList GetTypeParam(string name, string text, SyntaxTriviaList leadingWhitespace) - { - if (!text.IsDocsEmpty()) - { - var attribute = new SyntaxList(SyntaxFactory.XmlTextAttribute("name", name)); - XmlTextSyntax contents = GetTextAsCommentedTokens(text, leadingWhitespace); - return GetXmlTrivia("typeparam", attribute, contents, leadingWhitespace); - } - - return new(); - } - - private static SyntaxTriviaList GetTypeParameters(DocsAPI api, SyntaxTriviaList leadingWhitespace) - { - SyntaxTriviaList typeParameters = new(); - foreach (SyntaxTriviaList typeParameterTrivia in api.TypeParams - .Where(typeParam => !typeParam.Value.IsDocsEmpty()) - .Select(typeParam => GetTypeParam(typeParam.Name, typeParam.Value, leadingWhitespace))) - { - typeParameters = typeParameters.AddRange(typeParameterTrivia); - } - return typeParameters; - } - - private static SyntaxTriviaList GetReturns(DocsMember api, SyntaxTriviaList leadingWhitespace) - { - // Also applies for when is empty because the method return type is void - if (!api.Returns.IsDocsEmpty()) - { - XmlTextSyntax contents = GetTextAsCommentedTokens(api.Returns, leadingWhitespace); - XmlElementSyntax element = SyntaxFactory.XmlReturnsElement(contents); - return GetXmlTrivia(element, leadingWhitespace); - } - - return new(); - } - - private static SyntaxTriviaList GetException(string cref, string text, SyntaxTriviaList leadingWhitespace) - { - if (!text.IsDocsEmpty()) - { - cref = RemoveCrefPrefix(cref); - TypeCrefSyntax crefSyntax = SyntaxFactory.TypeCref(SyntaxFactory.ParseTypeName(cref)); - XmlTextSyntax contents = GetTextAsCommentedTokens(text, leadingWhitespace); - XmlElementSyntax element = SyntaxFactory.XmlExceptionElement(crefSyntax, contents); - return GetXmlTrivia(element, leadingWhitespace); - } - - return new(); - } - - private static SyntaxTriviaList GetExceptions(List docsExceptions, SyntaxTriviaList leadingWhitespace) - { - SyntaxTriviaList exceptions = new(); - if (docsExceptions.Any()) - { - foreach (SyntaxTriviaList exceptionsTrivia in docsExceptions.Select( - exception => GetException(exception.Cref, exception.Value, leadingWhitespace))) - { - exceptions = exceptions.AddRange(exceptionsTrivia); - } - } - return exceptions; - } - - private static SyntaxTriviaList GetSeeAlso(string cref, SyntaxTriviaList leadingWhitespace) - { - cref = RemoveCrefPrefix(cref); - TypeCrefSyntax crefSyntax = SyntaxFactory.TypeCref(SyntaxFactory.ParseTypeName(cref)); - XmlEmptyElementSyntax element = SyntaxFactory.XmlSeeAlsoElement(crefSyntax); - return GetXmlTrivia(element, leadingWhitespace); - } - - private static SyntaxTriviaList GetSeeAlsos(List docsSeeAlsoCrefs, SyntaxTriviaList leadingWhitespace) - { - SyntaxTriviaList seealsos = new(); - if (docsSeeAlsoCrefs.Any()) - { - foreach (SyntaxTriviaList seealsoTrivia in docsSeeAlsoCrefs.Select( - s => GetSeeAlso(s, leadingWhitespace))) - { - seealsos = seealsos.AddRange(seealsoTrivia); - } - } - return seealsos; - } - - private static SyntaxTriviaList GetAltMember(string cref, SyntaxTriviaList leadingWhitespace) - { - cref = RemoveCrefPrefix(cref); - XmlAttributeSyntax attribute = SyntaxFactory.XmlTextAttribute("cref", cref); - XmlEmptyElementSyntax emptyElement = SyntaxFactory.XmlEmptyElement(SyntaxFactory.XmlName(SyntaxFactory.Identifier("altmember")), new SyntaxList(attribute)); - return GetXmlTrivia(emptyElement, leadingWhitespace); - } - - private static SyntaxTriviaList GetAltMembers(List docsAltMembers, SyntaxTriviaList leadingWhitespace) - { - SyntaxTriviaList altMembers = new(); - if (docsAltMembers.Any()) - { - foreach (SyntaxTriviaList altMemberTrivia in docsAltMembers.Select( - s => GetAltMember(s, leadingWhitespace))) - { - altMembers = altMembers.AddRange(altMemberTrivia); - } - } - return altMembers; - } - - private static SyntaxTriviaList GetRelated(string articleType, string href, string value, SyntaxTriviaList leadingWhitespace) - { - SyntaxList attributes = new(); - - attributes = attributes.Add(SyntaxFactory.XmlTextAttribute("type", articleType)); - attributes = attributes.Add(SyntaxFactory.XmlTextAttribute("href", href)); - - XmlTextSyntax contents = GetTextAsCommentedTokens(value, leadingWhitespace); - return GetXmlTrivia("related", attributes, contents, leadingWhitespace); - } - - private static SyntaxTriviaList GetRelateds(List docsRelateds, SyntaxTriviaList leadingWhitespace) - { - SyntaxTriviaList relateds = new(); - if (docsRelateds.Any()) - { - foreach (SyntaxTriviaList relatedsTrivia in docsRelateds.Select( - s => GetRelated(s.ArticleType, s.Href, s.Value, leadingWhitespace))) - { - relateds = relateds.AddRange(relatedsTrivia); - } - } - return relateds; - } - - private static XmlTextSyntax GetTextAsCommentedTokens(string text, SyntaxTriviaList leadingWhitespace, bool wrapWithNewLines = false) - { - text = CleanCrefs(text); - - // collapse newlines to a single one - string whitespace = Regex.Replace(leadingWhitespace.ToFullString(), @"(\r?\n)+", ""); - SyntaxToken whitespaceToken = SyntaxFactory.XmlTextNewLine(Environment.NewLine + whitespace); - - SyntaxTrivia leadingTrivia = SyntaxFactory.SyntaxTrivia(SyntaxKind.DocumentationCommentExteriorTrivia, string.Empty); - SyntaxTriviaList leading = SyntaxTriviaList.Create(leadingTrivia); - - string[] lines = text.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - var tokens = new List(); - - if (wrapWithNewLines) - { - tokens.Add(whitespaceToken); - } - - for (int lineNumber = 0; lineNumber < lines.Length; lineNumber++) - { - string line = lines[lineNumber]; - - SyntaxToken token = SyntaxFactory.XmlTextLiteral(leading, line, line, default); - tokens.Add(token); - - if (lines.Length > 1 && lineNumber < lines.Length - 1) - { - tokens.Add(whitespaceToken); - } - } - - if (wrapWithNewLines) - { - tokens.Add(whitespaceToken); - } - - XmlTextSyntax xmlText = SyntaxFactory.XmlText(tokens.ToArray()); - return xmlText; - } - - private static SyntaxTriviaList GetXmlTrivia(XmlNodeSyntax node, SyntaxTriviaList leadingWhitespace) - { - DocumentationCommentTriviaSyntax docComment = SyntaxFactory.DocumentationComment(node); - SyntaxTrivia docCommentTrivia = SyntaxFactory.Trivia(docComment); - - return leadingWhitespace - .Add(docCommentTrivia) - .Add(SyntaxFactory.CarriageReturnLineFeed); - } - - // Generates a custom SyntaxTrivia object containing a triple slashed xml element with optional attributes. - // Looks like below (excluding square brackets): - // [ /// text] - private static SyntaxTriviaList GetXmlTrivia(string name, SyntaxList attributes, XmlTextSyntax contents, SyntaxTriviaList leadingWhitespace) - { - XmlElementStartTagSyntax start = SyntaxFactory.XmlElementStartTag( - SyntaxFactory.Token(SyntaxKind.LessThanToken), - SyntaxFactory.XmlName(SyntaxFactory.Identifier(name)), - attributes, - SyntaxFactory.Token(SyntaxKind.GreaterThanToken)); - - XmlElementEndTagSyntax end = SyntaxFactory.XmlElementEndTag( - SyntaxFactory.Token(SyntaxKind.LessThanSlashToken), - SyntaxFactory.XmlName(SyntaxFactory.Identifier(name)), - SyntaxFactory.Token(SyntaxKind.GreaterThanToken)); - - XmlElementSyntax element = SyntaxFactory.XmlElement(start, new SyntaxList(contents), end); - return GetXmlTrivia(element, leadingWhitespace); - } - - private static string WrapInRemarks(string acum) - { - string wrapped = Environment.NewLine + "" + Environment.NewLine; - return wrapped; - } - - private static string WrapCodeIncludes(string[] splitted, ref int n) - { - string acum = string.Empty; - while (n < splitted.Length && splitted[n].ContainsStrings(MarkdownCodeIncludes)) - { - acum += Environment.NewLine + splitted[n]; - if ((n + 1) < splitted.Length && splitted[n + 1].ContainsStrings(MarkdownCodeIncludes)) - { - n++; - } - else - { - break; - } - } - return WrapInRemarks(acum); - } - - private static SyntaxTriviaList GetFormattedRemarks(IDocsAPI api, SyntaxTriviaList leadingWhitespace) - { - - string remarks = RemoveUnnecessaryMarkdown(api.Remarks); - string example = string.Empty; - - XmlNodeSyntax contents; - if (remarks.ContainsStrings(MarkdownUnconvertableStrings)) - { - contents = GetTextAsFormatCData(remarks, leadingWhitespace); - } - else - { - string[] splitted = remarks.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - string updatedRemarks = string.Empty; - for (int n = 0; n < splitted.Length; n++) - { - string acum; - string line = splitted[n]; - if (line.ContainsStrings(MarkdownHeaders)) - { - acum = line; - n++; - while (n < splitted.Length && splitted[n].StartsWith(">")) - { - acum += Environment.NewLine + splitted[n]; - if ((n + 1) < splitted.Length && splitted[n + 1].StartsWith(">")) - { - n++; - } - else - { - break; - } - } - updatedRemarks += WrapInRemarks(acum); - } - else if (line.ContainsStrings(MarkdownCodeIncludes)) - { - updatedRemarks += WrapCodeIncludes(splitted, ref n); - } - // When an example is found, everything after the header is considered part of that section - else if (line.Contains("## Example")) - { - n++; - while (n < splitted.Length) - { - line = splitted[n]; - if (line.ContainsStrings(MarkdownCodeIncludes)) - { - example += WrapCodeIncludes(splitted, ref n); - } - else - { - example += Environment.NewLine + line; - } - n++; - } - } - else - { - updatedRemarks += ReplaceMarkdownWithXmlElements(Environment.NewLine + line, api.Params, api.TypeParams); - } - } - - contents = GetTextAsCommentedTokens(updatedRemarks, leadingWhitespace); - } - - XmlElementSyntax remarksXml = SyntaxFactory.XmlRemarksElement(contents); - SyntaxTriviaList result = GetXmlTrivia(remarksXml, leadingWhitespace); - - if (!string.IsNullOrWhiteSpace(example)) - { - SyntaxTriviaList exampleTriviaList = GetFormattedExamples(api, example, leadingWhitespace); - result = result.AddRange(exampleTriviaList); - } - - return result; - } - - private static SyntaxTriviaList GetFormattedExamples(IDocsAPI api, string example, SyntaxTriviaList leadingWhitespace) - { - example = ReplaceMarkdownWithXmlElements(example, api.Params, api.TypeParams); - XmlNodeSyntax exampleContents = GetTextAsCommentedTokens(example, leadingWhitespace); - XmlElementSyntax exampleXml = SyntaxFactory.XmlExampleElement(exampleContents); - SyntaxTriviaList exampleTriviaList = GetXmlTrivia(exampleXml, leadingWhitespace); - return exampleTriviaList; - } - - private static XmlNodeSyntax GetTextAsFormatCData(string text, SyntaxTriviaList leadingWhitespace) - { - XmlTextSyntax remarks = GetTextAsCommentedTokens(text, leadingWhitespace, wrapWithNewLines: true); - - XmlNameSyntax formatName = SyntaxFactory.XmlName("format"); - XmlAttributeSyntax formatAttribute = SyntaxFactory.XmlTextAttribute("type", "text/markdown"); - var formatAttributes = new SyntaxList(formatAttribute); - - var formatStart = SyntaxFactory.XmlElementStartTag(formatName, formatAttributes); - var formatEnd = SyntaxFactory.XmlElementEndTag(formatName); - - XmlCDataSectionSyntax cdata = SyntaxFactory.XmlCDataSection(remarks.TextTokens); - var cdataList = new SyntaxList(cdata); - - XmlElementSyntax contents = SyntaxFactory.XmlElement(formatStart, cdataList, formatEnd); - - return contents; - } - - private static string RemoveUnnecessaryMarkdown(string text) - { - text = Regex.Replace(text, @"", ""); - text = Regex.Replace(text, @"##[ ]?Remarks(\r?\n)*[\t ]*", ""); - return text; - } - - private static string ReplaceMarkdownWithXmlElements(string text, List docsParams, List docsTypeParams) - { - text = CleanXrefs(text); - - // commonly used url entities - text = Regex.Replace(text, @"%23", "#"); - text = Regex.Replace(text, @"%28", "("); - text = Regex.Replace(text, @"%29", ")"); - text = Regex.Replace(text, @"%2C", ","); - - // hyperlinks - text = Regex.Replace(text, RegexMarkdownLinkPattern, RegexHtmlLinkReplacement); - - // bold - text = Regex.Replace(text, RegexMarkdownBoldPattern, RegexXmlBoldReplacement); - - // code snippet - text = Regex.Replace(text, RegexMarkdownCodeStartPattern, RegexXmlCodeStartReplacement); - text = Regex.Replace(text, RegexMarkdownCodeEndPattern, RegexXmlCodeEndReplacement); - - // langwords|parameters|typeparams - MatchCollection collection = Regex.Matches(text, @"(?`(?[a-zA-Z0-9_]+)`)"); - foreach (Match match in collection) - { - string backtickedParam = match.Groups["backtickedParam"].Value; - string paramName = match.Groups["paramName"].Value; - if (ReservedKeywords.Any(x => x == paramName)) - { - text = Regex.Replace(text, $"{backtickedParam}", $""); - } - else if (docsParams.Any(x => x.Name == paramName)) - { - text = Regex.Replace(text, $"{backtickedParam}", $""); - } - else if (docsTypeParams.Any(x => x.Name == paramName)) - { - text = Regex.Replace(text, $"{backtickedParam}", $""); - } - } - - return text; - } - - // Removes the one letter prefix and the following colon, if found, from a cref. - private static string RemoveCrefPrefix(string cref) - { - if (cref.Length > 2 && cref[1] == ':') - { - return cref[2..]; - } - return cref; - } - - private static string ReplacePrimitives(string text) - { - foreach ((string key, string value) in PrimitiveTypes) - { - text = Regex.Replace(text, key, value); - } - return text; - } - - private static string ReplaceDocId(Match m) - { - string docId = m.Groups["docId"].Value; - string overload = string.IsNullOrWhiteSpace(m.Groups["overload"].Value) ? "" : "O:"; - docId = ReplacePrimitives(docId); - docId = Regex.Replace(docId, @"%60", "`"); - docId = Regex.Replace(docId, @"`\d", "{T}"); - return overload + docId; - } - - private static string CrefEvaluator(Match m) - { - string docId = ReplaceDocId(m); - return "cref=\"" + docId + "\""; - } - - private static string CleanCrefs(string text) - { - text = Regex.Replace(text, RegexXmlCrefPattern, CrefEvaluator); - return text; - } - - private static string XrefEvaluator(Match m) - { - string docId = ReplaceDocId(m); - return ""; - } - - private static string CleanXrefs(string text) - { - text = Regex.Replace(text, RegexMarkdownXrefPattern, XrefEvaluator); - return text; - } - - #endregion } } diff --git a/Libraries/RoslynTripleSlash/XmlDocComments.cs b/Libraries/RoslynTripleSlash/XmlDocComments.cs new file mode 100644 index 0000000..78736da --- /dev/null +++ b/Libraries/RoslynTripleSlash/XmlDocComments.cs @@ -0,0 +1,800 @@ +using Libraries.Docs; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text.RegularExpressions; +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; + +namespace Libraries.RoslynTripleSlash +{ + public static class XmlDocComments + { + private static readonly string[] ReservedKeywords = new[] { "abstract", "async", "await", "false", "null", "sealed", "static", "true", "virtual" }; + + private static readonly Dictionary PrimitiveTypes = new() + { + { "System.Boolean", "bool" }, + { "System.Byte", "byte" }, + { "System.Char", "char" }, + { "System.Decimal", "decimal" }, + { "System.Double", "double" }, + { "System.Int16", "short" }, + { "System.Int32", "int" }, + { "System.Int64", "long" }, + { "System.Object", "object" }, // Ambiguous: could be 'object' or 'dynamic' https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/built-in-types + { "System.SByte", "sbyte" }, + { "System.Single", "float" }, + { "System.String", "string" }, + { "System.UInt16", "ushort" }, + { "System.UInt32", "uint" }, + { "System.UInt64", "ulong" }, + { "System.Void", "void" } + }; + + // Note that we need to support generics that use the ` literal as well as any url encoded character + private static readonly string ValidRegexChars = @"[A-Za-z0-9\-\._~:\/#\[\]\{\}@!\$&'\(\)\*\+,;]|`\d+|%\w{2}"; + private static readonly string ValidExtraChars = @"\?="; + + private static readonly string RegexDocIdPattern = @"(?[A-Za-z]{1}:)?(?(" + ValidRegexChars + @")+)?(?\?(" + ValidRegexChars + @")+=(" + ValidRegexChars + @")+)?"; + private static readonly string RegexXmlCrefPattern = "cref=\"" + RegexDocIdPattern + "\""; + private static readonly string RegexMarkdownXrefPattern = @"(?)"; + + private static readonly string RegexMarkdownBoldPattern = @"\*\*(?[A-Za-z0-9\-\._~:\/#\[\]@!\$&'\(\)\+,;%` ]+)\*\*"; + private static readonly string RegexXmlBoldReplacement = @"${content}"; + + private static readonly string RegexMarkdownLinkPattern = @"\[(?.+)\]\((?(http|www)(" + ValidRegexChars + "|" + ValidExtraChars + @")+)\)"; + private static readonly string RegexHtmlLinkReplacement = "${linkValue}"; + + private static readonly string RegexMarkdownCodeStartPattern = @"```(?(cs|csharp|cpp|vb|visualbasic))(?\s+)"; + private static readonly string RegexXmlCodeStartReplacement = "${spaces}"; + + private static readonly string RegexMarkdownCodeEndPattern = @"```(?\s+)"; + private static readonly string RegexXmlCodeEndReplacement = "${spaces}"; + private static readonly string[] MarkdownUnconvertableStrings = new[] { "](~/includes", "[!INCLUDE" }; + + private static readonly string[] MarkdownCodeIncludes = new[] { "[!code-cpp", "[!code-csharp", "[!code-vb", }; + + private static readonly string[] MarkdownExamples = new[] { "## Examples", "## Example" }; + + private static readonly string[] MarkdownHeaders = new[] { "[!NOTE]", "[!IMPORTANT]", "[!TIP]" }; + + public static SyntaxList GetXmlCommentLines(string[] commentLines) + { + var xmlTokens = commentLines + .Select(l => XmlTextLiteral(l, l)) + .Zip(Enumerable.Repeat(XmlTextNewLine(Environment.NewLine), commentLines.Length - 1)) + .SelectMany((pair) => new[] { pair.First, pair.Second }); + + var xmlText = XmlText(TokenList(xmlTokens)); + + return SingletonList(xmlText); + } + + public static XmlNodeSyntax? GetSummary(DocsSummary summary) + { + var text = summary.ParsedText; + + if (!text.IsDocsEmpty()) + { + return XmlElement("summary", SingletonList(XmlText(XmlTextLiteral(text, text)))); + } + + return null; + } + + public static SyntaxTriviaList GetSummary(DocsAPI api, SyntaxTriviaList leadingWhitespace) + { + if (!api.Summary.IsDocsEmpty()) + { + XmlTextSyntax content = GetTextAsCommentedTokens(api.Summary, leadingWhitespace); + XmlElementSyntax element = XmlSummaryElement(content); + return GetXmlTrivia(element, leadingWhitespace); + } + + return new(); + } + + public static XmlNodeSyntax? GetRemarks(DocsRemarks remarks) + { + var text = remarks.ParsedText; + + if (!text.IsDocsEmpty()) + { + return XmlElement("remarks", SingletonList(XmlText(XmlTextLiteral(text, text)))); + } + + return null; + } + + public static SyntaxTriviaList GetRemarks(DocsAPI api, SyntaxTriviaList leadingWhitespace) + { + if (!api.Remarks.IsDocsEmpty()) + { + return GetFormattedRemarks(api, leadingWhitespace); + } + + return new(); + } + + public static XmlNodeSyntax? GetExample(DocsExample example) + { + if (!example.ParsedText.IsDocsEmpty()) + { + var content = XmlText(example.ParsedText); + return XmlExampleElement(content); + } + + return null; + } + + public static IEnumerable GetExamples(IEnumerable examples) + { + return examples + .Where(e => !e.ParsedText?.IsDocsEmpty() ?? false) + .Select(GetExample)!; + } + + public static XmlNodeSyntax? GetValue(string value) + { + if (!value.IsDocsEmpty()) + { + return XmlValueElement(XmlText(value)); + } + + return null; + } + + public static SyntaxTriviaList GetValue(DocsMember api, SyntaxTriviaList leadingWhitespace) + { + if (!api.Value.IsDocsEmpty()) + { + XmlTextSyntax contents = GetTextAsCommentedTokens(api.Value, leadingWhitespace); + XmlElementSyntax element = XmlValueElement(contents); + return GetXmlTrivia(element, leadingWhitespace); + } + + return new(); + } + + public static XmlNodeSyntax? GetParameter(DocsParam parameter) + { + if (!parameter.ParsedText.IsDocsEmpty()) + { + var content = XmlText(parameter.ParsedText); + return XmlParamElement(parameter.Name, content); + } + + return null; + } + + public static SyntaxTriviaList GetParameter(string name, string text, SyntaxTriviaList leadingWhitespace) + { + if (!text.IsDocsEmpty()) + { + XmlTextSyntax contents = GetTextAsCommentedTokens(text, leadingWhitespace); + XmlElementSyntax element = XmlParamElement(name, contents); + return GetXmlTrivia(element, leadingWhitespace); + } + + return new(); + } + + public static IEnumerable GetParameters(IEnumerable parameters) + { + return parameters + .Where(param => !param.ParsedText.IsDocsEmpty()) + .Select(GetParameter)!; + } + + public static SyntaxTriviaList GetParameters(DocsAPI api, SyntaxTriviaList leadingWhitespace) + { + SyntaxTriviaList parameters = new(); + foreach (SyntaxTriviaList parameterTrivia in api.Params + .Where(param => !param.Value.IsDocsEmpty()) + .Select(param => GetParameter(param.Name, param.Value, leadingWhitespace))) + { + parameters = parameters.AddRange(parameterTrivia); + } + return parameters; + } + + public static XmlNodeSyntax? GetTypeParameter(DocsTypeParam param) + { + if (!param.ParsedText.IsDocsEmpty()) + { + var attribute = new SyntaxList(XmlTextAttribute("name", param.Name)); + var content = XmlText(param.ParsedText); + + return GetXmlNode("typeparam", attribute, content); + } + + return null; + } + + public static SyntaxTriviaList GetTypeParam(string name, string text, SyntaxTriviaList leadingWhitespace) + { + if (!text.IsDocsEmpty()) + { + var attribute = new SyntaxList(XmlTextAttribute("name", name)); + XmlTextSyntax contents = GetTextAsCommentedTokens(text, leadingWhitespace); + return GetXmlTrivia("typeparam", attribute, contents, leadingWhitespace); + } + + return new(); + } + + public static IEnumerable GetTypeParameters(IEnumerable typeParams) + { + return typeParams + .Where(typeParam => !typeParam.ParsedText.IsDocsEmpty()) + .Select(GetTypeParameter)!; + } + + public static SyntaxTriviaList GetTypeParameters(DocsAPI api, SyntaxTriviaList leadingWhitespace) + { + SyntaxTriviaList typeParameters = new(); + foreach (SyntaxTriviaList typeParameterTrivia in api.TypeParams + .Where(typeParam => !typeParam.Value.IsDocsEmpty()) + .Select(typeParam => GetTypeParam(typeParam.Name, typeParam.Value, leadingWhitespace))) + { + typeParameters = typeParameters.AddRange(typeParameterTrivia); + } + return typeParameters; + } + + public static XmlNodeSyntax? GetReturns(string returns) + { + if (!returns.IsDocsEmpty()) + { + return XmlReturnsElement(XmlText(returns)); + } + + return null; + } + + public static SyntaxTriviaList GetReturns(DocsMember api, SyntaxTriviaList leadingWhitespace) + { + // Also applies for when is empty because the method return type is void + if (!api.Returns.IsDocsEmpty()) + { + XmlTextSyntax contents = GetTextAsCommentedTokens(api.Returns, leadingWhitespace); + XmlElementSyntax element = XmlReturnsElement(contents); + return GetXmlTrivia(element, leadingWhitespace); + } + + return new(); + } + + public static XmlNodeSyntax GetException(DocsException exception) + { + var withoutPrefix = RemoveCrefPrefix(exception.Cref); + var crefType = TypeCref(ParseTypeName(withoutPrefix)); + + return XmlExceptionElement(crefType, XmlText(XmlTextLiteral(exception.Value, exception.Value))); + } + + public static SyntaxTriviaList GetException(string cref, string text, SyntaxTriviaList leadingWhitespace) + { + if (!text.IsDocsEmpty()) + { + cref = RemoveCrefPrefix(cref); + TypeCrefSyntax crefSyntax = TypeCref(ParseTypeName(cref)); + XmlTextSyntax contents = GetTextAsCommentedTokens(text, leadingWhitespace); + XmlElementSyntax element = XmlExceptionElement(crefSyntax, contents); + return GetXmlTrivia(element, leadingWhitespace); + } + + return new(); + } + + public static IEnumerable GetExceptions(List docsExceptions) => + docsExceptions.Select(GetException); + + public static SyntaxTriviaList GetExceptions(List docsExceptions, SyntaxTriviaList leadingWhitespace) + { + SyntaxTriviaList exceptions = new(); + if (docsExceptions.Any()) + { + foreach (SyntaxTriviaList exceptionsTrivia in docsExceptions.Select( + exception => GetException(exception.Cref, exception.Value, leadingWhitespace))) + { + exceptions = exceptions.AddRange(exceptionsTrivia); + } + } + return exceptions; + } + + public static XmlNodeSyntax GetSeeAlso(string cref) + { + var withoutPrefix = RemoveCrefPrefix(cref); + var crefType = TypeCref(ParseTypeName(withoutPrefix)); + + return XmlSeeAlsoElement(crefType); + } + + public static SyntaxTriviaList GetSeeAlso(string cref, SyntaxTriviaList leadingWhitespace) + { + cref = RemoveCrefPrefix(cref); + TypeCrefSyntax crefSyntax = TypeCref(ParseTypeName(cref)); + XmlEmptyElementSyntax element = XmlSeeAlsoElement(crefSyntax); + return GetXmlTrivia(element, leadingWhitespace); + } + + public static IEnumerable GetSeeAlsos(List docsSeeAlsoCrefs) => + docsSeeAlsoCrefs.Select(GetSeeAlso); + + public static SyntaxTriviaList GetSeeAlsos(List docsSeeAlsoCrefs, SyntaxTriviaList leadingWhitespace) + { + SyntaxTriviaList seealsos = new(); + if (docsSeeAlsoCrefs.Any()) + { + foreach (SyntaxTriviaList seealsoTrivia in docsSeeAlsoCrefs.Select( + s => GetSeeAlso(s, leadingWhitespace))) + { + seealsos = seealsos.AddRange(seealsoTrivia); + } + } + return seealsos; + } + + public static XmlNodeSyntax GetAltMember(string cref) + { + var withoutPrefix = RemoveCrefPrefix(cref); + var crefType = MapDocIdGenericsToCrefGenerics(withoutPrefix); + + return XmlEmptyElement(XmlName("altmember"), new(new[] { XmlTextAttribute("cref", crefType) })); + } + + public static SyntaxTriviaList GetAltMember(string cref, SyntaxTriviaList leadingWhitespace) + { + cref = RemoveCrefPrefix(cref); + cref = MapDocIdGenericsToCrefGenerics(cref); + XmlAttributeSyntax attribute = XmlTextAttribute("cref", cref); + XmlEmptyElementSyntax emptyElement = XmlEmptyElement(XmlName(Identifier("altmember")), new SyntaxList(attribute)); + return GetXmlTrivia(emptyElement, leadingWhitespace); + } + + public static IEnumerable GetAltMembers(List docsAltMembers) + => docsAltMembers.Select(GetAltMember); + + public static SyntaxTriviaList GetAltMembers(List docsAltMembers, SyntaxTriviaList leadingWhitespace) + { + SyntaxTriviaList altMembers = new(); + if (docsAltMembers.Any()) + { + foreach (SyntaxTriviaList altMemberTrivia in docsAltMembers.Select( + s => GetAltMember(s, leadingWhitespace))) + { + altMembers = altMembers.AddRange(altMemberTrivia); + } + } + return altMembers; + } + + public static XmlNodeSyntax GetRelated(DocsRelated related) + { + var attributes = new[] + { + XmlTextAttribute("type", related.ArticleType), + XmlTextAttribute("href", related.Href) + }; + + var start = XmlElementStartTag(XmlName("related"), new(attributes)); + var end = XmlElementEndTag(XmlName("related")); + + return XmlElement(start, new(XmlText(XmlTextLiteral(related.Value))), end); + } + + public static SyntaxTriviaList GetRelated(string articleType, string href, string value, SyntaxTriviaList leadingWhitespace) + { + SyntaxList attributes = new(); + + attributes = attributes.Add(XmlTextAttribute("type", articleType)); + attributes = attributes.Add(XmlTextAttribute("href", href)); + + XmlTextSyntax contents = GetTextAsCommentedTokens(value, leadingWhitespace); + return GetXmlTrivia("related", attributes, contents, leadingWhitespace); + } + + public static IEnumerable GetRelateds(List docsRelateds) => + docsRelateds.Select(GetRelated); + + public static SyntaxTriviaList GetRelateds(List docsRelateds, SyntaxTriviaList leadingWhitespace) + { + SyntaxTriviaList relateds = new(); + if (docsRelateds.Any()) + { + foreach (SyntaxTriviaList relatedsTrivia in docsRelateds.Select( + s => GetRelated(s.ArticleType, s.Href, s.Value, leadingWhitespace))) + { + relateds = relateds.AddRange(relatedsTrivia); + } + } + return relateds; + } + + private static XmlTextSyntax GetTextAsCommentedTokens(string text, SyntaxTriviaList leadingWhitespace, bool wrapWithNewLines = false) + { + text = CleanCrefs(text); + + // collapse newlines to a single one + string whitespace = Regex.Replace(leadingWhitespace.ToFullString(), @"(\r?\n)+", ""); + SyntaxToken whitespaceToken = XmlTextNewLine(Environment.NewLine + whitespace); + + SyntaxTrivia leadingTrivia = SyntaxTrivia(SyntaxKind.DocumentationCommentExteriorTrivia, string.Empty); + SyntaxTriviaList leading = SyntaxTriviaList.Create(leadingTrivia); + + string[] lines = text.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + var tokens = new List(); + + if (wrapWithNewLines) + { + tokens.Add(whitespaceToken); + } + + for (int lineNumber = 0; lineNumber < lines.Length; lineNumber++) + { + string line = lines[lineNumber]; + + SyntaxToken token = XmlTextLiteral(leading, line, line, default); + tokens.Add(token); + + if (lines.Length > 1 && lineNumber < lines.Length - 1) + { + tokens.Add(whitespaceToken); + } + } + + if (wrapWithNewLines) + { + tokens.Add(whitespaceToken); + } + + XmlTextSyntax xmlText = XmlText(tokens.ToArray()); + return xmlText; + } + + private static SyntaxTriviaList GetXmlTrivia(XmlNodeSyntax node, SyntaxTriviaList leadingWhitespace) + { + DocumentationCommentTriviaSyntax docComment = DocumentationComment(node); + SyntaxTrivia docCommentTrivia = Trivia(docComment); + + return leadingWhitespace + .Add(docCommentTrivia) + .Add(CarriageReturnLineFeed); + } + + private static XmlNodeSyntax GetXmlNode(string name, SyntaxList attributes, XmlTextSyntax content) + { + var start = XmlElementStartTag( + Token(SyntaxKind.LessThanToken), + XmlName(Identifier(name)), + attributes, + Token(SyntaxKind.GreaterThanToken)); + + var end = XmlElementEndTag( + Token(SyntaxKind.LessThanSlashToken), + XmlName(Identifier(name)), + Token(SyntaxKind.GreaterThanToken)); + + return XmlElement(start, new(content), end); + } + + // Generates a custom SyntaxTrivia object containing a triple slashed xml element with optional attributes. + // Looks like below (excluding square brackets): + // [ /// text] + private static SyntaxTriviaList GetXmlTrivia(string name, SyntaxList attributes, XmlTextSyntax contents, SyntaxTriviaList leadingWhitespace) + { + XmlElementStartTagSyntax start = XmlElementStartTag( + Token(SyntaxKind.LessThanToken), + XmlName(Identifier(name)), + attributes, + Token(SyntaxKind.GreaterThanToken)); + + XmlElementEndTagSyntax end = XmlElementEndTag( + Token(SyntaxKind.LessThanSlashToken), + XmlName(Identifier(name)), + Token(SyntaxKind.GreaterThanToken)); + + XmlElementSyntax element = XmlElement(start, new SyntaxList(contents), end); + return GetXmlTrivia(element, leadingWhitespace); + } + + private static string WrapInRemarks(string acum) + { + string wrapped = Environment.NewLine + "" + Environment.NewLine; + return wrapped; + } + + private static string WrapCodeIncludes(string[] splitted, ref int n) + { + string acum = string.Empty; + while (n < splitted.Length && splitted[n].ContainsStrings(MarkdownCodeIncludes)) + { + acum += Environment.NewLine + splitted[n]; + if ((n + 1) < splitted.Length && splitted[n + 1].ContainsStrings(MarkdownCodeIncludes)) + { + n++; + } + else + { + break; + } + } + return WrapInRemarks(acum); + } + + private static SyntaxTriviaList GetFormattedRemarks(IDocsAPI api, SyntaxTriviaList leadingWhitespace) + { + + string remarks = RemoveUnnecessaryMarkdown(api.Remarks); + string example = string.Empty; + + XmlNodeSyntax contents; + if (remarks.ContainsStrings(MarkdownUnconvertableStrings)) + { + contents = GetTextAsFormatCData(remarks, leadingWhitespace); + } + else + { + string[] splitted = remarks.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + string updatedRemarks = string.Empty; + for (int n = 0; n < splitted.Length; n++) + { + string acum; + string line = splitted[n]; + if (line.ContainsStrings(MarkdownHeaders)) + { + acum = line; + n++; + while (n < splitted.Length && splitted[n].StartsWith(">")) + { + acum += Environment.NewLine + splitted[n]; + if ((n + 1) < splitted.Length && splitted[n + 1].StartsWith(">")) + { + n++; + } + else + { + break; + } + } + updatedRemarks += WrapInRemarks(acum); + } + else if (line.ContainsStrings(MarkdownCodeIncludes)) + { + updatedRemarks += WrapCodeIncludes(splitted, ref n); + } + // When an example is found, everything after the header is considered part of that section + else if (line.Contains("## Example")) + { + n++; + while (n < splitted.Length) + { + line = splitted[n]; + if (line.ContainsStrings(MarkdownCodeIncludes)) + { + example += WrapCodeIncludes(splitted, ref n); + } + else + { + example += Environment.NewLine + line; + } + n++; + } + } + else + { + updatedRemarks += ReplaceMarkdownWithXmlElements(Environment.NewLine + line, api.Params, api.TypeParams); + } + } + + contents = GetTextAsCommentedTokens(updatedRemarks, leadingWhitespace); + } + + XmlElementSyntax remarksXml = XmlRemarksElement(contents); + SyntaxTriviaList result = GetXmlTrivia(remarksXml, leadingWhitespace); + + if (!string.IsNullOrWhiteSpace(example)) + { + SyntaxTriviaList exampleTriviaList = GetFormattedExamples(api, example, leadingWhitespace); + result = result.AddRange(exampleTriviaList); + } + + return result; + } + + private static SyntaxTriviaList GetFormattedExamples(IDocsAPI api, string example, SyntaxTriviaList leadingWhitespace) + { + example = ReplaceMarkdownWithXmlElements(example, api.Params, api.TypeParams); + XmlNodeSyntax exampleContents = GetTextAsCommentedTokens(example, leadingWhitespace); + XmlElementSyntax exampleXml = XmlExampleElement(exampleContents); + SyntaxTriviaList exampleTriviaList = GetXmlTrivia(exampleXml, leadingWhitespace); + return exampleTriviaList; + } + + private static XmlNodeSyntax GetTextAsFormatCData(string text, SyntaxTriviaList leadingWhitespace) + { + XmlTextSyntax remarks = GetTextAsCommentedTokens(text, leadingWhitespace, wrapWithNewLines: true); + + XmlNameSyntax formatName = XmlName("format"); + XmlAttributeSyntax formatAttribute = XmlTextAttribute("type", "text/markdown"); + var formatAttributes = new SyntaxList(formatAttribute); + + var formatStart = XmlElementStartTag(formatName, formatAttributes); + var formatEnd = XmlElementEndTag(formatName); + + XmlCDataSectionSyntax cdata = XmlCDataSection(remarks.TextTokens); + var cdataList = new SyntaxList(cdata); + + XmlElementSyntax contents = XmlElement(formatStart, cdataList, formatEnd); + + return contents; + } + + private static string RemoveUnnecessaryMarkdown(string text) + { + text = Regex.Replace(text, @"", ""); + text = Regex.Replace(text, @"##[ ]?Remarks(\r?\n)*[\t ]*", ""); + return text; + } + + private static string ReplaceMarkdownWithXmlElements(string text, List docsParams, List docsTypeParams) + { + text = CleanXrefs(text); + + // commonly used url entities + text = Regex.Replace(text, @"%23", "#"); + text = Regex.Replace(text, @"%28", "("); + text = Regex.Replace(text, @"%29", ")"); + text = Regex.Replace(text, @"%2C", ","); + + // hyperlinks + text = Regex.Replace(text, RegexMarkdownLinkPattern, RegexHtmlLinkReplacement); + + // bold + text = Regex.Replace(text, RegexMarkdownBoldPattern, RegexXmlBoldReplacement); + + // code snippet + text = Regex.Replace(text, RegexMarkdownCodeStartPattern, RegexXmlCodeStartReplacement); + text = Regex.Replace(text, RegexMarkdownCodeEndPattern, RegexXmlCodeEndReplacement); + + // langwords|parameters|typeparams and other type references within markdown backticks + MatchCollection collection = Regex.Matches(text, @"(?`(?[a-zA-Z0-9_]+(?\<(?[a-zA-Z0-9_,]+)\>){0,1})`)"); + foreach (Match match in collection) + { + string backtickContent = match.Groups["backtickContent"].Value; + string backtickedApi = match.Groups["backtickedApi"].Value; + Group genericType = match.Groups["genericType"]; + Group typeParam = match.Groups["typeParam"]; + + if (genericType.Success && typeParam.Success) + { + backtickedApi = backtickedApi.Replace(genericType.Value, $"{{{typeParam.Value}}}"); + } + + if (ReservedKeywords.Any(x => x == backtickedApi)) + { + text = Regex.Replace(text, $"{backtickContent}", $""); + } + else if (docsParams.Any(x => x.Name == backtickedApi)) + { + text = Regex.Replace(text, $"{backtickContent}", $""); + } + else if (docsTypeParams.Any(x => x.Name == backtickedApi)) + { + text = Regex.Replace(text, $"{backtickContent}", $""); + } + else + { + text = Regex.Replace(text, $"{backtickContent}", $""); + } + } + + return text; + } + + // Removes the one letter prefix and the following colon, if found, from a cref. + private static string RemoveCrefPrefix(string cref) + { + if (cref.Length > 2 && cref[1] == ':') + { + return cref[2..]; + } + return cref; + } + + private static string ReplacePrimitives(string text) + { + foreach ((string key, string value) in PrimitiveTypes) + { + text = Regex.Replace(text, key, value); + } + return text; + } + + private static string ReplaceDocId(Match m) + { + string docId = m.Groups["docId"].Value; + string? prefix = m.Groups["prefix"].Value == "O:" ? "O:" : null; + docId = ReplacePrimitives(docId); + docId = System.Net.WebUtility.UrlDecode(docId); + + // Strip '*' character from the tail end of DocId names + if (docId.EndsWith('*')) + { + prefix = "O:"; + docId = docId[..^1]; + } + + return prefix + MapDocIdGenericsToCrefGenerics(docId); + } + + private static string MapDocIdGenericsToCrefGenerics(string docId) + { + // Map DocId generic parameters to Xml Doc generic parameters + // need to support both single and double backtick syntax + const string GenericParameterPattern = @"`{1,2}([\d+])"; + int genericParameterArity = 0; + return Regex.Replace(docId, GenericParameterPattern, MapDocIdGenericParameterToXmlDocGenericParameter); + + string MapDocIdGenericParameterToXmlDocGenericParameter(Match match) + { + int index = int.Parse(match.Groups[1].Value); + + if (genericParameterArity == 0) + { + // this is the first match that declares the generic parameter arity of the method + // e.g. GenericMethod``3 ---> GenericMethod{T1,T2,T3}(...); + Debug.Assert(index > 0); + genericParameterArity = index; + return WrapInCurlyBrackets(string.Join(",", Enumerable.Range(0, index).Select(CreateGenericParameterName))); + } + + // Subsequent matches are references to generic parameters in the method signature, + // e.g. GenericMethod{T1,T2,T3}(..., List{``1} parameter, ...); ---> List{T2} parameter + return CreateGenericParameterName(index); + + // NB this naming scheme does not map to the exact generic parameter names, + // however this is still accepted by intellisense and backporters can rename + // manually with the help of tooling. + string CreateGenericParameterName(int index) + => genericParameterArity == 1 ? "T" : $"T{index + 1}"; + + static string WrapInCurlyBrackets(string input) => $"{{{input}}}"; + } + } + + private static string CrefEvaluator(Match m) + { + string docId = ReplaceDocId(m); + return "cref=\"" + docId + "\""; + } + + private static string CleanCrefs(string text) + { + text = Regex.Replace(text, RegexXmlCrefPattern, CrefEvaluator); + return text; + } + + private static string XrefEvaluator(Match m) + { + string docId = ReplaceDocId(m); + return ""; + } + + private static string CleanXrefs(string text) + { + text = Regex.Replace(text, RegexMarkdownXrefPattern, XrefEvaluator); + return text; + } + } +} diff --git a/Libraries/XmlHelper.cs b/Libraries/XmlHelper.cs index b1e40fc..fe40eae 100644 --- a/Libraries/XmlHelper.cs +++ b/Libraries/XmlHelper.cs @@ -9,7 +9,8 @@ namespace Libraries { internal class XmlHelper { - private static readonly Dictionary _replaceableNormalElementPatterns = new Dictionary { + private static readonly Dictionary _replaceableNormalElementPatterns = new() + { { "null", ""}, { "true", ""}, { "false", ""}, @@ -31,7 +32,8 @@ internal class XmlHelper { ">", " />" } }; - private static readonly Dictionary _replaceableMarkdownPatterns = new Dictionary { + private static readonly Dictionary _replaceableMarkdownPatterns = new() + { { "", "`null`" }, { "", "`null`" }, { "", "`true`" }, @@ -72,13 +74,15 @@ internal class XmlHelper { "", "" } }; - private static readonly Dictionary _replaceableExceptionPatterns = new Dictionary{ + private static readonly Dictionary _replaceableExceptionPatterns = new() + { { "", "\r\n" }, { "", "" } }; - private static readonly Dictionary _replaceableMarkdownRegexPatterns = new Dictionary { + private static readonly Dictionary _replaceableMarkdownRegexPatterns = new() + { { @"\", @"`${paramrefContents}`" }, { @"\", @"seealsoContents" }, }; @@ -153,7 +157,7 @@ public static void SaveFormattedAsMarkdown(XElement element, string newValue, bo // Empty value because SaveChildElement will add a child to the parent, not replace it element.Value = string.Empty; - XElement xeFormat = new XElement("format"); + XElement xeFormat = new("format"); string updatedValue = SubstituteRemarksRegexPatterns(newValue); updatedValue = ReplaceMarkdownPatterns(updatedValue).Trim(); @@ -305,7 +309,7 @@ private static string SubstituteRegexPatterns(string value, Dictionary pattern in replaceableRegexPatterns) { - Regex regex = new Regex(pattern.Key); + Regex regex = new(pattern.Key); if (regex.IsMatch(value)) { value = regex.Replace(value, pattern.Value); diff --git a/Packages.props b/Packages.props new file mode 100644 index 0000000..83413fe --- /dev/null +++ b/Packages.props @@ -0,0 +1,29 @@ + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/Program/DocsPortingTool.csproj b/Program/DocsPortingTool.csproj index 67aff36..8a41b1c 100644 --- a/Program/DocsPortingTool.csproj +++ b/Program/DocsPortingTool.csproj @@ -14,10 +14,10 @@ - - - - + + + + diff --git a/Tests/DocsApi/DocsApiReferenceTests.cs b/Tests/DocsApi/DocsApiReferenceTests.cs new file mode 100644 index 0000000..d970800 --- /dev/null +++ b/Tests/DocsApi/DocsApiReferenceTests.cs @@ -0,0 +1,88 @@ +using Xunit; + +namespace Libraries.Docs.Tests +{ + public class DocsApiReferenceTests + { + [Theory] + [InlineData("System.Boolean", "bool")] + [InlineData("System.Byte", "byte")] + [InlineData("System.Char", "char")] + [InlineData("System.Decimal", "decimal")] + [InlineData("System.Double", "double")] + [InlineData("System.Int16", "short")] + [InlineData("System.Int32", "int")] + [InlineData("System.Int64", "long")] + [InlineData("System.Object", "object")] + [InlineData("System.SByte", "sbyte")] + [InlineData("System.Single", "float")] + [InlineData("System.String", "string")] + [InlineData("System.UInt16", "ushort")] + [InlineData("System.UInt32", "uint")] + [InlineData("System.UInt64", "ulong")] + [InlineData("System.Void", "void")] + [InlineData("System.Int32.ToString(System.String)", "int.ToString(string)")] + public void ReturnsShorthandPrimitives(string apiReference, string expected) + { + var reference = new DocsApiReference(apiReference); + Assert.Equal(expected, reference.Api); + } + + [Theory] + [InlineData("T:System.Int32", "int")] + [InlineData("M:System.Int32.ToString()", "int.ToString()")] + [InlineData("O:System.Int32.ToString(System.String)", "int.ToString(string)")] + public void RemovesPrefixFromApi(string apiReference, string expected) + { + var reference = new DocsApiReference(apiReference); + Assert.Equal(expected, reference.Api); + } + + [Theory] + [InlineData("System.Int32", "int")] + [InlineData("System.Int32.ToString()", "int.ToString()")] + [InlineData("System.Int32.ToString(System.String)", "int.ToString(string)")] + [InlineData("T:System.Int32", "T:int")] + [InlineData("M:System.Int32.ToString()", "M:int.ToString()")] + [InlineData("O:System.Int32.ToString(System.String)", "O:int.ToString(string)")] + public void OverridesToStringWithPrefixAndApi(string apiReference, string expected) + { + var reference = new DocsApiReference(apiReference); + Assert.Equal(expected, reference.ToString()); + } + + [Theory] + [InlineData("MyNamespace.MyGenericType.Select`1", "MyNamespace.MyGenericType.Select{T}")] + [InlineData("MyNamespace.MyGenericType.Select`2", "MyNamespace.MyGenericType.Select{T1,T2}")] + [InlineData("MyNamespace.MyGenericType.Select``2(MyNamespace.MyGenericType{``0},System.Func{``0,``1})", "MyNamespace.MyGenericType.Select{T1,T2}(MyNamespace.MyGenericType{T1},System.Func{T1,T2})")] + [InlineData("MyNamespace.MyGenericType.Select%60%602%28MyNamespace.MyGenericType%7B%60%600%7D%2CSystem.Func%7B%60%600%2C%60%601%7D%29", "MyNamespace.MyGenericType.Select{T1,T2}(MyNamespace.MyGenericType{T1},System.Func{T1,T2})")] + public void ParsesGenerics(string apiReference, string expected) + { + var reference = new DocsApiReference(apiReference); + Assert.Equal(expected, reference.Api); + } + + [Theory] + [InlineData("System.Int32", false)] + [InlineData("T:System.Int32", false)] + [InlineData("M:System.Int32.ToString()", false)] + [InlineData("O:System.Int32.ToString(System.String)", true)] + public void IdentifiesOverloadReferences(string apiReference, bool expected) + { + var reference = new DocsApiReference(apiReference); + Assert.Equal(expected, reference.IsOverload); + } + + [Theory] + [InlineData(@"Accessibility: ", @"Accessibility: ")] + [InlineData(@"The `MyGenericType` type contains the nested class .", @"The `MyGenericType` type contains the nested class .")] + [InlineData(@"SyndicationCategory: ", @"SyndicationCategory: ")] + [InlineData(@"Label: ", @"Label: ")] + [InlineData(@"==: ", @"==: ")] + public void ReplacesMarkdownXrefsWithSeeCrefs(string markdown, string expected) + { + var replaced = DocsApiReference.ReplaceMarkdownXrefWithSeeCref(markdown); + Assert.Equal(expected, replaced); + } + } +} diff --git a/Tests/DocsApi/DocsAssemblyInfoTests.cs b/Tests/DocsApi/DocsAssemblyInfoTests.cs new file mode 100644 index 0000000..a1d7b5d --- /dev/null +++ b/Tests/DocsApi/DocsAssemblyInfoTests.cs @@ -0,0 +1,39 @@ +using System.Xml.Linq; +using Xunit; + +namespace Libraries.Docs.Tests +{ + public class DocsAssemblyInfoTests + { + [Fact] + public void ExtractsAssemblyName() + { + var assembly = new DocsAssemblyInfo(XElement.Parse(@" + + MyAssembly + " + )); + + Assert.Equal("MyAssembly", assembly.AssemblyName); + } + + [Theory] + [InlineData(@" + + 4.0.0.0 + ", + new string[] { "4.0.0.0" })] + [InlineData(@" + + 4.0.0.0 + 5.0.0.0 + ", + new string[] { "4.0.0.0", "5.0.0.0" })] + public void ExtractsOneAssemblyVersion(string xml, string[] expected) + { + var assembly = new DocsAssemblyInfo(XElement.Parse(xml)); + + Assert.Equal(expected, assembly.AssemblyVersions); + } + } +} diff --git a/Tests/DocsApi/DocsAttributeTests.cs b/Tests/DocsApi/DocsAttributeTests.cs new file mode 100644 index 0000000..cced809 --- /dev/null +++ b/Tests/DocsApi/DocsAttributeTests.cs @@ -0,0 +1,49 @@ +using System.Xml.Linq; +using Xunit; + +namespace Libraries.Docs.Tests +{ + public class DocsAttributeTests + { + [Theory] + [InlineData( + @"", + @"netframework-4.0")] + public void ExtractsFrameworkAlternate(string xml, string expected) + { + var attribute = new DocsAttribute(XElement.Parse(xml)); + Assert.Equal(expected, attribute.FrameworkAlternate); + } + + [Theory] + [InlineData("C#", @"[System.Runtime.TargetedPatchingOptOut(""Performance critical to inline this type of method across NGen image boundaries"")]")] + [InlineData("F#", @"[]")] + public void ExtractsAttributeNameByLanguage(string language, string expected) + { + var attribute = new DocsAttribute(XElement.Parse(@" + + [System.Runtime.TargetedPatchingOptOut(""Performance critical to inline this type of method across NGen image boundaries"")] + [<System.Runtime.TargetedPatchingOptOut(""Performance critical to inline this type of method across NGen image boundaries"")>] + + ")); + + var actual = attribute.GetAttributeName(language); + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData(@" + + [System.Runtime.TargetedPatchingOptOut(""Performance critical to inline this type of method across NGen image boundaries"")] + [<System.Runtime.TargetedPatchingOptOut(""Performance critical to inline this type of method across NGen image boundaries"")>] + ", + @"[System.Runtime.TargetedPatchingOptOut(""Performance critical to inline this type of method across NGen image boundaries"")]")] + public void ExtractsAttributeNameForCsharpByDefault(string xml, string expected) + { + var attribute = new DocsAttribute(XElement.Parse(xml)); + var actual = attribute.GetAttributeName("C#"); + + Assert.Equal(expected, actual); + } + } +} diff --git a/Tests/DocsApi/DocsExampleTests.cs b/Tests/DocsApi/DocsExampleTests.cs new file mode 100644 index 0000000..238971d --- /dev/null +++ b/Tests/DocsApi/DocsExampleTests.cs @@ -0,0 +1,206 @@ +using System.Collections.Generic; +using System.Linq; +using System.Xml; +using System.Xml.Linq; +using Xunit; + +namespace Libraries.Docs.Tests +{ + public class DocsExampleTests + { + [Theory] + [InlineData( + @"Example.", + @"Example.")] + [InlineData( + @"Example referencing .", + @"Example referencing .")] + [InlineData( + @" + Multiline + Example + Referencing + . + ", + @" + Multiline + Example + Referencing + . + ")] + public void GetsRawText(string xml, string expected) + { + var example = new DocsExample(XElement.Parse(xml)); + Assert.Equal(expected, example.RawText); + } + + [Theory] + [InlineData( + @"Example.", + @"Example.")] + [InlineData( + @"Example referencing .", + @"Example referencing .")] + [InlineData( + @" + Multiline + + Example + + Referencing + + + + With Blank Lines. + ", + @"Multiline +Example +Referencing + +With Blank Lines.")] + [InlineData( + @" + + ", + @"Markdown example")] + public void GetsParsedText(string xml, string expected) + { + var example = new DocsExample(XElement.Parse(xml)); + Assert.Equal(expected, example.ParsedText); + } + + [Theory] + [InlineData( // [!INCLUDE + @"", + @"")] + [InlineData( // [!NOTE + @"", + @"")] + [InlineData( // [!IMPORTANT + @"", + @"")] + [InlineData( // [!TIP + @"", + @"")] + [InlineData( // [!code-cpp + @"", + @"")] + [InlineData( // [!code-csharp + @"", + @"")] + [InlineData( // [!code-vb + @"", + @"")] + public void RetainsMarkdownFormatForUnparseableContent(string xml, string expected) + { + var example = new DocsExample(XElement.Parse(xml)); + Assert.Equal(expected, example.ParsedText); + } + + [Theory] + [InlineData( // [!INCLUDE -- Without CDATA + @"Has an inline include. [!INCLUDE[include-file](~/includes/include-file.md)]", + @"Has an inline include. [!INCLUDE[include-file](~/includes/include-file.md)]")] + [InlineData( // [!INCLUDE -- With CDATA + @"", + @"")] + [InlineData( // [!INCLUDE -- With CDATA and newlines + @"", + @"")] + public void RetainsMarkdownStructure(string xml, string expected) + { + var example = new DocsExample(XElement.Parse(xml)); + Assert.Equal(expected, example.ParsedText); + } + + [Fact] + public void ReplacesMarkdownCodeSnippetsWithTags() + { + var xml = @""; + + var expected = @"Here's an example: + +Console.WriteLine(""Hello World!""); +"; + + var example = new DocsExample(XElement.Parse(xml)); + Assert.Equal(expected, example.ParsedText); + } + + [Fact] + public void GetsNodes() + { + var xml = @"Example referencing a ."; + var example = new DocsExample(XElement.Parse(xml)); + + var expected = new XNode[] + { + new XText("Example referencing a "), + XElement.Parse(@""), + new XText(".") + }; + + Assert.Equal(expected.Select(x => x.ToString()), example.RawNodes.ToArray().Select(x => x.ToString())); + } + + [Fact] + public void CanIncludeSeeElements() + { + var xml = @""; + var example = new DocsExample(XElement.Parse(xml)); + var see = example.RawNodes.Single(); + + Assert.Equal(XmlNodeType.Element, see.NodeType); + } + + [Fact] + public void CanExposeRawSeeElements() + { + var xml = @""; + var example = new DocsExample(XElement.Parse(xml)); + var see = example.RawNodes.Single(); + + Assert.Equal("see", ((XElement)see).Name); + } + + [Fact] + public void CanExposeRawSeeCrefValues() + { + var xml = @""; + var example = new DocsExample(XElement.Parse(xml)); + var see = example.RawNodes.Single(); + + Assert.Equal("T:System.Type", ((XElement)see).Attribute("cref").Value); + } + + [Fact] + public void ParsesNodes() + { + var xml = @"Example referencing a ."; + var example = new DocsExample(XElement.Parse(xml)); + + var expected = new XNode[] + { + new XText("Example referencing a "), + XElement.Parse(@""), + new XText(".") + }; + + Assert.Equal(expected.Select(x => x.ToString()), example.ParsedNodes.ToArray()); + } + } +} diff --git a/Tests/DocsApi/DocsExceptionTests.cs b/Tests/DocsApi/DocsExceptionTests.cs new file mode 100644 index 0000000..64fb11a --- /dev/null +++ b/Tests/DocsApi/DocsExceptionTests.cs @@ -0,0 +1,49 @@ +using System.Xml.Linq; +using Xunit; + +namespace Libraries.Docs.Tests +{ + public class DocsExceptionTests + { + [Theory] + [InlineData( + @" + If a null reference is returned from ", + @"T:System.InvalidOperationException")] + public void ExtractsCref(string xml, string expected) + { + var parent = new TestDocsApi(); + var exception = new DocsException(parent, XElement.Parse(xml)); + + Assert.Equal(expected, exception.Cref); + } + + [Theory] + [InlineData( + @" + If a null reference is returned from ", + @"If a null reference is returned from ")] + [InlineData( + @"This is the IndexOutOfRangeException thrown by MyVoidMethod. + +-or- + +This is the second case. + +Empty newlines should be respected.", + @"This is the IndexOutOfRangeException thrown by MyVoidMethod. + +-or- + +This is the second case. + +Empty newlines should be respected.")] + public void ExtractsValueInPlainText(string xml, string expected) + { + var parent = new TestDocsApi(); + var exception = new DocsException(parent, XElement.Parse(xml)); + + Assert.Equal(expected, exception.Value); + } + } +} diff --git a/Tests/DocsApi/DocsParamTests.cs b/Tests/DocsApi/DocsParamTests.cs new file mode 100644 index 0000000..c1e4a47 --- /dev/null +++ b/Tests/DocsApi/DocsParamTests.cs @@ -0,0 +1,35 @@ +using System.Xml.Linq; +using Xunit; + +namespace Libraries.Docs.Tests +{ + public class DocsParamTests + { + [Theory] + [InlineData( + @"The object to animate.", + @"image")] + public void ExtractsName(string xml, string expected) + { + var parent = new TestDocsApi(); + var param = new DocsParam(parent, XElement.Parse(xml)); + + Assert.Equal(expected, param.Name); + } + + [Theory] + [InlineData( + @"The object to animate.", + @"The object to animate.")] + [InlineData( + @"The object to be added to the end of the . The value can be for reference types.", + @"The object to be added to the end of the . The value can be for reference types.")] + public void ExtractsValueAsPlainText(string xml, string expected) + { + var parent = new TestDocsApi(); + var param = new DocsParam(parent, XElement.Parse(xml)); + + Assert.Equal(expected, param.Value); + } + } +} diff --git a/Tests/DocsApi/DocsParameterTests.cs b/Tests/DocsApi/DocsParameterTests.cs new file mode 100644 index 0000000..f1366fb --- /dev/null +++ b/Tests/DocsApi/DocsParameterTests.cs @@ -0,0 +1,31 @@ +using System.Xml.Linq; +using Xunit; + +namespace Libraries.Docs.Tests +{ + public class DocsParameterTests + { + [Theory] + [InlineData( + @"", + @"image")] + public void ExtractsName(string xml, string expected) + { + var parameter = new DocsParameter(XElement.Parse(xml)); + Assert.Equal(expected, parameter.Name); + } + + [Theory] + [InlineData( + @"", + @"System.Drawing.Image")] + [InlineData( + @"", + @"System.Collections.Generic.IEnumerable")] + public void ExtractsType(string xml, string expected) + { + var parameter = new DocsParameter(XElement.Parse(xml)); + Assert.Equal(expected, parameter.Type); + } + } +} diff --git a/Tests/DocsApi/DocsRelatedTests.cs b/Tests/DocsApi/DocsRelatedTests.cs new file mode 100644 index 0000000..6054518 --- /dev/null +++ b/Tests/DocsApi/DocsRelatedTests.cs @@ -0,0 +1,48 @@ +using System.Xml.Linq; +using Xunit; + +namespace Libraries.Docs.Tests +{ + public class DocsRelatedTests + { + [Fact] + public void ExtractsArticleType() + { + var parent = new TestDocsApi(); + var related = new DocsRelated(parent, XElement.Parse(@" + Iterators (C#) + ")); + + Assert.Equal("Article", related.ArticleType); + } + + [Fact] + public void ExtractsHref() + { + var parent = new TestDocsApi(); + var related = new DocsRelated(parent, XElement.Parse(@" + Iterators (C#) + ")); + + Assert.Equal("/dotnet/csharp/programming-guide/concepts/iterators", related.Href); + } + + [Theory] + [InlineData( + @"Iterators (C#)", + @"Iterators (C#)")] + [InlineData( + @"<compilers> Element", + @"<compilers> Element")] + [InlineData( + @"Memory<T> and Span<T> usage guidelines", + @"Memory<T> and Span<T> usage guidelines")] + public void ExtractsValueAsPlainText(string xml, string expected) + { + var parent = new TestDocsApi(); + var related = new DocsRelated(parent, XElement.Parse(xml)); + + Assert.Equal(expected, related.Value); + } + } +} diff --git a/Tests/DocsApi/DocsRemarksTests.cs b/Tests/DocsApi/DocsRemarksTests.cs new file mode 100644 index 0000000..487d0e7 --- /dev/null +++ b/Tests/DocsApi/DocsRemarksTests.cs @@ -0,0 +1,394 @@ +using System.Collections.Generic; +using System.Linq; +using System.Xml; +using System.Xml.Linq; +using Xunit; + +namespace Libraries.Docs.Tests +{ + public class DocsRemarksTests + { + [Theory] + [InlineData( + @"Remarks.", + @"Remarks.")] + [InlineData( + @"Remarks referencing .", + @"Remarks referencing .")] + [InlineData( + @" + Multiline + Remarks + Referencing + . + ", + @" + Multiline + Remarks + Referencing + . + ")] + [InlineData( + @" + . + +There are also a `true` and a `null`. + + ]]> + ", + @". + +There are also a `true` and a `null`. + + ]]>")] + public void GetsRawText(string xml, string expected) + { + var remarks = new DocsRemarks(XElement.Parse(xml)); + Assert.Equal(expected, remarks.RawText); + } + + [Theory] + [InlineData( + @"Remarks.", + @"Remarks.")] + [InlineData( + @"Remarks referencing .", + @"Remarks referencing .")] + [InlineData( + @" + Multiline + + Remarks + + Referencing + + + + With Blank Lines. + ", + @"Multiline +Remarks +Referencing + +With Blank Lines.")] + [InlineData( + @" This is the MyTypeParamMethod typeparam T. + + . + +There are also a `true` and a `null`. + + ]]> + ", + @"This is a reference to the typeparam . +This is a reference to the parameter . +Mentions the and an . +There are also a and a .")] + public void GetsParsedText(string xml, string expected) + { + var remarks = new DocsRemarks(XElement.Parse(xml)); + Assert.Equal(expected, remarks.ParsedText); + } + + [Theory] + [InlineData( + @" + + ", + @"Markdown remarks")] + [InlineData( + @" + + ", + @"Markdown remarks")] + public void RemovesRemarksHeader(string xml, string expected) + { + var remarks = new DocsRemarks(XElement.Parse(xml)); + Assert.Equal(expected, remarks.ParsedText); + } + + [Theory] + [InlineData( // [!INCLUDE + @"", + @"")] + [InlineData( // [!NOTE + @"", + @"")] + [InlineData( // [!IMPORTANT + @"", + @"")] + [InlineData( // [!TIP + @"", + @"")] + [InlineData( // [!code-cpp + @"", + @"")] + [InlineData( // [!code-csharp + @"", + @"")] + [InlineData( // [!code-vb + @"", + @"")] + public void RetainsMarkdownFormatForUnparseableContent(string xml, string expected) + { + var remarks = new DocsRemarks(XElement.Parse(xml)); + Assert.Equal(expected, remarks.ParsedText); + } + + [Theory] + [InlineData( // [!INCLUDE -- Without CDATA + @"Has an inline include. [!INCLUDE[include-file](~/includes/include-file.md)]", + @"Has an inline include. [!INCLUDE[include-file](~/includes/include-file.md)]")] + [InlineData( // [!INCLUDE -- With CDATA + @"", + @"")] + [InlineData( // [!INCLUDE -- With CDATA and newlines + @"", + @"")] + public void RetainsMarkdownStructure(string xml, string expected) + { + var remarks = new DocsRemarks(XElement.Parse(xml)); + Assert.Equal(expected, remarks.ParsedText); + } + + [Theory] + [InlineData( + @"", + @"")] + public void RetainsMarkdownStructureButRemovesHeader(string xml, string expected) + { + var remarks = new DocsRemarks(XElement.Parse(xml)); + Assert.Equal(expected, remarks.ParsedText); + } + + [Fact] + public void ReplacesMarkdownXrefWithTags() + { + var xml = @". + ]]>"; + + var expected = @"See ."; + var remarks = new DocsRemarks(XElement.Parse(xml)); + + Assert.Equal(expected, remarks.ParsedText); + } + + [Fact] + public void ReplacesMarkdownLinksWithTags() + { + var xml = @""; + + var expected = @"See the web."; + var remarks = new DocsRemarks(XElement.Parse(xml)); + + Assert.Equal(expected, remarks.ParsedText); + } + + [Theory] + [InlineData(@"Use `async` methods.", @"Use methods.")] + [InlineData(@"The `T` generic type parameter must be a struct.", @"The generic type parameter must be a struct.")] + [InlineData(@"The `length` parameter cannot be negative.", @"The parameter cannot be negative.")] + [InlineData(@"See `System.ComponentModel.DataAnnotations.Validator`.", @"See .")] + public void ReplacesMarkdownBacktickReferencesWithTags(string markdown, string expected) + { + var xml = $@""; + + var testDoc = new TestDocsApi(); + var typeParamT = new DocsTypeParam(testDoc, XElement.Parse(@"The struct.")); + var paramLength = new DocsParam(testDoc, XElement.Parse(@"The length.")); + + var remarks = new DocsRemarks(XElement.Parse(xml)) + { + TypeParams = new[] { typeParamT }, + Params = new[] { paramLength } + }; + + Assert.Equal(expected, remarks.ParsedText); + } + + [Theory] + [InlineData(@" + + ## EXAMPLE + EXAMPLE CONTENT + ")] + [InlineData(@" + + ##EXAMPLES + EXAMPLE CONTENT + ")] + public void RemovesExamplesFromRemarks(string xml) + { + var remarks = new DocsRemarks(XElement.Parse(xml)); + Assert.DoesNotContain("EXAMPLE CONTENT", remarks.ParsedText); + } + + [Theory] + [InlineData(@" + + ## EXAMPLE + EXAMPLE CONTENT + ", + @"")] + [InlineData(@" + + ##EXAMPLES + EXAMPLE CONTENT + ", + @"")] + [InlineData(@" + + ## REMARKS + + REMARK CONTENT + + ##EXAMPLES + + EXAMPLE CONTENT + ", + @"REMARK CONTENT")] + public void TrimsRemarksAfterRemovingExamples(string xml, string expected) + { + var remarks = new DocsRemarks(XElement.Parse(xml)); + Assert.Equal(expected, remarks.ParsedText); + } + + [Theory] + [InlineData(@" + + ## EXAMPLE + EXAMPLE CONTENT + ", + @"EXAMPLE CONTENT")] + [InlineData(@" + + ##EXAMPLES + EXAMPLE CONTENT + ", + @"EXAMPLE CONTENT")] + [InlineData(@" + + ## REMARKS + + REMARK CONTENT + + ##EXAMPLES + + EXAMPLE CONTENT + ", + @"EXAMPLE CONTENT")] + public void GetsExampleContent(string xml, string expected) + { + var remarks = new DocsRemarks(XElement.Parse(xml)); + Assert.Equal(expected, remarks.ExampleContent?.ParsedText); + } + + [Fact] + public void GetsNodes() + { + var xml = @"Remarks referencing a ."; + var remarks = new DocsRemarks(XElement.Parse(xml)); + + var expected = new XNode[] + { + new XText("Remarks referencing a "), + XElement.Parse(@""), + new XText(".") + }; + + Assert.Equal(expected.Select(x => x.ToString()), remarks.RawNodes.ToArray().Select(x => x.ToString())); + } + + [Fact] + public void CanIncludeSeeElements() + { + var xml = @""; + var remarks = new DocsRemarks(XElement.Parse(xml)); + var see = remarks.RawNodes.Single(); + + Assert.Equal(XmlNodeType.Element, see.NodeType); + } + + [Fact] + public void CanExposeRawSeeElements() + { + var xml = @""; + var remarks = new DocsRemarks(XElement.Parse(xml)); + var see = remarks.RawNodes.Single(); + + Assert.Equal("see", ((XElement)see).Name); + } + + [Fact] + public void CanExposeRawSeeCrefValues() + { + var xml = @""; + var remarks = new DocsRemarks(XElement.Parse(xml)); + var see = remarks.RawNodes.Single(); + + Assert.Equal("T:System.Type", ((XElement)see).Attribute("cref").Value); + } + + [Fact] + public void ParsesNodes() + { + var xml = @"Remarks referencing a ."; + var remarks = new DocsRemarks(XElement.Parse(xml)); + + var expected = new XNode[] + { + new XText("Remarks referencing a "), + XElement.Parse(@""), + new XText(".") + }; + + Assert.Equal(expected.Select(x => x.ToString()), remarks.ParsedNodes.ToArray()); + } + } +} diff --git a/Tests/DocsApi/DocsSummaryTests.cs b/Tests/DocsApi/DocsSummaryTests.cs new file mode 100644 index 0000000..0d76a14 --- /dev/null +++ b/Tests/DocsApi/DocsSummaryTests.cs @@ -0,0 +1,139 @@ +using System.Collections.Generic; +using System.Linq; +using System.Xml; +using System.Xml.Linq; +using Xunit; + +namespace Libraries.Docs.Tests +{ + public class DocsSummaryTests + { + [Theory] + [InlineData( + @"Summary.", + @"Summary.")] + [InlineData( + @"Summary referencing .", + @"Summary referencing .")] + [InlineData( + @" + Multiline + Summary + Referencing + . + ", + @" + Multiline + Summary + Referencing + . + ")] + public void GetsRawText(string xml, string expected) + { + var summary = new DocsSummary(XElement.Parse(xml)); + Assert.Equal(expected, summary.RawText); + } + + [Theory] + [InlineData( + @"Summary.", + @"Summary.")] + [InlineData( + @"Summary referencing .", + @"Summary referencing .")] + [InlineData( + @" + Multiline + + Summary + + Referencing + + + + With Blank Lines. + ", + @"Multiline +Summary +Referencing + +With Blank Lines.")] + public void GetsParsedText(string xml, string expected) + { + var summary = new DocsSummary(XElement.Parse(xml)); + Assert.Equal(expected, summary.ParsedText); + } + + [Fact] + public void AllowsInlineIncludesInParsedText() + { + var xml = @"Converts narrow (single-byte) characters in the string to wide (double-byte) characters. Applies to Asian locales. This member is equivalent to the Visual Basic constant . [!INCLUDE[vbstrconv-wide](~/includes/vbstrconv-wide-md.md)]"; + var expected = @"Converts narrow (single-byte) characters in the string to wide (double-byte) characters. Applies to Asian locales. This member is equivalent to the Visual Basic constant . [!INCLUDE[vbstrconv-wide](~/includes/vbstrconv-wide-md.md)]"; + + var summary = new DocsSummary(XElement.Parse(xml)); + Assert.Equal(expected, summary.ParsedText); + } + + [Fact] + public void GetsNodes() + { + var xml = @"Summary referencing a ."; + var summary = new DocsSummary(XElement.Parse(xml)); + + var expected = new XNode[] + { + new XText("Summary referencing a "), + XElement.Parse(@""), + new XText(".") + }; + + Assert.Equal(expected.Select(x => x.ToString()), summary.RawNodes.ToArray().Select(x => x.ToString())); + } + + [Fact] + public void CanIncludeSeeElements() + { + var xml = @""; + var summary = new DocsSummary(XElement.Parse(xml)); + var see = summary.RawNodes.Single(); + + Assert.Equal(XmlNodeType.Element, see.NodeType); + } + + [Fact] + public void CanExposeRawSeeElements() + { + var xml = @""; + var summary = new DocsSummary(XElement.Parse(xml)); + var see = summary.RawNodes.Single(); + + Assert.Equal("see", ((XElement)see).Name); + } + + [Fact] + public void CanExposeRawSeeCrefValues() + { + var xml = @""; + var summary = new DocsSummary(XElement.Parse(xml)); + var see = summary.RawNodes.Single(); + + Assert.Equal("T:System.Type", ((XElement)see).Attribute("cref").Value); + } + + [Fact] + public void ParsesNodes() + { + var xml = @"Summary referencing a ."; + var summary = new DocsSummary(XElement.Parse(xml)); + + var expected = new XNode[] + { + new XText("Summary referencing a "), + XElement.Parse(@""), + new XText(".") + }; + + Assert.Equal(expected.Select(x => x.ToString()), summary.ParsedNodes.ToArray().Select(x => x.ToString())); + } + } +} diff --git a/Tests/DocsApi/DocsTypeParamTests.cs b/Tests/DocsApi/DocsTypeParamTests.cs new file mode 100644 index 0000000..b5401cf --- /dev/null +++ b/Tests/DocsApi/DocsTypeParamTests.cs @@ -0,0 +1,35 @@ +using System.Xml.Linq; +using Xunit; + +namespace Libraries.Docs.Tests +{ + public class DocsTypeParamTests + { + [Theory] + [InlineData( + @"The type of the keys in the dictionary.", + @"TKey")] + public void ExtractsName(string xml, string expected) + { + var parent = new TestDocsApi(); + var typeParam = new DocsTypeParam(parent, XElement.Parse(xml)); + + Assert.Equal(expected, typeParam.Name); + } + + [Theory] + [InlineData( + @"The type of the keys in the dictionary.", + @"The type of the keys in the dictionary.")] + [InlineData( + @"The type of items in the .", + @"The type of items in the .")] + public void ExtractsValueAsPlainText(string xml, string expected) + { + var parent = new TestDocsApi(); + var typeParam = new DocsTypeParam(parent, XElement.Parse(xml)); + + Assert.Equal(expected, typeParam.Value); + } + } +} diff --git a/Tests/DocsApi/DocsTypeParameterTests.cs b/Tests/DocsApi/DocsTypeParameterTests.cs new file mode 100644 index 0000000..6b39b44 --- /dev/null +++ b/Tests/DocsApi/DocsTypeParameterTests.cs @@ -0,0 +1,80 @@ +using System.Collections.Generic; +using System.Xml.Linq; +using Xunit; + +namespace Libraries.Docs.Tests +{ + public class DocsTypeParameterTests + { + [Theory] + [InlineData( + @"", + @"T")] + [InlineData(@" + + + Covariant + + ", + @"T")] + public void ExtractsName(string xml, string expected) + { + var typeParameter = new DocsTypeParameter(XElement.Parse(xml)); + Assert.Equal(expected, typeParameter.Name); + } + + [Theory] + [InlineData( + @"", + new string[] { })] + [InlineData(@" + + + Covariant + + ", + new string[] { "Covariant" })] + [InlineData(@" + + + DefaultConstructorConstraint + NotNullableValueTypeConstraint + System.ValueType + + ", + new string[] { "DefaultConstructorConstraint", "NotNullableValueTypeConstraint" })] + public void ExtractsConstraintsParameterAttributesAsPlainText(string xml, IEnumerable expected) + { + var typeParameter = new DocsTypeParameter(XElement.Parse(xml)); + Assert.Equal(expected, typeParameter.ConstraintsParameterAttributes); + } + + [Theory] + [InlineData(@" + + + System.Data.DataRow + + ", + @"System.Data.DataRow")] + public void ExtractsConstraintsBaseTypeName(string xml, string expected) + { + var typeParameter = new DocsTypeParameter(XElement.Parse(xml)); + Assert.Equal(expected, typeParameter.ConstraintsBaseTypeName); + } + + [Theory] + [InlineData(@" + + + System.IComparable<T> + + ", + @"System.IComparable<T>")] + public void ExtractsConstraintsInterfaceName(string xml, string expected) + { + var typeParameter = new DocsTypeParameter(XElement.Parse(xml)); + Assert.Equal(expected, typeParameter.ConstraintsInterfaceName); + } + } +} diff --git a/Tests/DocsApi/DocsTypeSignatureTests.cs b/Tests/DocsApi/DocsTypeSignatureTests.cs new file mode 100644 index 0000000..265e7f3 --- /dev/null +++ b/Tests/DocsApi/DocsTypeSignatureTests.cs @@ -0,0 +1,34 @@ +using System.Xml.Linq; +using Xunit; + +namespace Libraries.Docs.Tests +{ + public class DocsTypeSignatureTests + { + [Theory] + [InlineData( + @"", + @"C#")] + [InlineData( + @"", + @"VB.NET")] + public void ExtractsLanguage(string xml, string expected) + { + var typeSignature = new DocsTypeSignature(XElement.Parse(xml)); + Assert.Equal(expected, typeSignature.Language); + } + + [Theory] + [InlineData( + @"", + @"public sealed class RequiredAttribute : Attribute")] + [InlineData( + @"", + "Public NotInheritable Class RequiredAttribute \nInherits Attribute")] + public void ExtractsValue(string xml, string expected) + { + var typeSignature = new DocsTypeSignature(XElement.Parse(xml)); + Assert.Equal(expected, typeSignature.Value); + } + } +} diff --git a/Tests/DocsApi/DocsTypeTests.cs b/Tests/DocsApi/DocsTypeTests.cs new file mode 100644 index 0000000..387f049 --- /dev/null +++ b/Tests/DocsApi/DocsTypeTests.cs @@ -0,0 +1,45 @@ +using System.Text; +using System.Xml.Linq; +using Xunit; + +namespace Libraries.Docs.Tests +{ + public class DocsTypeTests + { + [Theory] + [InlineData( // No remarks + @"", + @"")] + [InlineData( // Plain text + @"These are remarks", + @"These are remarks")] + [InlineData( // With < and > sequences + @"These are remarks with <xml> like content", + @"These are remarks with <xml> like content")] + [InlineData( // With markdown content + @" + + HTML embedded. + + ]]> + + ", + @"HTML embedded. + + ]]>" + )] + public void ExtractsRemarksAsPlainText(string xml, string expected) + { + var doc = XDocument.Parse(@$"{xml}"); + var type = new DocsType("MyType.xml", doc, doc.Root, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)); + + Assert.Equal(expected, type.Remarks); + } + } +} diff --git a/Tests/DocsApi/TestDocsApi.cs b/Tests/DocsApi/TestDocsApi.cs new file mode 100644 index 0000000..01d0ebe --- /dev/null +++ b/Tests/DocsApi/TestDocsApi.cs @@ -0,0 +1,18 @@ +using System.Xml.Linq; + +namespace Libraries.Docs.Tests +{ + class TestDocsApi : DocsAPI + { + public TestDocsApi() : base(XElement.Parse("")) { } + + public override bool Changed { get; set; } + + public override string DocId => "--DocId--"; + + public override string Summary { get; set; } + public override string Remarks { get; set; } + public override string ReturnType { get; } + public override string Returns { get; set; } + } +} diff --git a/Tests/PortToDocs/PortToDocsTests.cs b/Tests/PortToDocs/PortToDocsTests.cs index a780839..786a724 100644 --- a/Tests/PortToDocs/PortToDocsTests.cs +++ b/Tests/PortToDocs/PortToDocsTests.cs @@ -35,7 +35,7 @@ public void Port_AssemblyAndNamespaceDifferent() { PortToDocs("AssemblyAndNamespaceDifferent", GetConfiguration(), - namespaceNames: new[] { TestData.TestNamespace}); + namespaceNames: new[] { TestData.TestNamespace }); } [Fact] @@ -153,7 +153,7 @@ private static Configuration GetConfiguration( SkipInterfaceRemarks = skipInterfaceRemarks }; - private static void PortToDocs( + private static void PortToDocs( string testName, Configuration c, string[] assemblyNames = null, diff --git a/Tests/PortToTripleSlash/LeadingTriviaRewriterTests.cs b/Tests/PortToTripleSlash/LeadingTriviaRewriterTests.cs new file mode 100644 index 0000000..50fed69 --- /dev/null +++ b/Tests/PortToTripleSlash/LeadingTriviaRewriterTests.cs @@ -0,0 +1,195 @@ +#nullable enable +using Libraries.RoslynTripleSlash; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Xunit; + +namespace Libraries.Tests.PortToTripleSlash +{ + public class LeadingTriviaRewriterTests + { + public struct LeadingTriviaTestFile + { + public SyntaxNode MyType; + public SyntaxNode MyEnum; + public SyntaxNode MyField; + public SyntaxNode MyProperty; + public SyntaxNode MyMethod; + public SyntaxNode MyInterface; + } + + private static LeadingTriviaTestFile LoadTestFile(string fileName) + { + // We rely on the test data files being marked as Content + // and being copied to the output directory + string testFolder = "./PortToTripleSlash/TestData/LeadingTrivia"; + string testContent = File.ReadAllText(Path.Combine(testFolder, fileName)); + + IEnumerable nodes = SyntaxFactory.ParseSyntaxTree(testContent).GetRoot().DescendantNodes(); + + return new LeadingTriviaTestFile + { + MyType = nodes.First(n => n.IsKind(SyntaxKind.ClassDeclaration)), + MyEnum = nodes.First(n => n.IsKind(SyntaxKind.EnumDeclaration)), + MyField = nodes.First(n => n.IsKind(SyntaxKind.FieldDeclaration)), + MyProperty = nodes.First(n => n.IsKind(SyntaxKind.PropertyDeclaration)), + MyMethod = nodes.First(n => n.IsKind(SyntaxKind.MethodDeclaration)), + MyInterface = nodes.First(n => n.IsKind(SyntaxKind.InterfaceDeclaration)) + }; + } + + private static (LeadingTriviaTestFile Original, LeadingTriviaTestFile Expected) LoadTestFiles(string test) + { + LeadingTriviaTestFile original = LoadTestFile($"{test}.Original.cs"); + LeadingTriviaTestFile expected = LoadTestFile($"{test}.Expected.cs"); + + return (original, expected); + } + + public static IEnumerable GetLeadingTriviaTests() + { + yield return new object[] { "WhitespaceOnly", LoadTestFiles("WhitespaceOnly") }; + yield return new object[] { "Directives", LoadTestFiles("Directives") }; + yield return new object[] { "ExistingXml", LoadTestFiles("ExistingXml") }; + yield return new object[] { "DirectivesExistingXml", LoadTestFiles("DirectivesExistingXml") }; + } + + private static IEnumerable GetTestComments(string testName) + { + XmlTextSyntax summaryText = SyntaxFactory.XmlText(testName); + XmlElementSyntax summaryElement = SyntaxFactory.XmlSummaryElement(summaryText); + + XmlTextSyntax remarksText = SyntaxFactory.XmlText(testName); + XmlElementSyntax remarksElement = SyntaxFactory.XmlRemarksElement(remarksText); + + return new[] { summaryElement, remarksElement }; + } + + [Fact] + public void WithoutDocumentationComments_RemovesSingleLineDocumentationComments() + { + var trivia = SyntaxFactory.ParseSyntaxTree(@" + /// This is the summary + /// These are the remarks + // This is another comment + public int field; + ").GetRoot().GetLeadingTrivia(); + + var actual = LeadingTriviaRewriter.WithoutDocumentationComments(trivia).ToFullString(); + var expected = @" + // This is another comment + "; + + Assert.Equal(expected, actual); + } + + [Fact] + public void WithoutDocumentationComments_RemovesMultiLineDocumentationComments() + { + var trivia = SyntaxFactory.ParseSyntaxTree(@" + /** + * This is the summary + * These are the remarks + * */ + // This is another comment + public int field; + ").GetRoot().GetLeadingTrivia(); + + var actual = LeadingTriviaRewriter.WithoutDocumentationComments(trivia).ToFullString(); + var expected = @" + // This is another comment + "; + + Assert.Equal(expected, actual); + } + + [Theory] + [MemberData(nameof(GetLeadingTriviaTests))] + public void AddsXmlToClassDeclaration(string testName, (LeadingTriviaTestFile Original, LeadingTriviaTestFile Expected) test) + { + var actual = LeadingTriviaRewriter.ApplyXmlComments( + test.Original.MyType, + GetTestComments(testName) + ).GetLeadingTrivia().ToFullString(); + + var expected = test.Expected.MyType.GetLeadingTrivia().ToFullString(); + + Assert.Equal(expected, actual); + } + + [Theory] + [MemberData(nameof(GetLeadingTriviaTests))] + public void AddsXmlToEnumDeclaration(string testName, (LeadingTriviaTestFile Original, LeadingTriviaTestFile Expected) test) + { + var actual = LeadingTriviaRewriter.ApplyXmlComments( + test.Original.MyEnum, + GetTestComments(testName) + ).GetLeadingTrivia().ToFullString(); + + var expected = test.Expected.MyEnum.GetLeadingTrivia().ToFullString(); + + Assert.Equal(expected, actual); + } + + [Theory] + [MemberData(nameof(GetLeadingTriviaTests))] + public void AddsXmlToFieldDeclaration(string testName, (LeadingTriviaTestFile Original, LeadingTriviaTestFile Expected) test) + { + var actual = LeadingTriviaRewriter.ApplyXmlComments( + test.Original.MyField, + GetTestComments(testName) + ).GetLeadingTrivia().ToFullString(); + + var expected = test.Expected.MyField.GetLeadingTrivia().ToFullString(); + + Assert.Equal(expected, actual); + } + + [Theory] + [MemberData(nameof(GetLeadingTriviaTests))] + public void AddsXmlToPropertyDeclaration(string testName, (LeadingTriviaTestFile Original, LeadingTriviaTestFile Expected) test) + { + var actual = LeadingTriviaRewriter.ApplyXmlComments( + test.Original.MyProperty, + GetTestComments(testName) + ).GetLeadingTrivia().ToFullString(); + + var expected = test.Expected.MyProperty.GetLeadingTrivia().ToFullString(); + + Assert.Equal(expected, actual); + } + + [Theory] + [MemberData(nameof(GetLeadingTriviaTests))] + public void AddsXmlToMethodDeclaration(string testName, (LeadingTriviaTestFile Original, LeadingTriviaTestFile Expected) test) + { + var actual = LeadingTriviaRewriter.ApplyXmlComments( + test.Original.MyMethod, + GetTestComments(testName) + ).GetLeadingTrivia().ToFullString(); + + var expected = test.Expected.MyMethod.GetLeadingTrivia().ToFullString(); + + Assert.Equal(expected, actual); + } + + [Theory] + [MemberData(nameof(GetLeadingTriviaTests))] + public void AddsXmlToInterfaceDeclaration(string testName, (LeadingTriviaTestFile Original, LeadingTriviaTestFile Expected) test) + { + var actual = LeadingTriviaRewriter.ApplyXmlComments( + test.Original.MyInterface, + GetTestComments(testName) + ).GetLeadingTrivia().ToFullString(); + + var expected = test.Expected.MyInterface.GetLeadingTrivia().ToFullString(); + + Assert.Equal(expected, actual); + } + } +} \ No newline at end of file diff --git a/Tests/PortToTripleSlash/PortToTripleSlashTestData.cs b/Tests/PortToTripleSlash/PortToTripleSlashTestData.cs index 4fb8b22..3804eac 100644 --- a/Tests/PortToTripleSlash/PortToTripleSlashTestData.cs +++ b/Tests/PortToTripleSlash/PortToTripleSlashTestData.cs @@ -5,7 +5,6 @@ namespace Libraries.Tests internal class PortToTripleSlashTestData : TestData { private const string SourceOriginal = "SourceOriginal.cs"; - private const string SourceExpected = "SourceExpected.cs"; private const string ProjectDirName = "Project"; private string TestDataRootDirPath => @"../../../PortToTripleSlash/TestData"; @@ -40,10 +39,6 @@ internal PortToTripleSlashTestData( ActualFilePath = Path.Combine(ProjectDir.FullName, SourceOriginal); File.Copy(originCsOriginal, ActualFilePath); - string originCsExpected = Path.Combine(testDataPath, SourceExpected); - ExpectedFilePath = Path.Combine(tempDir.FullPath, SourceExpected); - File.Copy(originCsExpected, ExpectedFilePath); - string originCsproj = Path.Combine(testDataPath, $"{assemblyName}.csproj"); ProjectFilePath = Path.Combine(ProjectDir.FullName, $"{assemblyName}.csproj"); File.Copy(originCsproj, ProjectFilePath); diff --git a/Tests/PortToTripleSlash/PortToTripleSlashTests.cs b/Tests/PortToTripleSlash/PortToTripleSlashTests.cs index 4798818..e9d885b 100644 --- a/Tests/PortToTripleSlash/PortToTripleSlashTests.cs +++ b/Tests/PortToTripleSlash/PortToTripleSlashTests.cs @@ -1,27 +1,26 @@ -using Xunit; +using VerifyXunit; +using Xunit; using Xunit.Abstractions; namespace Libraries.Tests { + [UsesVerify] public class PortToTripleSlashTests : BasePortTests { - public PortToTripleSlashTests(ITestOutputHelper output) : base(output) + public PortToTripleSlashTests(ITestOutputHelper output) + : base(output) { } - [Fact] - public void Port_Basic() + [Theory] + [InlineData("Basic")] + [InlineData("Generics")] + public async Task PortToTripleSlash(string scenario) { - PortToTripleSlash("Basic"); + await TestScenario(scenario); } - [Fact] - public void Port_Generics() - { - PortToTripleSlash("Generics"); - } - - private static void PortToTripleSlash( + private static async Task TestScenario( string testDataDir, bool save = true, bool skipInterfaceImplementations = true, @@ -55,29 +54,9 @@ private static void PortToTripleSlash( ToTripleSlashPorter.Start(c); - Verify(testData); - } - - private static void Verify(PortToTripleSlashTestData testData) - { - string[] expectedLines = File.ReadAllLines(testData.ExpectedFilePath); - string[] actualLines = File.ReadAllLines(testData.ActualFilePath); - - for (int i = 0; i < expectedLines.Length; i++) - { - string expectedLine = expectedLines[i]; - string actualLine = actualLines[i]; - if (System.Diagnostics.Debugger.IsAttached) - { - if (expectedLine != actualLine) - { - System.Diagnostics.Debugger.Break(); - } - } - Assert.Equal(expectedLine, actualLine); - } - - Assert.Equal(expectedLines.Length, actualLines.Length); + await Verifier.VerifyFile(testData.ActualFilePath) + .UseDirectory($"./TestData/{testDataDir}") + .UseFileName("SourceExpected"); } } } diff --git a/Tests/PortToTripleSlash/TestData/Basic/MyDelegate.xml b/Tests/PortToTripleSlash/TestData/Basic/MyDelegate.xml index b65d763..5153140 100644 --- a/Tests/PortToTripleSlash/TestData/Basic/MyDelegate.xml +++ b/Tests/PortToTripleSlash/TestData/Basic/MyDelegate.xml @@ -18,7 +18,7 @@ These are the remarks. There is a code ex ## Examples -Here is some text in the examples section. There is an that should be converted to xml. +Here is some text in the examples section. There is an that should remain an xref element. The snippet links below should be inserted in markdown. @@ -26,7 +26,7 @@ The snippet links below should be inserted in markdown. [!code-vb[MyExample#2](~/samples/snippets/example.vb)] [!code-cpp[MyExample#3](~/samples/snippets/example.cpp)] -This text should be outside the cdata in xml: . +This text remain inside the cdata: . ]]> diff --git a/Tests/PortToTripleSlash/TestData/Basic/MyType.xml b/Tests/PortToTripleSlash/TestData/Basic/MyType.xml index 475a62a..da746c2 100644 --- a/Tests/PortToTripleSlash/TestData/Basic/MyType.xml +++ b/Tests/PortToTripleSlash/TestData/Basic/MyType.xml @@ -20,9 +20,9 @@ Multiple lines. > [!NOTE] > This note should prevent converting markdown to xml. It has a . -This text is not a note. It has a that should be xml and outside **the cdata**. +This text is not a note. It has a that should remain and still be **inside the cdata**. -Long xrefs one after the other: or should both be converted to crefs. +Long xrefs one after the other: or should also remain. ]]> @@ -122,7 +122,7 @@ Here is a random snippet, NOT preceded by the examples header. [!code-cpp[MyExample](~/samples/snippets/example.cpp)] -There is a hyperlink, which should still allow conversion from markdown to xml: [MyHyperlink](http://github.com/dotnet/runtime). +There is a hyperlink, which should remain as markdown: [MyHyperlink](http://github.com/dotnet/runtime). ]]> diff --git a/Tests/PortToTripleSlash/TestData/Basic/SourceExpected.cs b/Tests/PortToTripleSlash/TestData/Basic/SourceExpected.verified.cs similarity index 85% rename from Tests/PortToTripleSlash/TestData/Basic/SourceExpected.cs rename to Tests/PortToTripleSlash/TestData/Basic/SourceExpected.verified.cs index a93b747..a2d6bea 100644 --- a/Tests/PortToTripleSlash/TestData/Basic/SourceExpected.cs +++ b/Tests/PortToTripleSlash/TestData/Basic/SourceExpected.verified.cs @@ -18,20 +18,20 @@ public enum MyEnum } /// This is the MyType class summary. - /// These are the class remarks. - /// URL entities: #(),. + /// class remarks. + /// URL entities: %23%28%29%2C. /// Multiple lines. - /// [!NOTE] /// > This note should prevent converting markdown to xml. It has a . - /// ]]> - /// This text is not a note. It has a that should be xml and outside the cdata. - /// Long xrefs one after the other: or should both be converted to crefs. + /// This text is not a note. It has a that should remain and still be **inside the cdata**. + /// Long xrefs one after the other: or should also remain. + /// ]]> // Original MyType class comments with information for maintainers, must stay. public class MyType { + // Original MyType constructor double slash comments on top of triple slash, with information for maintainers, must stay. /// This is the MyType constructor summary. - // Original MyType constructor double slash comments on top of triple slash, with information for maintainers, must stay but after triple slash. // Original MyType constructor double slash comments on bottom of triple slash, with information for maintainers, must stay. public MyType() { @@ -86,12 +86,12 @@ public int MyProperty /// This is the MyIntMethod return value. It mentions the . /// This is the ArgumentNullException thrown by MyIntMethod. It mentions the . /// This is the IndexOutOfRangeException thrown by MyIntMethod. - /// These are the MyIntMethod remarks. + /// - /// There is a hyperlink, which should still allow conversion from markdown to xml: MyHyperlink. + /// There is a hyperlink, which should remain as markdown: [MyHyperlink](http://github.com/dotnet/runtime). + /// ]]> public int MyIntMethod(int param1, int param2) { // Internal comments should remain untouched. @@ -101,14 +101,17 @@ public int MyIntMethod(int param1, int param2) /// This is the MyVoidMethod summary. /// This is the ArgumentNullException thrown by MyVoidMethod. It mentions the . /// This is the IndexOutOfRangeException thrown by MyVoidMethod. + /// /// -or- + /// /// This is the second case. + /// /// Empty newlines should be respected. /// These are the MyVoidMethod remarks. /// Multiple lines. /// Mentions the . - /// Also mentions an overloaded method DocID: . - /// And also mentions an overloaded method DocID with displayProperty which should be ignored when porting: . + /// Also mentions an overloaded method DocID: . + /// And also mentions an overloaded method DocID with displayProperty which should be ignored when porting: . public void MyVoidMethod() { } @@ -141,14 +144,14 @@ public void MyTypeParamMethod(int param1) /// This is the sender parameter. /// This is the e parameter. /// These are the remarks. There is a code example, which should be moved to its own examples section: - /// Here is some text in the examples section. There is an that should be converted to xml. + /// that should remain an xref element. /// The snippet links below should be inserted in markdown. - /// - /// This text should be outside the cdata in xml: . + /// This text remain inside the cdata: . + /// ]]> /// /// /// The .NET Runtime repo. diff --git a/Tests/PortToTripleSlash/TestData/Basic/SourceOriginal.cs b/Tests/PortToTripleSlash/TestData/Basic/SourceOriginal.cs index 449065a..4fc77d8 100644 --- a/Tests/PortToTripleSlash/TestData/Basic/SourceOriginal.cs +++ b/Tests/PortToTripleSlash/TestData/Basic/SourceOriginal.cs @@ -13,7 +13,7 @@ public enum MyEnum // Original MyType class comments with information for maintainers, must stay. public class MyType { - // Original MyType constructor double slash comments on top of triple slash, with information for maintainers, must stay but after triple slash. + // Original MyType constructor double slash comments on top of triple slash, with information for maintainers, must stay. /// /// Original triple slash comments. They should be replaced. /// diff --git a/Tests/PortToTripleSlash/TestData/Generics/MyGenericType.xml b/Tests/PortToTripleSlash/TestData/Generics/MyGenericType.xml new file mode 100644 index 0000000..b354acf --- /dev/null +++ b/Tests/PortToTripleSlash/TestData/Generics/MyGenericType.xml @@ -0,0 +1,30 @@ + + + + MyAssembly + + + This is the MyGenericType static class summary. + + + + + + Projects each element into a new form. + The type of the elements of . + The type of the value returned by . + A sequence of values to invoke a transform function on. + A transform function to apply to each element. + + + + . + ]]> + + + + + + \ No newline at end of file diff --git a/Tests/PortToTripleSlash/TestData/Generics/MyGenericType`1.xml b/Tests/PortToTripleSlash/TestData/Generics/MyGenericType`1.xml index a858375..d2e23a2 100644 --- a/Tests/PortToTripleSlash/TestData/Generics/MyGenericType`1.xml +++ b/Tests/PortToTripleSlash/TestData/Generics/MyGenericType`1.xml @@ -9,7 +9,7 @@ . +The `MyGenericType` type contains the nested class . ]]> diff --git a/Tests/PortToTripleSlash/TestData/Generics/SourceExpected.cs b/Tests/PortToTripleSlash/TestData/Generics/SourceExpected.cs deleted file mode 100644 index ae85c0f..0000000 --- a/Tests/PortToTripleSlash/TestData/Generics/SourceExpected.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; - -namespace MyNamespace -{ - /// This is the MyGenericType{T} class summary. - /// Contains the nested class . - // Original MyGenericType class comments with information for maintainers, must stay. - public class MyGenericType - { - /// This is the MyGenericType{T}.Enumerator class summary. - // Original MyGenericType.Enumerator class comments with information for maintainers, must stay. - public class Enumerator { } - } -} diff --git a/Tests/PortToTripleSlash/TestData/Generics/SourceExpected.verified.cs b/Tests/PortToTripleSlash/TestData/Generics/SourceExpected.verified.cs new file mode 100644 index 0000000..1c27771 --- /dev/null +++ b/Tests/PortToTripleSlash/TestData/Generics/SourceExpected.verified.cs @@ -0,0 +1,27 @@ +using System; + +namespace MyNamespace +{ + /// This is the MyGenericType{T} class summary. + /// The type contains the nested class . + // Original MyGenericType class comments with information for maintainers, must stay. + public class MyGenericType + { + /// This is the MyGenericType{T}.Enumerator class summary. + // Original MyGenericType.Enumerator class comments with information for maintainers, must stay. + public class Enumerator { } + } + + /// This is the MyGenericType static class summary. + public static class MyGenericType + { + /// Projects each element into a new form. + /// The type of the elements of . + /// The type of the value returned by . + /// A sequence of values to invoke a transform function on. + /// A transform function to apply to each element. + /// Here's a reference to . + /// + public static MyGenericType Select(this MyGenericType source, Func selector) => null; + } +} diff --git a/Tests/PortToTripleSlash/TestData/Generics/SourceOriginal.cs b/Tests/PortToTripleSlash/TestData/Generics/SourceOriginal.cs index 3d91be3..6e8d502 100644 --- a/Tests/PortToTripleSlash/TestData/Generics/SourceOriginal.cs +++ b/Tests/PortToTripleSlash/TestData/Generics/SourceOriginal.cs @@ -8,4 +8,9 @@ public class MyGenericType // Original MyGenericType.Enumerator class comments with information for maintainers, must stay. public class Enumerator { } } + + public static class MyGenericType + { + public static MyGenericType Select(this MyGenericType source, Func selector) => null; + } } diff --git a/Tests/PortToTripleSlash/TestData/LeadingTrivia/Directives.Expected.cs b/Tests/PortToTripleSlash/TestData/LeadingTrivia/Directives.Expected.cs new file mode 100644 index 0000000..d15dd61 --- /dev/null +++ b/Tests/PortToTripleSlash/TestData/LeadingTrivia/Directives.Expected.cs @@ -0,0 +1,69 @@ +namespace LeadingTriviaTestData.Directives.Expected +{ +#if false + internal +#else + /// Directives + /// Directives + public +#endif + class MyType + { + #region MyEnum + +#if true + /// Directives + /// Directives + public +#else + internal +#endif + enum MyEnum + { + FirstValue = 1, + SecondValue, + ThirdValue, + } + + #endregion + +#pragma warning disable + /// Directives + /// Directives + // This comment should remain below the XML comments + public int MyField; +#pragma warning restore + +#nullable enable + /// Directives + /// Directives + public string MyProperty + { + get + { + return ""; + } + set + { + + } + } +#nullable restore + +#if true + /// Directives + /// Directives + public bool MyMethod() + { + return true; + } + + /// Directives + /// Directives + public interface MyInterface + { + bool IsInterface { get; } + } +#endif + } +} \ No newline at end of file diff --git a/Tests/PortToTripleSlash/TestData/LeadingTrivia/Directives.Original.cs b/Tests/PortToTripleSlash/TestData/LeadingTrivia/Directives.Original.cs new file mode 100644 index 0000000..43585d3 --- /dev/null +++ b/Tests/PortToTripleSlash/TestData/LeadingTrivia/Directives.Original.cs @@ -0,0 +1,57 @@ +namespace LeadingTriviaTestData.Directives.Original +{ +#if false + internal +#else + public +#endif + class MyType + { + #region MyEnum + +#if true + public +#else + internal +#endif + enum MyEnum + { + FirstValue = 1, + SecondValue, + ThirdValue, + } + + #endregion + +#pragma warning disable + // This comment should remain below the XML comments + public int MyField; +#pragma warning restore + +#nullable enable + public string MyProperty + { + get + { + return ""; + } + set + { + + } + } +#nullable restore + +#if true + public bool MyMethod() + { + return true; + } + + public interface MyInterface + { + bool IsInterface { get; } + } +#endif + } +} \ No newline at end of file diff --git a/Tests/PortToTripleSlash/TestData/LeadingTrivia/DirectivesExistingXml.Expected.cs b/Tests/PortToTripleSlash/TestData/LeadingTrivia/DirectivesExistingXml.Expected.cs new file mode 100644 index 0000000..e85d11d --- /dev/null +++ b/Tests/PortToTripleSlash/TestData/LeadingTrivia/DirectivesExistingXml.Expected.cs @@ -0,0 +1,69 @@ +namespace LeadingTriviaTestData.DirectivesExistingXml.Expected +{ + /// DirectivesExistingXml + /// DirectivesExistingXml +#if false + internal +#else + public +#endif + class MyType + { + #region MyEnum + +#if true + /// DirectivesExistingXml + /// DirectivesExistingXml + public +#else + internal +#endif + enum MyEnum + { + FirstValue = 1, + SecondValue, + ThirdValue, + } + + #endregion + +#pragma warning disable + // This comment should remain above the XML comments + /// DirectivesExistingXml + /// DirectivesExistingXml + public int MyField; +#pragma warning restore + +#nullable enable + /// DirectivesExistingXml + /// DirectivesExistingXml + public string MyProperty + { + get + { + return ""; + } + set + { + + } + } +#nullable restore + +#if true + /// DirectivesExistingXml + /// DirectivesExistingXml + public bool MyMethod() + { + return true; + } + + /// DirectivesExistingXml + /// DirectivesExistingXml + public interface MyInterface + { + bool IsInterface { get; } + } +#endif + } +} \ No newline at end of file diff --git a/Tests/PortToTripleSlash/TestData/LeadingTrivia/DirectivesExistingXml.Original.cs b/Tests/PortToTripleSlash/TestData/LeadingTrivia/DirectivesExistingXml.Original.cs new file mode 100644 index 0000000..4da7076 --- /dev/null +++ b/Tests/PortToTripleSlash/TestData/LeadingTrivia/DirectivesExistingXml.Original.cs @@ -0,0 +1,93 @@ +namespace LeadingTriviaTestData.DirectivesExistingXml.Original +{ + /// + /// This is the original summary + /// + /// + /// These are the existing remarks + /// +#if false + internal +#else + public +#endif + class MyType + { + #region MyEnum + +#if true + /// + /// This is the original summary + /// + /// + /// These are the existing remarks + /// + public +#else + internal +#endif + enum MyEnum + { + FirstValue = 1, + SecondValue, + ThirdValue, + } + + #endregion + +#pragma warning disable + // This comment should remain above the XML comments + /// + /// This is the original summary + /// + /// + /// These are the existing remarks + /// + public int MyField; +#pragma warning restore + +#nullable enable + /// + /// This is the original summary + /// + /// + /// These are the existing remarks + /// + public string MyProperty + { + get + { + return ""; + } + set + { + + } + } +#nullable restore + +#if true + /// + /// This is the original summary + /// + /// + /// These are the existing remarks + /// + public bool MyMethod() + { + return true; + } + + /// + /// This is the original summary + /// + /// + /// These are the existing remarks + /// + public interface MyInterface + { + bool IsInterface { get; } + } +#endif + } +} \ No newline at end of file diff --git a/Tests/PortToTripleSlash/TestData/LeadingTrivia/ExistingXml.Expected.cs b/Tests/PortToTripleSlash/TestData/LeadingTrivia/ExistingXml.Expected.cs new file mode 100644 index 0000000..0750260 --- /dev/null +++ b/Tests/PortToTripleSlash/TestData/LeadingTrivia/ExistingXml.Expected.cs @@ -0,0 +1,66 @@ +namespace LeadingTriviaTestData.ExistingXml.Expected +{ + // Single line comment to keep above the XML comments + /// ExistingXml + /// ExistingXml + /* Multi-line comment to keep + * below the XML comments */ + public class MyType + { + // Single line comment to keep above the XML comments + /// ExistingXml + /// ExistingXml + /* Multi-line comment to keep + * below the XML comments */ + public enum MyEnum + { + FirstValue = 1, + SecondValue, + ThirdValue, + } + + // Single line comment to keep above the XML comments + /// ExistingXml + /// ExistingXml + /* Multi-line comment to keep + * below the XML comments */ + public int MyField; + + // Single line comment to keep above the XML comments + /// ExistingXml + /// ExistingXml + /* Multi-line comment to keep + * below the XML comments */ + public string MyProperty + { + get + { + return ""; + } + set + { + + } + } + + // Single line comment to keep above the XML comments + /// ExistingXml + /// ExistingXml + /* Multi-line comment to keep + * below the XML comments */ + public bool MyMethod() + { + return true; + } + + // Single line comment to keep above the XML comments + /// ExistingXml + /// ExistingXml + /* Multi-line comment to keep + * below the XML comments */ + public interface MyInterface + { + bool IsInterface { get; } + } + } +} \ No newline at end of file diff --git a/Tests/PortToTripleSlash/TestData/LeadingTrivia/ExistingXml.Original.cs b/Tests/PortToTripleSlash/TestData/LeadingTrivia/ExistingXml.Original.cs new file mode 100644 index 0000000..1f4529c --- /dev/null +++ b/Tests/PortToTripleSlash/TestData/LeadingTrivia/ExistingXml.Original.cs @@ -0,0 +1,90 @@ +namespace LeadingTriviaTestData.ExistingXml.Original +{ + // Single line comment to keep above the XML comments + /// + /// This was the original summary + /// + /// + /// These were the original remarks + /// + /* Multi-line comment to keep + * below the XML comments */ + public class MyType + { + // Single line comment to keep above the XML comments + /// + /// This was the original summary + /// + /// + /// These were the original remarks + /// + /* Multi-line comment to keep + * below the XML comments */ + public enum MyEnum + { + FirstValue = 1, + SecondValue, + ThirdValue, + } + + // Single line comment to keep above the XML comments + /// + /// This was the original summary + /// + /// + /// These were the original remarks + /// + /* Multi-line comment to keep + * below the XML comments */ + public int MyField; + + // Single line comment to keep above the XML comments + /// + /// This was the original summary + /// + /// + /// These were the original remarks + /// + /* Multi-line comment to keep + * below the XML comments */ + public string MyProperty + { + get + { + return ""; + } + set + { + + } + } + + // Single line comment to keep above the XML comments + /// + /// This was the original summary + /// + /// + /// These were the original remarks + /// + /* Multi-line comment to keep + * below the XML comments */ + public bool MyMethod() + { + return true; + } + + // Single line comment to keep above the XML comments + /// + /// This was the original summary + /// + /// + /// These were the original remarks + /// + /* Multi-line comment to keep + * below the XML comments */ + public interface MyInterface + { + bool IsInterface { get; } + } + } +} \ No newline at end of file diff --git a/Tests/PortToTripleSlash/TestData/LeadingTrivia/WhitespaceOnly.Expected.cs b/Tests/PortToTripleSlash/TestData/LeadingTrivia/WhitespaceOnly.Expected.cs new file mode 100644 index 0000000..6b8cc45 --- /dev/null +++ b/Tests/PortToTripleSlash/TestData/LeadingTrivia/WhitespaceOnly.Expected.cs @@ -0,0 +1,48 @@ +namespace LeadingTriviaTestData.WhitespaceOnly.Expected +{ + /// WhitespaceOnly + /// WhitespaceOnly + public class MyType + { + /// WhitespaceOnly + /// WhitespaceOnly + public enum MyEnum + { + FirstValue = 1, + SecondValue, + ThirdValue, + } + + /// WhitespaceOnly + /// WhitespaceOnly + public int MyField; + + /// WhitespaceOnly + /// WhitespaceOnly + public string MyProperty + { + get + { + return ""; + } + set + { + + } + } + + /// WhitespaceOnly + /// WhitespaceOnly + public bool MyMethod() + { + return true; + } + + /// WhitespaceOnly + /// WhitespaceOnly + public interface MyInterface + { + bool IsInterface { get; } + } + } +} \ No newline at end of file diff --git a/Tests/PortToTripleSlash/TestData/LeadingTrivia/WhitespaceOnly.Original.cs b/Tests/PortToTripleSlash/TestData/LeadingTrivia/WhitespaceOnly.Original.cs new file mode 100644 index 0000000..34610f4 --- /dev/null +++ b/Tests/PortToTripleSlash/TestData/LeadingTrivia/WhitespaceOnly.Original.cs @@ -0,0 +1,36 @@ +namespace LeadingTriviaTestData.WhitespaceOnly.Original +{ + public class MyType + { + public enum MyEnum + { + FirstValue = 1, + SecondValue, + ThirdValue, + } + + public int MyField; + + public string MyProperty + { + get + { + return ""; + } + set + { + + } + } + + public bool MyMethod() + { + return true; + } + + public interface MyInterface + { + bool IsInterface { get; } + } + } +} \ No newline at end of file diff --git a/Tests/TestData.cs b/Tests/TestData.cs index ca56dd5..f579c85 100644 --- a/Tests/TestData.cs +++ b/Tests/TestData.cs @@ -9,7 +9,6 @@ internal class TestData internal const string TestType = "MyType"; internal const string DocsDirName = "Docs"; - internal string ExpectedFilePath { get; set; } internal string ActualFilePath { get; set; } internal DirectoryInfo DocsDir { get; set; } diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index ed8c457..ae11375 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -5,41 +5,40 @@ Microsoft carlossanlop false + enable - - - - + + + + - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + + PreserveNewest + + + PreserveNewest + - + + + + + + + + + + - - - - - - +