using System.IO; using System.Text; using System.Text.RegularExpressions; using System.Text.Json; using System.Xml; using System.Xml.Linq; using Autodesk.Revit.DB; using Autodesk.Revit.UI; namespace RevitBridge.Tools { /// /// Offline Revit API documentation search. Data source: the RevitAPI.xml and /// RevitAPIUI.xml compiler doc files shipped next to the Revit install, resolved from /// the loaded RevitAPI.dll location. The index is built lazily on the first query and /// shared process-wide; a missing or unparsable file degrades to a warning instead of /// an error. RequiresDocument = false: the tool never touches the Revit API (reading /// assembly metadata for the install path is not an API call), so it runs on the /// bridge's server task — index construction never blocks the Revit thread. /// internal sealed class SearchApiDocs : ITool { private const int DefaultMaxResults = 10; private const int MaxResultsCap = 50; // The Max*Chars caps apply to the model-visible markdown only; the index and // the details payload keep the full documentation text. private const int MaxMarkdownChars = 6_000; private const int MaxRemarksChars = 600; private const int MaxReturnsChars = 300; private const int MaxParamDocChars = 240; private const int MaxParamsPerMember = 10; private const int MaxExceptionsPerMember = 8; public string Name => "search_api_docs"; public string Label => "Search API Docs"; public string Description => "Search the offline Revit API documentation (the RevitAPI.xml and RevitAPIUI.xml files shipped with Revit) for types, methods, constructors, properties, fields, and events; every public API enum is fully searchable by value name (values the XML leaves undocumented are synthesized from the API assemblies). query is a single name or substring — e.g. 'FilteredElementCollector', 'Wall.Create', 'WALL_BASE_OFFSET' — ranked: exact name first, then prefix, then substring; dotted Type.Member queries match composites, and same-named overloads rank simplest-first. To target one overload, continue the query past a parenthesis with parameter types, e.g. 'Wall.Create(Document, Curve'. Returns signatures with summary, remarks, parameter docs, return docs, exception docs, and the Revit version a member was introduced in ('since'); the top match shows its full docs inline. Works with no document open. Use it to verify exact classes, members, and signatures before writing execute_csharp code. The first query builds the index (a few seconds); later queries are instant."; public bool RequiresDocument => false; public object ParametersSchema => new { type = "object", properties = new { query = new { type = "string", description = "Type or member name to look up, e.g. FilteredElementCollector, Wall.Create, WALL_BASE_OFFSET. Substring and multi-word matches are ranked; Type.Member composites rank highest on exact match.", }, kind = new { type = "string", @enum = new[] { "type", "method", "property", "field", "event" }, description = "Optional filter to one member kind ('method' includes constructors; 'field' covers enum values).", }, max_results = new { type = "integer", description = $"Maximum matches to return (1-{MaxResultsCap}, default {DefaultMaxResults}).", }, }, required = new[] { "query" }, }; public string? PromptSnippet => "Search the offline Revit API documentation for types, members, and signatures."; public IReadOnlyList? PromptGuidelines => new[] { "Before writing execute_csharp code, verify exact classes and member signatures with search_api_docs (e.g. query 'Wall.Create' or 'FilteredElementCollector').", }; public object? Execute(JsonElement args, ToolContext context) { string query = (JsonArgs.GetString(args, "query") ?? string.Empty).Trim(); if (query.Length == 0) throw new ArgumentException("query must be a non-empty string, e.g. 'FilteredElementCollector' or 'Wall.Create'."); char? kindFilter = ParseKindFilter(JsonArgs.GetString(args, "kind")); int maxResults = Math.Clamp(JsonArgs.GetInt(args, "max_results", DefaultMaxResults), 1, MaxResultsCap); var index = Index.Value; if (index.Members.Count == 0) throw new InvalidOperationException("The Revit API documentation index is empty. " + string.Join(" ", index.Warnings)); var (top, total, rewriteNote) = Search(index, query, kindFilter, maxResults); var matches = new List>(top.Count); for (int i = 0; i < top.Count; i++) { var member = top[i]; matches.Add(new Dictionary { ["rank"] = i + 1, ["kind"] = KindLabel(member), ["name"] = member.FullName, ["signature"] = member.Signature, ["assembly"] = member.Assembly, ["since"] = member.Since, ["summary"] = member.Summary, ["remarks"] = member.Remarks, ["parameters"] = member.Parameters? .Select(pair => new Dictionary { ["name"] = pair.Key, ["description"] = pair.Value }) .ToList(), ["returns"] = member.Returns, ["exceptions"] = member.Exceptions? .Select(pair => new Dictionary { ["type"] = pair.Key, ["description"] = pair.Value }) .ToList(), }); } return new ToolOutput(new { query, totalMatches = total, returnedCount = matches.Count, matches, indexedMembers = index.Members.Count, sources = index.SourceFiles, warnings = index.Warnings.Count > 0 ? index.Warnings : null, }, BuildMarkdown(query, top, total, index) + (rewriteNote is null ? string.Empty : $"\nNote: {rewriteNote}")); } // ----------------------------------------------------------------- search private static readonly char[] WordSeparators = { '.', ' ', '_', '(', ')', ',', ':', '-', '/' }; private static (List Top, int Total, string? RewriteNote) Search(DocIndex index, string query, char? kindFilter, int maxResults) { // Signatures are rendered exactly one way ("Name(Type, Type)"): normalize the // query's spacing around commas and parentheses so 'Wall.Create(Document,Curve' // and 'Wall.Create( Document, Curve' hit the same rendered text instead of // failing on typography. Parameter types are additionally reduced the same way // the renderer reduces them (namespaces stripped, CLR names -> C# keywords), so // 'Wall.Create(Document, Curve, ElementId, Boolean' and // '...(System.String' match the rendered 'bool' / 'string'. string q = NormalizeSignatureQuery(query).ToLowerInvariant(); q = Regex.Replace(q, @"\s*,\s*", ", "); q = Regex.Replace(q, @"\(\s+", "("); // The Creation-factory pattern: code says doc.Create.NewRoom(...) but the docs // live on Autodesk.Revit.Creation.Document (rendered 'Document.NewRoom') or a // base class like ItemFactoryBase. Try the factory rewrite, then the bare // member, before giving up. Deterministic rewrites of an exact idiom — never // applied unless the literal 'document.create.' / 'application.create.' prefix // is present, so ordinary names like Wall.Create are untouched. var candidates = new List<(string Query, string? Note, bool AccessorRewrite)> { (q, null, false) }; foreach (string factory in new[] { "document.create.", "application.create." }) { if (!q.StartsWith(factory, StringComparison.Ordinal)) continue; string owner = factory[..(factory.IndexOf('.') + 1)]; // "document." string rest = q[factory.Length..]; candidates.Add((owner + rest, $"'{owner}Create.*' is the Creation factory — matched as '{owner}{rest}'.", false)); string bareMember = rest.Split('(')[0]; if (bareMember.Length > 0) candidates.Add((bareMember, $"'{owner}Create.{bareMember}' is a Creation-factory call; its docs live on the factory class (e.g. ItemFactoryBase) — matched by member name '{bareMember}'.", false)); } // C# spells property and indexer accessors 'get_X'/'set_X' (element.get_Parameter(...), // wall.get_BoundingBox(view)), but the XML mostly documents the underlying property // ('Element.Parameter', 'Element.BoundingBox'). Members documented WITH a literal // prefix (LocationCurve.get_ElementsAtJoin) match the untouched query first -- // candidates run in order and this rewrite is only reached when the literal // spelling found nothing. { string accessor = q.Replace(".get_", ".", StringComparison.Ordinal).Replace(".set_", ".", StringComparison.Ordinal); if (accessor.StartsWith("get_", StringComparison.Ordinal) || accessor.StartsWith("set_", StringComparison.Ordinal)) accessor = accessor["get_".Length..]; if (accessor != q) candidates.Add((accessor, "'get_X' / 'set_X' is the C# accessor spelling of property or indexer 'X'; matched without the prefix.", true)); } foreach (var (candidate, note, accessorRewrite) in candidates) { // A C# accessor IS a method to the caller ('element.get_Parameter(...)'), but // the XML documents the underlying member as a property/indexer — so an // accessor-rewritten candidate lets kind=method admit properties too. bool widenKind = accessorRewrite && kindFilter == 'M'; var (top, total) = RunScoring(index, candidate, kindFilter, maxResults, widenKind); if (total > 0) return (top, total, widenKind ? note + " The 'method' kind filter also admitted properties here: accessors are documented as properties/indexers." : note); } return (new List(), 0, null); } private static (List Top, int Total) RunScoring(DocIndex index, string q, char? kindFilter, int maxResults, bool methodKindAdmitsProperties = false) { string[] words = q.Split(WordSeparators, StringSplitOptions.RemoveEmptyEntries); var scored = new List<(ApiMember Member, int Score)>(); foreach (var member in index.Members) { if (kindFilter is { } kind && member.Kind != kind && !(methodKindAdmitsProperties && kind == 'M' && member.Kind == 'P')) continue; int score = Score(member, q, words); if (score > 0) scored.Add((member, score)); } var top = scored .OrderByDescending(entry => entry.Score) .ThenBy(entry => entry.Member.Composite.Length) .ThenBy(entry => entry.Member.ParameterCount) .ThenBy(entry => entry.Member.FullName, StringComparer.Ordinal) .Take(maxResults) .Select(entry => entry.Member) .ToList(); return (top, scored.Count); } /// Reduces the parameter part of a signature query exactly the way the /// renderer reduces signatures: each identifier keeps only its last dot-segment /// and CLR primitive names map to C# keywords (case-insensitively — queries say /// 'Boolean' or 'boolean'; signatures render 'bool'). The member path before the /// first '(' is left untouched. private static string NormalizeSignatureQuery(string query) { int paren = query.IndexOf('('); if (paren < 0) return query; string tail = query[(paren + 1)..]; var result = new StringBuilder(tail.Length); int i = 0; while (i < tail.Length) { char c = tail[i]; if (char.IsLetter(c) || c == '_') { int start = i, lastDot = -1; while (i < tail.Length && (char.IsLetterOrDigit(tail[i]) || tail[i] is '_' or '.')) { if (tail[i] == '.') lastDot = i; i++; } string identifier = tail[(lastDot >= 0 ? lastDot + 1 : start)..i]; result.Append(QueryTypeAliases.TryGetValue(identifier, out string? keyword) ? keyword : identifier); } else { result.Append(c); i++; } } return query[..(paren + 1)] + result; } /// Case-insensitive inverse of MapTypeKeyword for query text. private static readonly Dictionary QueryTypeAliases = new(StringComparer.OrdinalIgnoreCase) { ["String"] = "string", ["Boolean"] = "bool", ["Int32"] = "int", ["Int64"] = "long", ["Int16"] = "short", ["Double"] = "double", ["Single"] = "float", ["Object"] = "object", ["Void"] = "void", ["Byte"] = "byte", ["SByte"] = "sbyte", ["Char"] = "char", ["Decimal"] = "decimal", ["UInt16"] = "ushort", ["UInt32"] = "uint", ["UInt64"] = "ulong", }; private static int Score(ApiMember member, string q, string[] words) { // A query with a parenthesis targets a specific overload by signature, // e.g. 'wall.create(document, curve' — matched against the shortened // signature text before any name-based ranking. bool signatureQuery = q.Contains('('); int score; if (member.CompositeLower == q) score = 1000; else if (signatureQuery && member.SignatureLower == q) score = 980; // Constructors render as "new Type(...)": accept the natural C# spelling // 'FilteredElementCollector(Document' without requiring the 'new' prefix. else if (signatureQuery && member.SignatureLower == "new " + q) score = 975; else if (signatureQuery && member.SignatureLower.StartsWith(q, StringComparison.Ordinal)) score = 950; else if (signatureQuery && member.SignatureLower.StartsWith("new " + q, StringComparison.Ordinal)) score = 945; else if (member.ShortNameLower == q) score = 900; else if (member.CompositeLower.StartsWith(q, StringComparison.Ordinal)) score = 700; else if (member.ShortNameLower.StartsWith(q, StringComparison.Ordinal)) score = 650; else if (member.CompositeLower.Contains(q, StringComparison.Ordinal)) score = 500; else if (member.FullNameLower.Contains(q, StringComparison.Ordinal)) score = 400; else if (words.Length > 1 && words.All(word => member.FullNameLower.Contains(word, StringComparison.Ordinal))) score = 300; else return 0; if (member.Kind == 'T') score += 30; if (member.FullNameLower.StartsWith("autodesk.revit.db.", StringComparison.Ordinal)) score += 10; return score; } private static string BuildMarkdown(string query, IReadOnlyList top, int total, DocIndex index) { var markdown = new StringBuilder(); markdown.Append($"Revit API docs for '{query}': "); if (total == 0) { markdown.Append("no matches. Try a shorter substring (a class name like 'FilteredElementCollector'), a Type.Member composite like 'Wall.Create', or drop the kind filter."); } else { markdown.Append(total == top.Count ? $"{total} match(es)." : $"top {top.Count} of {total} matches."); // Same-named members from different namespaces (XYZ exists in // Autodesk.Revit.DB and in helper namespaces) render identical // signatures; disambiguate those lines with their full container path. var ambiguous = top .GroupBy(member => member.Signature, StringComparer.Ordinal) .Where(group => group.Count() > 1) .Select(group => group.Key) .ToHashSet(StringComparer.Ordinal); for (int i = 0; i < top.Count; i++) { var member = top[i]; string origin = member.Since is null ? member.Assembly : $"{member.Assembly}, since {member.Since}"; if (ambiguous.Contains(member.Signature)) origin += $", in {ContainingPath(member)}"; string line = $"\n{i + 1}. **{member.Signature}** — {KindLabel(member)} ({origin}) — {FirstSentence(member.Summary) ?? "(no summary)"}"; if (markdown.Length + line.Length > MaxMarkdownChars) { markdown.Append($"\n… capped; {top.Count - i} more match(es) in details.payload.matches."); break; } markdown.Append(line); if (i == 0) AppendTopMatchDocs(markdown, member, top.Count(other => other.CompositeLower == member.CompositeLower)); } } foreach (string warning in index.Warnings) markdown.Append($"\nNote: {warning}"); return markdown.ToString(); } /// The model can read details.payload (the cap note above even points it /// there), but this markdown is what lands in the strongest-attention position of /// the tool result — so the top match carries its remarks, parameter, return, and /// exception docs inline (capped for display; the payload keeps full text). Lines /// that would blow the markdown budget are dropped individually. When the top match /// is one of several same-named overloads, a note says how to target another one. private static void AppendTopMatchDocs(StringBuilder markdown, ApiMember member, int overloadCount) { var lines = new List(5); if (member.Remarks is { } remarks) lines.Add($"\n remarks: {Cap(remarks, MaxRemarksChars)}"); if (member.Parameters is { Count: > 0 } parameters) lines.Add($"\n params: {string.Join("; ", parameters.Select(pair => $"{pair.Key} — {Cap(pair.Value, MaxParamDocChars)}"))}"); if (member.Returns is { } returns) lines.Add($"\n returns: {Cap(returns, MaxReturnsChars)}"); if (member.Exceptions is { Count: > 0 } exceptions) lines.Add($"\n throws: {string.Join("; ", exceptions.Select(pair => $"{pair.Key} — {Cap(pair.Value, MaxParamDocChars)}"))}"); if (overloadCount > 1) { // The targeting example must be the query form the signature matcher // actually accepts, so it is cut from the displayed signature itself — // 'new XYZ(' for constructors, 'Wall.Create(' for methods. int paren = member.Signature.IndexOf('('); string example = paren >= 0 ? member.Signature[..(paren + 1)] : member.Composite + "("; lines.Add($"\n note: {member.Composite} has {overloadCount} overload(s) listed; full docs shown for the simplest top-ranked one. To target another, continue the query past a parenthesis with its parameter types, e.g. '{example}'."); } foreach (string line in lines) { if (markdown.Length + line.Length > MaxMarkdownChars) break; markdown.Append(line); } } /// Namespace-qualified container for disambiguating display lines: /// the type itself for types, the declaring type's full path for members. private static string ContainingPath(ApiMember member) { if (member.Kind == 'T') return member.FullName; int dot = member.FullName.LastIndexOf('.'); return dot > 0 ? member.FullName[..dot] : member.FullName; } private static string KindLabel(ApiMember member) => member.IsConstructor ? "constructor" : member.Kind switch { 'T' => "type", 'M' => "method", 'P' => "property", 'F' => "field", 'E' => "event", _ => "member", }; private static char? ParseKindFilter(string? kind) { if (string.IsNullOrWhiteSpace(kind)) return null; return kind.Trim().ToLowerInvariant() switch { "type" or "class" or "t" => 'T', "method" or "constructor" or "m" => 'M', "property" or "p" => 'P', "field" or "enum" or "f" => 'F', "event" or "e" => 'E', _ => throw new ArgumentException($"Unknown kind: {kind}. Use type, method, property, field, or event."), }; } private static string? FirstSentence(string? text) { if (string.IsNullOrEmpty(text)) return null; int end = text.IndexOf(". ", StringComparison.Ordinal); string sentence = end >= 0 ? text[..(end + 1)] : text; return sentence.Length <= 220 ? sentence : sentence[..220] + "…"; } // ------------------------------------------------------------------ index private sealed class ApiMember { public required char Kind { get; init; } public required bool IsConstructor { get; init; } public required string Assembly { get; init; } /// Namespace-qualified name without the kind prefix or parameter list. public required string FullName { get; init; } /// "Wall" for types, "Wall.Create" for members — the primary match target. public required string Composite { get; init; } public required string Signature { get; init; } public required string FullNameLower { get; init; } public required string CompositeLower { get; init; } public required string ShortNameLower { get; init; } public required string SignatureLower { get; init; } /// Top-level parameter count from the doc id; overload tie-breaks /// rank the simplest overload first. public required int ParameterCount { get; init; } public string? Summary { get; init; } public string? Remarks { get; init; } public string? Returns { get; init; } public string? Since { get; init; } public IReadOnlyList>? Parameters { get; init; } public IReadOnlyList>? Exceptions { get; init; } } private sealed class DocIndex { public required IReadOnlyList Members { get; init; } public required IReadOnlyList SourceFiles { get; init; } public required IReadOnlyList Warnings { get; init; } } /// Built once per Revit session on first query; thread-safe via Lazy. The /// factory never throws — file problems are captured as warnings instead. private static readonly Lazy Index = new(BuildIndex, LazyThreadSafetyMode.ExecutionAndPublication); private static DocIndex BuildIndex() { var members = new List(90_000); var sources = new List(); var warnings = new List(); foreach (string path in CandidateXmlFiles(warnings)) { if (!File.Exists(path)) { warnings.Add($"Documentation file not found: {path}."); continue; } try { ParseFile(path, members); sources.Add(path); } catch (Exception ex) { warnings.Add($"Failed to parse {path}: {ex.Message}"); } } AddUndocumentedEnumValues(members, warnings); return new DocIndex { Members = members, SourceFiles = sources, Warnings = warnings }; } /// Autodesk's XML leaves many public API enums partially or entirely /// undocumented (measured on Revit 2025: 107 of 669, from BuiltInCategory's ~1200 /// values down to small flags). Synthesize the missing values from the loaded API /// assemblies via reflection — pure metadata, safe off the Revit thread. private static void AddUndocumentedEnumValues(List members, List warnings) { var existing = new HashSet( members.Where(member => member.Kind == 'F').Select(member => member.FullName), StringComparer.Ordinal); var assemblies = new (Func Load, string Label)[] { (() => typeof(Document).Assembly, "RevitAPI"), (() => typeof(UIApplication).Assembly, "RevitAPIUI"), }; foreach (var (load, label) in assemblies) { try { foreach (var type in load().GetExportedTypes()) { if (type.IsEnum) AddEnumValues(type, label, existing, members); } } catch (Exception ex) { warnings.Add($"Could not enumerate {label} enum values: {ex.Message}"); } } } /// Adds every value of one enum that the XML did not document itself. private static void AddEnumValues(Type enumType, string assemblyName, HashSet existing, List members) { string typeName = enumType.Name; string fullPrefix = (enumType.FullName ?? typeName).Replace('+', '.'); foreach (string name in Enum.GetNames(enumType)) { string fullName = fullPrefix + "." + name; if (!existing.Add(fullName)) continue; string composite = typeName + "." + name; members.Add(new ApiMember { Kind = 'F', IsConstructor = false, Assembly = assemblyName, FullName = fullName, Composite = composite, Signature = composite, FullNameLower = fullName.ToLowerInvariant(), CompositeLower = composite.ToLowerInvariant(), ShortNameLower = name.ToLowerInvariant(), SignatureLower = composite.ToLowerInvariant(), ParameterCount = 0, Summary = enumType == typeof(BuiltInCategory) ? "BuiltInCategory enum value — usable as the 'category' argument of get_elements/get_element_types and with FilteredElementCollector.OfCategory in execute_csharp." : $"{typeName} enum value (not documented in the XML; synthesized from {assemblyName} metadata).", }); } } /// RevitAPI.xml + RevitAPIUI.xml next to the loaded RevitAPI.dll. When the /// assembly location cannot be resolved, indexing is skipped with a warning instead /// of guessing at an install directory for a Revit version that was never detected. private static IEnumerable CandidateXmlFiles(List warnings) { string? installDir = null; try { string location = typeof(Document).Assembly.Location; if (!string.IsNullOrEmpty(location)) installDir = Path.GetDirectoryName(location); } catch { // Handled below together with the empty-location case. } if (string.IsNullOrEmpty(installDir)) { warnings.Add("Could not resolve the Revit install directory from the loaded RevitAPI.dll; documentation indexing skipped."); yield break; } yield return Path.Combine(installDir, "RevitAPI.xml"); yield return Path.Combine(installDir, "RevitAPIUI.xml"); } private static void ParseFile(string path, List members) { string assemblyName = Path.GetFileNameWithoutExtension(path); using var reader = XmlReader.Create(path, new XmlReaderSettings { IgnoreComments = true }); reader.MoveToContent(); while (!reader.EOF) { if (reader.NodeType == XmlNodeType.Element && reader.Name == "member") { string? rawName = reader.GetAttribute("name"); // XNode.ReadFrom consumes the whole element and leaves the // reader on the following node — do not Read() again here. var element = (XElement)XNode.ReadFrom(reader); if (rawName != null && CreateMember(rawName, element, assemblyName) is { } member) members.Add(member); } else { reader.Read(); } } } private static ApiMember? CreateMember(string rawName, XElement element, string assemblyName) { if (rawName.Length < 3 || rawName[1] != ':') return null; char kind = rawName[0]; if (kind is not ('T' or 'M' or 'P' or 'F' or 'E')) return null; string body = rawName[2..]; int paren = body.IndexOf('('); string path = paren >= 0 ? body[..paren] : body; string? paramText = null; if (paren >= 0) { int close = body.LastIndexOf(')'); if (close > paren) paramText = body[(paren + 1)..close]; } string[] segments = path.Split('.'); string shortName; string composite; string signature; bool isConstructor = false; if (kind == 'T') { shortName = StripArity(segments[^1]); composite = shortName; signature = shortName; } else { string memberName = segments[^1]; string typeName = segments.Length >= 2 ? StripArity(segments[^2]) : string.Empty; if (memberName is "#ctor" or "#cctor") { isConstructor = true; shortName = typeName; composite = typeName.Length > 0 ? $"{typeName}.{typeName}" : typeName; signature = $"new {typeName}({(paramText is null ? string.Empty : ShortenTypeText(paramText))})"; } else { // Explicit interface implementations look like System#IDisposable#Dispose. int hash = memberName.LastIndexOf('#'); if (hash >= 0) memberName = memberName[(hash + 1)..]; shortName = StripArity(memberName); composite = typeName.Length > 0 ? $"{typeName}.{shortName}" : shortName; signature = kind == 'M' || paramText != null ? $"{composite}({(paramText is null ? string.Empty : ShortenTypeText(paramText))})" : composite; } } List>? parameters = null; foreach (var param in element.Elements("param")) { if (parameters is { Count: >= MaxParamsPerMember }) break; string? name = param.Attribute("name")?.Value; string? text = CleanDocText(param); if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(text)) continue; (parameters ??= new List>()).Add(new KeyValuePair(name, text)); } List>? exceptions = null; foreach (var exception in element.Elements("exception")) { if (exceptions is { Count: >= MaxExceptionsPerMember }) break; string? cref = exception.Attribute("cref")?.Value; string? text = CleanDocText(exception); if (string.IsNullOrEmpty(cref) || string.IsNullOrEmpty(text)) continue; (exceptions ??= new List>()).Add(new KeyValuePair(ShortCref(cref), text)); } return new ApiMember { Kind = kind, IsConstructor = isConstructor, Assembly = assemblyName, FullName = path, Composite = composite, Signature = signature, FullNameLower = path.ToLowerInvariant(), CompositeLower = composite.ToLowerInvariant(), ShortNameLower = shortName.ToLowerInvariant(), SignatureLower = signature.ToLowerInvariant(), ParameterCount = CountTopLevelParameters(paramText), Summary = CleanDocText(element.Element("summary")), Remarks = CleanDocText(element.Element("remarks")), Returns = CleanDocText(element.Element("returns")), Since = CleanDocText(element.Element("since")), Parameters = parameters, Exceptions = exceptions, }; } /// Parameter count of a doc-id parameter list; commas inside generic /// braces (Dictionary{K,V}) do not separate parameters. private static int CountTopLevelParameters(string? paramText) { if (string.IsNullOrEmpty(paramText)) return 0; int count = 1; int depth = 0; foreach (char c in paramText) { if (c == '{') depth++; else if (c == '}') depth--; else if (c == ',' && depth == 0) count++; } return count; } // ------------------------------------------------------------- text utils /// Flattens doc XML to plain text: see/seealso cref -> short type name, /// paramref/typeparamref -> the name, all other tags -> their inner text; /// whitespace collapsed. private static string? CleanDocText(XElement? element) { if (element is null) return null; var sb = new StringBuilder(); foreach (var node in element.Nodes()) AppendNode(sb, node); string text = CollapseWhitespace(sb.ToString()); return text.Length == 0 ? null : text; } private static void AppendNode(StringBuilder sb, XNode node) { switch (node) { case XText text: sb.Append(text.Value); break; case XElement element: switch (element.Name.LocalName) { case "see" or "seealso": string? target = element.Attribute("cref")?.Value ?? element.Attribute("langword")?.Value; if (target != null) sb.Append(ShortCref(target)); else foreach (var child in element.Nodes()) AppendNode(sb, child); break; case "paramref" or "typeparamref": sb.Append(element.Attribute("name")?.Value); break; default: foreach (var child in element.Nodes()) AppendNode(sb, child); sb.Append(' '); break; } break; } } /// "T:Autodesk.Revit.DB.Wall" -> "Wall"; "M:...Wall.Create(...)" -> "Create". private static string ShortCref(string cref) { string text = cref.Length > 2 && cref[1] == ':' ? cref[2..] : cref; int paren = text.IndexOf('('); if (paren >= 0) text = text[..paren]; int dot = text.LastIndexOf('.'); if (dot >= 0) text = text[(dot + 1)..]; return StripArity(text); } /// Shortens a doc-id parameter list: namespaces stripped, {} -> <>, /// `N arity dropped, ``N generic parameter references -> T, @ (by-ref) -> &, /// System primitives -> C# keywords. "Autodesk.Revit.DB.Document,System.Collections.Generic.IList{Autodesk.Revit.DB.Curve},System.Boolean" /// -> "Document, IList<Curve>, bool". private static string ShortenTypeText(string text) { var result = new StringBuilder(text.Length); int i = 0; while (i < text.Length) { char c = text[i]; if (char.IsLetter(c) || c == '_') { int start = i; int lastDot = -1; while (i < text.Length && (char.IsLetterOrDigit(text[i]) || text[i] is '_' or '.')) { if (text[i] == '.') lastDot = i; i++; } result.Append(MapTypeKeyword(text[(lastDot >= 0 ? lastDot + 1 : start)..i])); } else if (c == '`') { // Directly after an identifier: a generic arity suffix (List`1) — drop it. // Standalone: a generic parameter reference (``0) — render as T. bool arity = i > 0 && char.IsLetterOrDigit(text[i - 1]); while (i < text.Length && (text[i] == '`' || char.IsDigit(text[i]))) i++; if (!arity) result.Append('T'); } else { result.Append(c switch { '{' => '<', '}' => '>', '@' => '&', _ => c }); if (c == ',') result.Append(' '); i++; } } return result.ToString(); } private static string MapTypeKeyword(string name) => name switch { "String" => "string", "Boolean" => "bool", "Int32" => "int", "Int64" => "long", "Int16" => "short", "Double" => "double", "Single" => "float", "Object" => "object", "Void" => "void", "Byte" => "byte", "SByte" => "sbyte", "Char" => "char", "Decimal" => "decimal", "UInt16" => "ushort", "UInt32" => "uint", "UInt64" => "ulong", _ => name, }; private static string StripArity(string name) { int tick = name.IndexOf('`'); return tick >= 0 ? name[..tick] : name; } private static string CollapseWhitespace(string text) { var sb = new StringBuilder(text.Length); bool pendingSpace = false; foreach (char c in text) { if (char.IsWhiteSpace(c)) { pendingSpace = sb.Length > 0; continue; } if (pendingSpace) { sb.Append(' '); pendingSpace = false; } sb.Append(c); } return sb.ToString(); } private static string? Cap(string? text, int max) => text is null || text.Length <= max ? text : text[..max] + "…"; } }