Skip to content

Commit

Permalink
fix: xml comment whitespace handling (#8607)
Browse files Browse the repository at this point in the history
  • Loading branch information
yufeih authored Apr 9, 2023
1 parent 0c42539 commit 378feab
Show file tree
Hide file tree
Showing 53 changed files with 857 additions and 465 deletions.
22 changes: 22 additions & 0 deletions samples/common/Example.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
public class BaseSocketClient
{
#region MessageDeleted

public void HookMessageDeleted(BaseSocketClient client)
{
client.MessageDeleted += HandleMessageDelete;
}

public Task HandleMessageDelete(Cacheable<IMessage, ulong> cachedMessage, ISocketMessageChannel channel)
{
// check if the message exists in cache; if not, we cannot report what was removed
if (!cachedMessage.HasValue) return;
var message = cachedMessage.Value;
Console.WriteLine($"A message ({message.Id}) from {message.Author} was removed from the channel {channel.Name} ({channel.Id}):"
+ Environment.NewLine
+ message.Content);
return Task.CompletedTask;
}

#endregion
}
39 changes: 37 additions & 2 deletions samples/seed/dotnet/project/Project/Class1.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

public class Class1
{
public class Test<T> { }

/// <summary>
/// This method should do something...
/// </summary>
Expand Down Expand Up @@ -38,5 +40,38 @@ public void Issue1651() { }
/// </remarks>
public void Issue7484() { }

public class Test<T> { }
}
/// <remarks>
/// <code>
/// void Update()
/// {
/// myClass.Execute();
/// }
/// </code>
/// </remarks>
/// <example>
/// <code source="../../../../common/Example.cs" region="MessageDeleted"></code>
/// </example>
public void Issue4017() { }

/// <remarks>
/// For example:
///
/// MyClass myClass = new MyClass();
///
/// void Update()
/// {
/// myClass.Execute();
/// }
/// </remarks>
/// <example>
/// ```csharp
/// MyClass myClass = new MyClass();
///
/// void Update()
/// {
/// myClass.Execute();
/// }
/// ```
/// </example>
public void Issue2623() { }
}
194 changes: 73 additions & 121 deletions src/Microsoft.DocAsCode.Dotnet/Parsers/XmlComment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@ internal class XmlComment
{
private const string idSelector = @"((?![0-9])[\w_])+[\w\(\)\.\{\}\[\]\|\*\^~#@!`,_<>:]*";
private static readonly Regex CommentIdRegex = new(@"^(?<type>N|T|M|P|F|E|Overload):(?<id>" + idSelector + ")$", RegexOptions.Compiled);
private static readonly Regex LineBreakRegex = new(@"\r?\n", RegexOptions.Compiled);
private static readonly Regex CodeElementRegex = new(@"<code[^>]*>([\s\S]*?)</code>", RegexOptions.Compiled);
private static readonly Regex RegionRegex = new(@"^\s*#region\s*(.*)$");
private static readonly Regex XmlRegionRegex = new(@"^\s*<!--\s*<([^/\s].*)>\s*-->$");
private static readonly Regex EndRegionRegex = new(@"^\s*#endregion\s*.*$");
Expand Down Expand Up @@ -75,7 +73,8 @@ private XmlComment(string xml, XmlCommentParserContext context)
ResolveCrefLink(doc, "//see[@cref]", context.AddReferenceDelegate);
ResolveCrefLink(doc, "//exception[@cref]", context.AddReferenceDelegate);

ResolveCodeSource(doc, context);
ResolveCode(doc, context);

var nav = doc.CreateNavigator();
Summary = GetSingleNodeValue(nav, "/member/summary");
Remarks = GetSingleNodeValue(nav, "/member/remarks");
Expand Down Expand Up @@ -126,58 +125,47 @@ public string GetTypeParameter(string name)
return TypeParameters.TryGetValue(name, out var value) ? value : null;
}

private void ResolveCodeSource(XDocument doc, XmlCommentParserContext context)
private void ResolveCode(XDocument doc, XmlCommentParserContext context)
{
foreach (XElement node in doc.XPathSelectElements("//code"))
foreach (var node in doc.XPathSelectElements("//code").ToList())
{
var source = node.Attribute("source");
if (source == null || string.IsNullOrEmpty(source.Value))
if (node.Attribute("data-inline") is { } inlineAttribute)
{
inlineAttribute.Remove();
continue;
}

var region = node.Attribute("region");

var path = source.Value;
if (!Path.IsPathRooted(path))
{
string basePath;

if (!string.IsNullOrEmpty(context.CodeSourceBasePath))
{
basePath = context.CodeSourceBasePath;
}
else
{
if (context.Source == null || string.IsNullOrEmpty(context.Source.Path))
{
Logger.LogWarning($"Unable to get source file path for {node.ToString()}");
continue;
}

basePath = Path.GetDirectoryName(Path.Combine(EnvironmentContext.BaseDirectory, context.Source.Path));
}

path = Path.Combine(basePath, path);
}

ResolveCodeSource(node, path, region?.Value);
var indent = ((IXmlLineInfo)node).LinePosition - 2;
var (lang, value) = ResolveCodeSource(node, context);
value = TrimEachLine(value ?? node.Value, new(' ', indent));
var code = new XElement("code", value);
code.SetAttributeValue("class", $"lang-{lang ?? "csharp"}");
node.ReplaceWith(new XElement("pre", code));
}
}

private void ResolveCodeSource(XElement element, string source, string region)
private (string lang, string code) ResolveCodeSource(XElement node, XmlCommentParserContext context)
{
if (!File.Exists(source))
{
Logger.LogWarning($"Source file '{source}' not found.");
return;
}
var source = node.Attribute("source")?.Value;
if (string.IsNullOrEmpty(source))
return default;

var lang = Path.GetExtension(source).TrimStart('.').ToLowerInvariant();

var code = context.ResolveCode?.Invoke(source);
if (code is null)
return (lang, null);

var region = node.Attribute("region")?.Value;
if (region is null)
return (lang, code);

var (regionRegex, endRegionRegex) = GetRegionRegex(source);

var builder = new StringBuilder();
var regionCount = 0;
foreach (var line in File.ReadLines(source))

foreach (var line in ReadLines(code))
{
if (!string.IsNullOrEmpty(region))
{
Expand Down Expand Up @@ -215,7 +203,17 @@ private void ResolveCodeSource(XElement element, string source, string region)
}
}

element.SetValue(builder.ToString());
return (lang, builder.ToString());
}

private static IEnumerable<string> ReadLines(string text)
{
string line;
using var sr = new StringReader(text);
while ((line = sr.ReadLine()) != null)
{
yield return line;
}
}

private Dictionary<string, string> GetListContent(XPathNavigator navigator, string xpath, string contentType, XmlCommentParserContext context)
Expand Down Expand Up @@ -276,7 +274,9 @@ private void ResolveLangword(XNode node)
}
else
{
item.ReplaceWith(new XElement("c", langword));
var code = new XElement("code", langword);
code.SetAttributeValue("data-inline", "true");
item.ReplaceWith(code);
}
}
}
Expand Down Expand Up @@ -484,103 +484,55 @@ private string GetXmlValue(XPathNavigator node)
if (node is null)
return null;

// NOTE: use node.InnerXml instead of node.Value, to keep decorative nodes,
// e.g.
// <remarks><para>Value</para></remarks>
// decode InnerXml as it encodes
// IXmlLineInfo.LinePosition starts from 1 and it would ignore '<'
// e.g.
// <summary/> the LinePosition is the column number of 's', so it should be minus 2
var lineInfo = node as IXmlLineInfo;
int column = lineInfo.HasLineInfo() ? lineInfo.LinePosition - 2 : 0;

return NormalizeXml(RemoveLeadingSpaces(GetInnerXml(node)), column);
return TrimEachLine(GetInnerXml(node));
}

/// <summary>
/// Remove least common whitespces in each line of xml
/// </summary>
/// <param name="xml"></param>
/// <returns>xml after removing least common whitespaces</returns>
private static string RemoveLeadingSpaces(string xml)
private static string TrimEachLine(string text, string indent = "")
{
var lines = LineBreakRegex.Split(xml);
var normalized = new List<string>();
var minLeadingWhitespace = int.MaxValue;
var lines = ReadLines(text).ToList();
foreach (var line in lines)
{
if (string.IsNullOrWhiteSpace(line))
continue;

var preIndex = 0;
var leadingSpaces = from line in lines
where !string.IsNullOrWhiteSpace(line)
select line.TakeWhile(char.IsWhiteSpace).Count();
var leadingWhitespace = 0;
while (leadingWhitespace < line.Length && char.IsWhiteSpace(line[leadingWhitespace]))
leadingWhitespace++;

if (leadingSpaces.Any())
{
preIndex = leadingSpaces.Min();
minLeadingWhitespace = Math.Min(minLeadingWhitespace, leadingWhitespace);
}

if (preIndex == 0)
{
return xml;
}
var builder = new StringBuilder();

foreach (var line in lines)
{
if (string.IsNullOrWhiteSpace(line))
{
normalized.Add(string.Empty);
}
else
{
normalized.Add(line.Substring(preIndex));
}
}
return string.Join("\n", normalized);
}
// Trim leading empty lines
var trimStart = true;

/// <summary>
/// Split xml into lines. Trim meaningless whitespaces.
/// if a line starts with xml node, all leading whitespaces would be trimmed
/// otherwise text node start position always aligns with the start position of its parent line(the last previous line that starts with xml node)
/// Trim newline character for code element.
/// </summary>
/// <param name="xml"></param>
/// <param name="parentIndex">the start position of the last previous line that starts with xml node</param>
/// <returns>normalized xml</returns>
private static string NormalizeXml(string xml, int parentIndex)
{
var lines = LineBreakRegex.Split(xml);
var normalized = new List<string>();
// Apply indentation to all lines except the first,
// since the first new line in <pre></code> is significant
var firstLine = true;

foreach (var line in lines)
{
if (trimStart && string.IsNullOrWhiteSpace(line))
continue;

if (firstLine)
firstLine = false;
else
builder.Append(indent);

if (string.IsNullOrWhiteSpace(line))
{
normalized.Add(string.Empty);
builder.AppendLine();
continue;
}
else
{
// TO-DO: special logic for TAB case
int index = line.TakeWhile(char.IsWhiteSpace).Count();
if (line[index] == '<')
{
parentIndex = index;
}

normalized.Add(line.Substring(Math.Min(parentIndex, index)));
}
trimStart = false;
builder.AppendLine(line.Substring(minLeadingWhitespace));
}

// trim newline character for code element
return CodeElementRegex.Replace(
string.Join("\n", normalized),
m =>
{
var group = m.Groups[1];
if (group.Length == 0)
{
return m.Value;
}
return m.Value.Replace(group.ToString(), group.ToString().Trim('\n'));
});
return builder.ToString().TrimEnd();
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ namespace Microsoft.DocAsCode.Dotnet;

internal class XmlCommentParserContext
{
public Action<string, string> AddReferenceDelegate { get; set; }
public Action<string, string> AddReferenceDelegate { get; init; }

public SourceDetail Source { get; set; }
public Func<string, string> ResolveCode { get; init; }

public string CodeSourceBasePath { get; set; }
public SourceDetail Source { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,7 @@ public static XDocument Transform(string xml)
using var ms = new MemoryStream();
using var writer = new XHtmlWriter(new StreamWriter(ms));
XDocument doc = XDocument.Parse(xml, LoadOptions.PreserveWhitespace);
var args = new XsltArgumentList();
args.AddParam("language", "urn:input-variables", "csharp");
_transform.Transform(doc.CreateNavigator(), args, writer);
_transform.Transform(doc.CreateNavigator(), writer);
ms.Seek(0, SeekOrigin.Begin);
return XDocument.Load(ms, LoadOptions.PreserveWhitespace | LoadOptions.SetLineInfo);
}
Expand Down
Loading

0 comments on commit 378feab

Please sign in to comment.