Editor/HotCodeEditor/Navigation/DocComment.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml;
using System.Xml.Linq;

/// <summary>
/// The readable parts of an XML doc comment (<c>/// &lt;summary&gt;</c> etc).
/// </summary>
public record DocComment( string Summary, IReadOnlyDictionary<string, string> Parameters, string Returns )
{
	public static readonly DocComment Empty = new( "", new Dictionary<string, string>(), "" );

	/// <summary>
	/// Parses what Roslyn's GetDocumentationCommentXml returns. Never throws; bad XML gives <see cref="Empty"/>.
	/// </summary>
	public static DocComment Parse( string xml )
	{
		if ( string.IsNullOrWhiteSpace( xml ) ) return Empty;

		XElement root;
		try
		{
			// Source comments come wrapped in <member>; metadata ones sometimes aren't wrapped at all
			root = XElement.Parse( $"<root>{xml}</root>", LoadOptions.PreserveWhitespace );
		}
		catch ( XmlException )
		{
			return Empty;
		}

		var summary = root.Descendants( "summary" ).FirstOrDefault();
		var returns = root.Descendants( "returns" ).FirstOrDefault();

		var parameters = new Dictionary<string, string>();
		foreach ( var param in root.Descendants( "param" ) )
		{
			var name = (string)param.Attribute( "name" );
			if ( !string.IsNullOrEmpty( name ) ) parameters[name] = TextOf( param );
		}

		return new DocComment( TextOf( summary ), parameters, TextOf( returns ) );
	}

	/// <summary>
	/// Element text with &lt;see cref="T:Foo.Bar"/&gt; shown as "Bar", &lt;paramref name="x"/&gt; as "x",
	/// and whitespace collapsed (doc comments are full of indentation).
	/// </summary>
	private static string TextOf( XElement element )
	{
		if ( element is null ) return "";

		var sb = new StringBuilder();
		Append( element, sb );
		return Regex.Replace( sb.ToString(), @"\s+", " " ).Trim();
	}

	private static void Append( XElement element, StringBuilder sb )
	{
		foreach ( var node in element.Nodes() )
		{
			switch ( node )
			{
				case XText text:
					sb.Append( text.Value );
					break;

				case XElement child when child.Name == "see" || child.Name == "seealso":
					var target = (string)child.Attribute( "cref" ) ?? (string)child.Attribute( "langword" ) ?? (string)child.Attribute( "href" ) ?? "";
					sb.Append( child.IsEmpty ? ShortName( target ) : child.Value );
					break;

				case XElement child when child.Name == "paramref" || child.Name == "typeparamref":
					sb.Append( (string)child.Attribute( "name" ) );
					break;

				case XElement child when child.Name == "para" || child.Name == "br":
					sb.Append( ' ' );
					Append( child, sb );
					sb.Append( ' ' );
					break;

				case XElement child:
					Append( child, sb );
					break;
			}
		}
	}

	/// <summary>
	/// "M:Sandbox.GameObject.Destroy" -> "Destroy", "T:System.String" -> "String".
	/// </summary>
	private static string ShortName( string cref )
	{
		if ( cref.Length > 2 && cref[1] == ':' ) cref = cref[2..];
		var paren = cref.IndexOf( '(' );
		if ( paren >= 0 ) cref = cref[..paren];
		var dot = cref.LastIndexOf( '.' );
		return dot >= 0 ? cref[(dot + 1)..] : cref;
	}
}