//#define DEBUG_XML_LOAD_EXCEPTIONS //#define DEBUG_XML_LOAD_SUCCESS //#define DEBUG_XML_LOAD_FAILED //#define DEBUG_XML_LOAD_STEPS //#define DEBUG_PARSE_COMMENT #if UNITY_EDITOR using System; using System.Collections.Generic; using System.IO; using System.Reflection; using System.Text; using System.Xml; using JetBrains.Annotations; using UnityEditor; using UnityEngine; namespace Phantom.XRMOD.UnityFusion.Editor { /// /// Class that handles fetching summary tooltips for components /// from the XML documentation files of assembly DLL files. /// internal static class DLLSummaryParser { private static readonly Dictionary cachedXMLDocuments = new(); private static readonly StringBuilder stringBuilder = new(128); /// /// Attempts to find XML documentation file for dll that defines the given class type and parse XML documentation comments /// for the class. /// /// Type of the class whose members' tooltips we want. This cannot be null. /// [out] The summary for the class. If no XML documentation is found, this will be set to an empty string. /// True if tooltip was found. public static bool TryParseSummary([NotNull] Type classType, [CanBeNull] out string summary) { var assembly = classType.Assembly; XmlDocument xmlDocumentation; if(!cachedXMLDocuments.TryGetValue(assembly, out xmlDocumentation)) { xmlDocumentation = GetXMLDocument(assembly); cachedXMLDocuments[assembly] = xmlDocumentation; } if(xmlDocumentation is null) { summary = ""; return false; } var doc = xmlDocumentation["doc"]; if(doc is null) { #if DEV_MODE Debug.LogWarning("XML Documentation for assembly "+classType.Assembly.GetName().Name+" had no \"doc\" section"); #endif summary = ""; return false; } var members = doc["members"]; if(members is null) { #if DEV_MODE Debug.LogWarning("XML Documentation for assembly "+classType.Assembly.GetName().Name+" had no \"members\" section under \"doc\""); #endif summary = ""; return false; } // For example: // T:UnityEngine.BoxCollider (class type) // T:Sisus.OdinSerializer.QueueFormatter`2 (class with two generic types) // F:UnityEngine.Camera.onPreCull (field) // P:UnityEngine.BoxCollider.size (property) // M:UnityEngine.AI.NavMeshAgent.CompleteOffMeshLink (method with no parameters) // M:UnityEngine.Collider.ClosestPoint(UnityEngine.Vector3) (method with parameters) // M:Namespace.ClassName`1.MethodName (method inside generic class) // M:Namespace.ClassName`1.MethodName(`0) (method inside generic class with parameter of class generic type) // M:Namespace.ClassName.#ctor (constructor with no parameters) // M:Namespace.ClassName.MethodName``1(``0[]) (method with generic parameter) // M:Namespace.ClassName.MethodName``2(System.Collections.Generic.Dictionary{``0,``1}) (method with two generic parameters) string match = "T:" + classType.FullName; foreach(object member in members) { // Skip non-XmlElement members to avoid exceptions if(!(member is XmlElement xmlElement)) { continue; } // skip members without attributes to avoid exceptions if(!xmlElement.HasAttributes) { continue; } XmlAttributeCollection attributes = xmlElement.Attributes; XmlAttribute nameAttribute = attributes["name"]; if(nameAttribute is null) { continue; } string typePrefixAndFullName = nameAttribute.InnerText; if(string.IsNullOrEmpty(typePrefixAndFullName)) { continue; } if(typePrefixAndFullName.IndexOf(match, StringComparison.Ordinal) == -1) { continue; } ParseXmlComment(xmlElement, stringBuilder); summary = stringBuilder.ToString(); stringBuilder.Clear(); return summary.Length > 0; } summary = ""; return false; } /// Tries to find XML Documentation file for given assembly. /// The assembly whose documentation file we want. This cannot be null. /// XmlDocument containing the documentation for the assembly. Null if no documentation was found. [CanBeNull] private static XmlDocument GetXMLDocument([NotNull]Assembly assembly) { string xmlFilePath = GetXMLDocumentationFilepath(assembly); if(xmlFilePath.Length == 0) { return null; } using(var streamReader = new StreamReader(xmlFilePath)) { var xmlDocument = new XmlDocument(); try { xmlDocument.Load(streamReader); return xmlDocument; } #if DEV_MODE && DEBUG_XML_LOAD_FAILED catch(Exception e) { Debug.LogWarning(e); #else catch(Exception) { #endif return null; } } } [NotNull] private static string GetXMLDocumentationFilepath(Assembly assembly) { string dllPathWithFilePrefix = assembly.CodeBase; // convert from explicit to implicit filepath string dllPath = new Uri(dllPathWithFilePrefix).LocalPath; string directory = Path.GetDirectoryName(dllPath); string xmlFileName = Path.GetFileNameWithoutExtension(dllPath) + ".xml"; string xmlFilePath = Path.Combine(directory, xmlFileName); if(File.Exists(xmlFilePath)) { #if DEV_MODE && DEBUG_XML_LOAD_SUCCESS Debug.Log("XML Found: "+ xmlFilePath); #endif return xmlFilePath; } #if DEV_MODE && DEBUG_XML_LOAD_FAILED Debug.LogWarning("XML documentation not found @ "+ xmlFilePath, null); #endif return ""; } public static void ParseXmlComment(string xmlComment, StringBuilder sb) { xmlComment = xmlComment.Trim(); int charCount = xmlComment.Length; if(charCount == 0) { return; } if(xmlComment[0] != '<') { #if DEV_MODE && DEBUG_PARSE_COMMENT Debug.Log("Returning whole xmlComment because first letter was not '<'\n\ninput:\n" + xmlComment); #endif sb.Append(xmlComment); return; } XmlDocument doc; if(TryLoadXmlComment(xmlComment, out doc)) { #if DEV_MODE && DEBUG_PARSE_COMMENT Debug.Log("TryLoadXmlComment success:\n" + xmlComment); #endif ParseXmlComment(doc.DocumentElement, sb); return; } #if DEV_MODE && DEBUG_PARSE_COMMENT Debug.Log("TryLoadXmlComment failed:\n" + xmlComment); #endif int openStart = 0; do { int openEnd = xmlComment.IndexOf('>', openStart + 1); if(openEnd == -1) { #if DEV_MODE && DEBUG_PARSE_COMMENT Debug.Log("Returning whole xmlComment because could not find '>'\n\ninput:\n" + xmlComment); #endif sb.Append(xmlComment); return; } int tagEnd = xmlComment.IndexOf(' ', openStart + 1); int from = openStart + 1; int fullNameLength = openEnd - from; int tagNameLength; if(tagEnd != -1 && tagEnd < openEnd) { tagNameLength = tagEnd - from; } else { tagNameLength = fullNameLength; } // tag name is part between last "<" and the following " " (if found before the next ">" string tagName = xmlComment.Substring(from, tagNameLength); string name; string body; // handle element without body like e.g. if(xmlComment[openEnd - 1] == '/') { #if DEV_MODE && DEBUG_PARSE_COMMENT Debug.Log("Skipping to next '<' because element \""+xmlComment.Substring(openStart, openEnd - openStart + 1) + "\" had no body\n\ninput:\n" + xmlComment); #endif body = ""; fullNameLength--; name = xmlComment.Substring(from, fullNameLength); openStart = xmlComment.IndexOf('<', openEnd + 1); } else { name = xmlComment.Substring(from, fullNameLength); string closeTag = ""; int closeStart = xmlComment.IndexOf(closeTag, openEnd + 1, StringComparison.Ordinal); if(closeStart == -1) { #if DEV_MODE Debug.LogWarning("ParseXmlComment: failed to find closing tag for "+tagName+" so returning whole xmlComment"); #endif sb.Append(xmlComment); return; } from = openEnd + 1; body = TrimAllLines(xmlComment.Substring(from, closeStart - from)); openStart = xmlComment.IndexOf('<', closeStart + closeTag.Length); } switch(tagName) { case "summary": name = ""; break; case "param": if(name.StartsWith("param name=\"", StringComparison.OrdinalIgnoreCase)) { name = name.Substring(12, name.Length - 13); } break; case "typeparam": if(name.StartsWith("typeparam name=\"", StringComparison.OrdinalIgnoreCase)) { name = name.Substring(16, name.Length - 17); } break; case "exception": if(name.StartsWith("exception cref=\"", StringComparison.OrdinalIgnoreCase)) { name = name.Substring(16, name.Length - 17); } break; } AddTooltipLine(name, body, sb); } while(openStart != -1); } public static bool TryLoadXmlComment(string xmlComment, out XmlDocument doc) { doc = new XmlDocument(); try { doc.LoadXml(xmlComment); return true; } #if DEV_MODE && DEBUG_XML_LOAD_FAILED catch(XmlException e) { Debug.LogWarning(e); return false; } #else catch(XmlException) { return false; } #endif } public static void ParseXmlComment(XmlElement xmlElement, StringBuilder sb) { if(!xmlElement.HasAttributes) { #if DEV_MODE && DEBUG_PARSE_COMMENT Debug.Log("C \"" + xmlElement.Name + "\" (Loc=" + xmlElement.LocalName + ", Pre=" + xmlElement.Prefix + ", Val=" + xmlElement.Value + ")\nInnerXml:\n" + xmlElement.InnerXml.Replace("><", ">\r\n<") + "\n\nInnerText:\n"+ xmlElement.InnerText+"\n\nOuterXml:"+xmlElement.OuterXml.Replace("><", ">\r\n<")); #endif AddTooltipLine(xmlElement.InnerText, sb); return; } #if DEV_MODE && DEBUG_PARSE_COMMENT Debug.Log("P("+ xmlElement.Attributes.Count + ") \"" + xmlElement.Name + "\" (Loc=" + xmlElement.LocalName+ ", Pre="+ xmlElement.Prefix+ ", Val="+ xmlElement.Value+")\nInnerXml:\n" + xmlElement.InnerXml.Replace("><",">\r\n<")+ "\n\nInnerText:\n"+ xmlElement.InnerText + "\n\nOuterXml:" + xmlElement.OuterXml.Replace("><", ">\r\n<")+ "\n\nxmlElement[\"name\"]=" + (xmlElement["name"] == null ? "null" : xmlElement["name"].Name)); #endif switch(xmlElement.Name) { case "param": case "typeparam": case "exception": var name = xmlElement["name"]; if(name != null) { #if DEV_MODE Debug.Log(xmlElement.Name+"[\"name\"]: \""+name.InnerText+"\""); #endif AddTooltipLine(name.InnerText, xmlElement.InnerText, sb); return; } var outerXml = xmlElement.OuterXml; string beforeName = "<" + xmlElement.Name + " name=\""; if(outerXml.StartsWith(beforeName, StringComparison.OrdinalIgnoreCase)) { int nameStart = beforeName.Length; int nameEnd = outerXml.IndexOf("\">", nameStart, StringComparison.Ordinal); if(nameEnd != -1) { string parsedName = ObjectNames.NicifyVariableName(outerXml.Substring(nameStart, nameEnd - nameStart)); #if DEV_MODE && DEBUG_PARSE_COMMENT Debug.Log(xmlElement.Name + " name parsed: \""+parsedName + "\"\nouterXml:\n\n" + outerXml); #endif AddTooltipLine(parsedName, xmlElement.InnerText, sb); return; } } #if DEV_MODE Debug.LogWarning(xmlElement.Name+": failed to get name...\nouterXml:\n\n" + outerXml); #endif AddTooltipLine(xmlElement.InnerText, sb); return; } foreach(var item in xmlElement) { var element = item as XmlElement; if(element == null) { #if DEV_MODE Debug.LogWarning("item "+item.GetType()+" of NodeType "+((XmlNode)item).NodeType+" was not of type XmlElement"); #endif continue; } ParseXmlComment(element, sb); } } private static void AddTooltipLine(string line, StringBuilder sb) { if(sb.Length > 0) { sb.Append('\n'); } sb.Append(line.Trim()); } private static void AddTooltipLine(string name, string body, StringBuilder sb) { if(sb.Length > 0) { sb.Append('\n'); sb.Append('\n'); } if(name.Length > 0) { sb.Append(ObjectNames.NicifyVariableName(name)); if(body.Length > 0) { sb.Append(" : "); sb.Append(body); } } else { sb.Append(body); } } private static string TrimAllLines(string input) { input = input.Trim(); for(int i = input.IndexOf('\n'); i != -1; i = input.IndexOf('\n', i + 1)) { input = input.Substring(0, i + 1) + input.Substring(i + 1).TrimStart(); } return input; } } } #endif