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] + "…";
}
}