pi-revit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,610 @@
1
+ using System.IO;
2
+ using System.Text;
3
+ using System.Text.Json;
4
+ using System.Xml;
5
+ using System.Xml.Linq;
6
+ using Autodesk.Revit.DB;
7
+
8
+ namespace RevitBridge.Tools
9
+ {
10
+ /// <summary>
11
+ /// Offline Revit API documentation search. Data source: the RevitAPI.xml and
12
+ /// RevitAPIUI.xml compiler doc files shipped next to the Revit install, resolved from
13
+ /// the loaded RevitAPI.dll location. The index is built lazily on the first query and
14
+ /// shared process-wide; a missing or unparsable file degrades to a warning instead of
15
+ /// an error. RequiresDocument = false: the tool never touches the Revit API (reading
16
+ /// assembly metadata for the install path is not an API call), so it runs on the
17
+ /// bridge's server task — index construction never blocks the Revit thread.
18
+ /// </summary>
19
+ internal sealed class SearchApiDocs : ITool
20
+ {
21
+ private const int DefaultMaxResults = 10;
22
+ private const int MaxResultsCap = 50;
23
+ private const int MaxMarkdownChars = 6_000;
24
+ private const int MaxSummaryChars = 600;
25
+ private const int MaxReturnsChars = 300;
26
+ private const int MaxParamDocChars = 240;
27
+ private const int MaxParamsPerMember = 10;
28
+
29
+ public string Name => "search_api_docs";
30
+ public string Label => "Search API Docs";
31
+ 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 (including enum values such as BuiltInParameter names), and events. query is a single name or substring — e.g. 'FilteredElementCollector', 'Wall.Create', 'WALL_BASE_OFFSET', 'ElementTransformUtils' — ranked: exact name first, then prefix, then substring; dotted Type.Member queries match composites. Returns signatures with summary, parameter docs, return docs, and the Revit version a member was introduced in ('since'). 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.";
32
+
33
+ public bool RequiresDocument => false;
34
+
35
+ public object ParametersSchema => new
36
+ {
37
+ type = "object",
38
+ properties = new
39
+ {
40
+ query = new
41
+ {
42
+ type = "string",
43
+ 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.",
44
+ },
45
+ kind = new
46
+ {
47
+ type = "string",
48
+ @enum = new[] { "type", "method", "property", "field", "event" },
49
+ description = "Optional filter to one member kind ('method' includes constructors; 'field' covers enum values).",
50
+ },
51
+ max_results = new
52
+ {
53
+ type = "integer",
54
+ description = $"Maximum matches to return (1-{MaxResultsCap}, default {DefaultMaxResults}).",
55
+ },
56
+ },
57
+ required = new[] { "query" },
58
+ };
59
+
60
+ public string? PromptSnippet => "Search the offline Revit API documentation for types, members, and signatures.";
61
+ public IReadOnlyList<string>? PromptGuidelines => new[]
62
+ {
63
+ "Before writing execute_csharp code, verify exact classes and member signatures with search_api_docs (e.g. query 'Wall.Create' or 'FilteredElementCollector').",
64
+ };
65
+
66
+ public object? Execute(JsonElement args, ToolContext context)
67
+ {
68
+ string query = (JsonArgs.GetString(args, "query") ?? string.Empty).Trim();
69
+ if (query.Length == 0)
70
+ throw new ArgumentException("query must be a non-empty string, e.g. 'FilteredElementCollector' or 'Wall.Create'.");
71
+ char? kindFilter = ParseKindFilter(JsonArgs.GetString(args, "kind"));
72
+ int maxResults = Math.Clamp(JsonArgs.GetInt(args, "max_results", DefaultMaxResults), 1, MaxResultsCap);
73
+
74
+ var index = Index.Value;
75
+ if (index.Members.Count == 0)
76
+ throw new InvalidOperationException("The Revit API documentation index is empty. " + string.Join(" ", index.Warnings));
77
+
78
+ var (top, total) = Search(index, query, kindFilter, maxResults);
79
+
80
+ var matches = new List<Dictionary<string, object?>>(top.Count);
81
+ for (int i = 0; i < top.Count; i++)
82
+ {
83
+ var member = top[i];
84
+ matches.Add(new Dictionary<string, object?>
85
+ {
86
+ ["rank"] = i + 1,
87
+ ["kind"] = KindLabel(member),
88
+ ["name"] = member.FullName,
89
+ ["signature"] = member.Signature,
90
+ ["assembly"] = member.Assembly,
91
+ ["since"] = member.Since,
92
+ ["summary"] = member.Summary,
93
+ ["parameters"] = member.Parameters?
94
+ .Select(pair => new Dictionary<string, object?> { ["name"] = pair.Key, ["description"] = pair.Value })
95
+ .ToList(),
96
+ ["returns"] = member.Returns,
97
+ });
98
+ }
99
+
100
+ return new ToolOutput(new
101
+ {
102
+ query,
103
+ totalMatches = total,
104
+ returnedCount = matches.Count,
105
+ matches,
106
+ indexedMembers = index.Members.Count,
107
+ sources = index.SourceFiles,
108
+ warnings = index.Warnings.Count > 0 ? index.Warnings : null,
109
+ }, BuildMarkdown(query, top, total, index));
110
+ }
111
+
112
+ // ----------------------------------------------------------------- search
113
+
114
+ private static readonly char[] WordSeparators = { '.', ' ', '_', '(', ')', ',', ':', '-', '/' };
115
+
116
+ private static (List<ApiMember> Top, int Total) Search(DocIndex index, string query, char? kindFilter, int maxResults)
117
+ {
118
+ string q = query.ToLowerInvariant();
119
+ string[] words = q.Split(WordSeparators, StringSplitOptions.RemoveEmptyEntries);
120
+
121
+ var scored = new List<(ApiMember Member, int Score)>();
122
+ foreach (var member in index.Members)
123
+ {
124
+ if (kindFilter is { } kind && member.Kind != kind)
125
+ continue;
126
+ int score = Score(member, q, words);
127
+ if (score > 0)
128
+ scored.Add((member, score));
129
+ }
130
+
131
+ var top = scored
132
+ .OrderByDescending(entry => entry.Score)
133
+ .ThenBy(entry => entry.Member.Composite.Length)
134
+ .ThenBy(entry => entry.Member.FullName, StringComparer.Ordinal)
135
+ .Take(maxResults)
136
+ .Select(entry => entry.Member)
137
+ .ToList();
138
+ return (top, scored.Count);
139
+ }
140
+
141
+ private static int Score(ApiMember member, string q, string[] words)
142
+ {
143
+ int score;
144
+ if (member.CompositeLower == q) score = 1000;
145
+ else if (member.ShortNameLower == q) score = 900;
146
+ else if (member.CompositeLower.StartsWith(q, StringComparison.Ordinal)) score = 700;
147
+ else if (member.ShortNameLower.StartsWith(q, StringComparison.Ordinal)) score = 650;
148
+ else if (member.CompositeLower.Contains(q, StringComparison.Ordinal)) score = 500;
149
+ else if (member.FullNameLower.Contains(q, StringComparison.Ordinal)) score = 400;
150
+ else if (words.Length > 1 && words.All(word => member.FullNameLower.Contains(word, StringComparison.Ordinal))) score = 300;
151
+ else return 0;
152
+
153
+ if (member.Kind == 'T') score += 30;
154
+ if (member.FullNameLower.StartsWith("autodesk.revit.db.", StringComparison.Ordinal)) score += 10;
155
+ return score;
156
+ }
157
+
158
+ private static string BuildMarkdown(string query, IReadOnlyList<ApiMember> top, int total, DocIndex index)
159
+ {
160
+ var markdown = new StringBuilder();
161
+ markdown.Append($"Revit API docs for '{query}': ");
162
+ if (total == 0)
163
+ {
164
+ 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.");
165
+ }
166
+ else
167
+ {
168
+ markdown.Append(total == top.Count ? $"{total} match(es)." : $"top {top.Count} of {total} matches.");
169
+ for (int i = 0; i < top.Count; i++)
170
+ {
171
+ var member = top[i];
172
+ string origin = member.Since is null ? member.Assembly : $"{member.Assembly}, since {member.Since}";
173
+ string line = $"\n{i + 1}. **{member.Signature}** — {KindLabel(member)} ({origin}) — {FirstSentence(member.Summary) ?? "(no summary)"}";
174
+ if (markdown.Length + line.Length > MaxMarkdownChars)
175
+ {
176
+ markdown.Append($"\n… capped; {top.Count - i} more match(es) in details.payload.matches.");
177
+ break;
178
+ }
179
+ markdown.Append(line);
180
+ }
181
+ }
182
+ foreach (string warning in index.Warnings)
183
+ markdown.Append($"\nNote: {warning}");
184
+ return markdown.ToString();
185
+ }
186
+
187
+ private static string KindLabel(ApiMember member) => member.IsConstructor ? "constructor" : member.Kind switch
188
+ {
189
+ 'T' => "type",
190
+ 'M' => "method",
191
+ 'P' => "property",
192
+ 'F' => "field",
193
+ 'E' => "event",
194
+ _ => "member",
195
+ };
196
+
197
+ private static char? ParseKindFilter(string? kind)
198
+ {
199
+ if (string.IsNullOrWhiteSpace(kind))
200
+ return null;
201
+ return kind.Trim().ToLowerInvariant() switch
202
+ {
203
+ "type" or "class" or "t" => 'T',
204
+ "method" or "constructor" or "m" => 'M',
205
+ "property" or "p" => 'P',
206
+ "field" or "enum" or "f" => 'F',
207
+ "event" or "e" => 'E',
208
+ _ => throw new ArgumentException($"Unknown kind: {kind}. Use type, method, property, field, or event."),
209
+ };
210
+ }
211
+
212
+ private static string? FirstSentence(string? text)
213
+ {
214
+ if (string.IsNullOrEmpty(text))
215
+ return null;
216
+ int end = text.IndexOf(". ", StringComparison.Ordinal);
217
+ string sentence = end >= 0 ? text[..(end + 1)] : text;
218
+ return sentence.Length <= 220 ? sentence : sentence[..220] + "…";
219
+ }
220
+
221
+ // ------------------------------------------------------------------ index
222
+
223
+ private sealed class ApiMember
224
+ {
225
+ public required char Kind { get; init; }
226
+ public required bool IsConstructor { get; init; }
227
+ public required string Assembly { get; init; }
228
+
229
+ /// <summary>Namespace-qualified name without the kind prefix or parameter list.</summary>
230
+ public required string FullName { get; init; }
231
+
232
+ /// <summary>"Wall" for types, "Wall.Create" for members — the primary match target.</summary>
233
+ public required string Composite { get; init; }
234
+
235
+ public required string Signature { get; init; }
236
+ public required string FullNameLower { get; init; }
237
+ public required string CompositeLower { get; init; }
238
+ public required string ShortNameLower { get; init; }
239
+ public string? Summary { get; init; }
240
+ public string? Returns { get; init; }
241
+ public string? Since { get; init; }
242
+ public IReadOnlyList<KeyValuePair<string, string>>? Parameters { get; init; }
243
+ }
244
+
245
+ private sealed class DocIndex
246
+ {
247
+ public required IReadOnlyList<ApiMember> Members { get; init; }
248
+ public required IReadOnlyList<string> SourceFiles { get; init; }
249
+ public required IReadOnlyList<string> Warnings { get; init; }
250
+ }
251
+
252
+ /// <summary>Built once per Revit session on first query; thread-safe via Lazy. The
253
+ /// factory never throws — file problems are captured as warnings instead.</summary>
254
+ private static readonly Lazy<DocIndex> Index = new(BuildIndex, LazyThreadSafetyMode.ExecutionAndPublication);
255
+
256
+ private static DocIndex BuildIndex()
257
+ {
258
+ var members = new List<ApiMember>(90_000);
259
+ var sources = new List<string>();
260
+ var warnings = new List<string>();
261
+
262
+ foreach (string path in CandidateXmlFiles())
263
+ {
264
+ if (!File.Exists(path))
265
+ {
266
+ warnings.Add($"Documentation file not found: {path}.");
267
+ continue;
268
+ }
269
+ try
270
+ {
271
+ ParseFile(path, members);
272
+ sources.Add(path);
273
+ }
274
+ catch (Exception ex)
275
+ {
276
+ warnings.Add($"Failed to parse {path}: {ex.Message}");
277
+ }
278
+ }
279
+
280
+ AddBuiltInCategoryValues(members, warnings);
281
+
282
+ return new DocIndex { Members = members, SourceFiles = sources, Warnings = warnings };
283
+ }
284
+
285
+ /// <summary>Autodesk's XML documents only a handful of the ~1000 BuiltInCategory
286
+ /// values. Synthesize the rest from the loaded RevitAPI.dll via reflection —
287
+ /// pure metadata, safe off the Revit thread.</summary>
288
+ private static void AddBuiltInCategoryValues(List<ApiMember> members, List<string> warnings)
289
+ {
290
+ try
291
+ {
292
+ var existing = new HashSet<string>(
293
+ members
294
+ .Where(member => member.FullName.StartsWith("Autodesk.Revit.DB.BuiltInCategory.", StringComparison.Ordinal))
295
+ .Select(member => member.FullName),
296
+ StringComparer.Ordinal);
297
+
298
+ foreach (string name in Enum.GetNames(typeof(Autodesk.Revit.DB.BuiltInCategory)))
299
+ {
300
+ string fullName = "Autodesk.Revit.DB.BuiltInCategory." + name;
301
+ if (!existing.Add(fullName))
302
+ continue;
303
+
304
+ string composite = "BuiltInCategory." + name;
305
+ members.Add(new ApiMember
306
+ {
307
+ Kind = 'F',
308
+ IsConstructor = false,
309
+ Assembly = "RevitAPI",
310
+ FullName = fullName,
311
+ Composite = composite,
312
+ Signature = composite,
313
+ FullNameLower = fullName.ToLowerInvariant(),
314
+ CompositeLower = composite.ToLowerInvariant(),
315
+ ShortNameLower = name.ToLowerInvariant(),
316
+ Summary = "BuiltInCategory enum value — usable as the 'category' argument of get_elements/get_element_types and with FilteredElementCollector.OfCategory in execute_csharp.",
317
+ });
318
+ }
319
+ }
320
+ catch (Exception ex)
321
+ {
322
+ warnings.Add($"Could not enumerate BuiltInCategory values: {ex.Message}");
323
+ }
324
+ }
325
+
326
+ /// <summary>RevitAPI.xml + RevitAPIUI.xml next to the loaded RevitAPI.dll, with the
327
+ /// standard install directory as the fallback.</summary>
328
+ private static IEnumerable<string> CandidateXmlFiles()
329
+ {
330
+ string? installDir = null;
331
+ try
332
+ {
333
+ string location = typeof(Document).Assembly.Location;
334
+ if (!string.IsNullOrEmpty(location))
335
+ installDir = Path.GetDirectoryName(location);
336
+ }
337
+ catch
338
+ {
339
+ // Fall through to the standard path.
340
+ }
341
+ installDir = string.IsNullOrEmpty(installDir) ? @"C:\Program Files\Autodesk\Revit 2025" : installDir;
342
+ yield return Path.Combine(installDir, "RevitAPI.xml");
343
+ yield return Path.Combine(installDir, "RevitAPIUI.xml");
344
+ }
345
+
346
+ private static void ParseFile(string path, List<ApiMember> members)
347
+ {
348
+ string assemblyName = Path.GetFileNameWithoutExtension(path);
349
+ using var reader = XmlReader.Create(path, new XmlReaderSettings { IgnoreComments = true });
350
+ reader.MoveToContent();
351
+ while (!reader.EOF)
352
+ {
353
+ if (reader.NodeType == XmlNodeType.Element && reader.Name == "member")
354
+ {
355
+ string? rawName = reader.GetAttribute("name");
356
+ // XNode.ReadFrom consumes the whole <member> element and leaves the
357
+ // reader on the following node — do not Read() again here.
358
+ var element = (XElement)XNode.ReadFrom(reader);
359
+ if (rawName != null && CreateMember(rawName, element, assemblyName) is { } member)
360
+ members.Add(member);
361
+ }
362
+ else
363
+ {
364
+ reader.Read();
365
+ }
366
+ }
367
+ }
368
+
369
+ private static ApiMember? CreateMember(string rawName, XElement element, string assemblyName)
370
+ {
371
+ if (rawName.Length < 3 || rawName[1] != ':')
372
+ return null;
373
+ char kind = rawName[0];
374
+ if (kind is not ('T' or 'M' or 'P' or 'F' or 'E'))
375
+ return null;
376
+
377
+ string body = rawName[2..];
378
+ int paren = body.IndexOf('(');
379
+ string path = paren >= 0 ? body[..paren] : body;
380
+ string? paramText = null;
381
+ if (paren >= 0)
382
+ {
383
+ int close = body.LastIndexOf(')');
384
+ if (close > paren)
385
+ paramText = body[(paren + 1)..close];
386
+ }
387
+
388
+ string[] segments = path.Split('.');
389
+ string shortName;
390
+ string composite;
391
+ string signature;
392
+ bool isConstructor = false;
393
+
394
+ if (kind == 'T')
395
+ {
396
+ shortName = StripArity(segments[^1]);
397
+ composite = shortName;
398
+ signature = shortName;
399
+ }
400
+ else
401
+ {
402
+ string memberName = segments[^1];
403
+ string typeName = segments.Length >= 2 ? StripArity(segments[^2]) : string.Empty;
404
+ if (memberName is "#ctor" or "#cctor")
405
+ {
406
+ isConstructor = true;
407
+ shortName = typeName;
408
+ composite = typeName.Length > 0 ? $"{typeName}.{typeName}" : typeName;
409
+ signature = $"new {typeName}({(paramText is null ? string.Empty : ShortenTypeText(paramText))})";
410
+ }
411
+ else
412
+ {
413
+ // Explicit interface implementations look like System#IDisposable#Dispose.
414
+ int hash = memberName.LastIndexOf('#');
415
+ if (hash >= 0)
416
+ memberName = memberName[(hash + 1)..];
417
+ shortName = StripArity(memberName);
418
+ composite = typeName.Length > 0 ? $"{typeName}.{shortName}" : shortName;
419
+ signature = kind == 'M' || paramText != null
420
+ ? $"{composite}({(paramText is null ? string.Empty : ShortenTypeText(paramText))})"
421
+ : composite;
422
+ }
423
+ }
424
+
425
+ List<KeyValuePair<string, string>>? parameters = null;
426
+ foreach (var param in element.Elements("param"))
427
+ {
428
+ if (parameters is { Count: >= MaxParamsPerMember })
429
+ break;
430
+ string? name = param.Attribute("name")?.Value;
431
+ string? text = Cap(CleanDocText(param), MaxParamDocChars);
432
+ if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(text))
433
+ continue;
434
+ (parameters ??= new List<KeyValuePair<string, string>>()).Add(new KeyValuePair<string, string>(name, text));
435
+ }
436
+
437
+ return new ApiMember
438
+ {
439
+ Kind = kind,
440
+ IsConstructor = isConstructor,
441
+ Assembly = assemblyName,
442
+ FullName = path,
443
+ Composite = composite,
444
+ Signature = signature,
445
+ FullNameLower = path.ToLowerInvariant(),
446
+ CompositeLower = composite.ToLowerInvariant(),
447
+ ShortNameLower = shortName.ToLowerInvariant(),
448
+ Summary = Cap(CleanDocText(element.Element("summary")), MaxSummaryChars),
449
+ Returns = Cap(CleanDocText(element.Element("returns")), MaxReturnsChars),
450
+ Since = CleanDocText(element.Element("since")),
451
+ Parameters = parameters,
452
+ };
453
+ }
454
+
455
+ // ------------------------------------------------------------- text utils
456
+
457
+ /// <summary>Flattens doc XML to plain text: see/seealso cref -> short type name,
458
+ /// paramref/typeparamref -> the name, all other tags -> their inner text;
459
+ /// whitespace collapsed.</summary>
460
+ private static string? CleanDocText(XElement? element)
461
+ {
462
+ if (element is null)
463
+ return null;
464
+ var sb = new StringBuilder();
465
+ foreach (var node in element.Nodes())
466
+ AppendNode(sb, node);
467
+ string text = CollapseWhitespace(sb.ToString());
468
+ return text.Length == 0 ? null : text;
469
+ }
470
+
471
+ private static void AppendNode(StringBuilder sb, XNode node)
472
+ {
473
+ switch (node)
474
+ {
475
+ case XText text:
476
+ sb.Append(text.Value);
477
+ break;
478
+ case XElement element:
479
+ switch (element.Name.LocalName)
480
+ {
481
+ case "see" or "seealso":
482
+ string? target = element.Attribute("cref")?.Value ?? element.Attribute("langword")?.Value;
483
+ if (target != null)
484
+ sb.Append(ShortCref(target));
485
+ else
486
+ foreach (var child in element.Nodes())
487
+ AppendNode(sb, child);
488
+ break;
489
+ case "paramref" or "typeparamref":
490
+ sb.Append(element.Attribute("name")?.Value);
491
+ break;
492
+ default:
493
+ foreach (var child in element.Nodes())
494
+ AppendNode(sb, child);
495
+ sb.Append(' ');
496
+ break;
497
+ }
498
+ break;
499
+ }
500
+ }
501
+
502
+ /// <summary>"T:Autodesk.Revit.DB.Wall" -> "Wall"; "M:...Wall.Create(...)" -> "Create".</summary>
503
+ private static string ShortCref(string cref)
504
+ {
505
+ string text = cref.Length > 2 && cref[1] == ':' ? cref[2..] : cref;
506
+ int paren = text.IndexOf('(');
507
+ if (paren >= 0)
508
+ text = text[..paren];
509
+ int dot = text.LastIndexOf('.');
510
+ if (dot >= 0)
511
+ text = text[(dot + 1)..];
512
+ return StripArity(text);
513
+ }
514
+
515
+ /// <summary>Shortens a doc-id parameter list: namespaces stripped, {} -> &lt;&gt;,
516
+ /// `N arity dropped, ``N generic parameter references -> T, @ (by-ref) -> &amp;,
517
+ /// System primitives -> C# keywords. "Autodesk.Revit.DB.Document,System.Collections.Generic.IList{Autodesk.Revit.DB.Curve},System.Boolean"
518
+ /// -> "Document, IList&lt;Curve&gt;, bool".</summary>
519
+ private static string ShortenTypeText(string text)
520
+ {
521
+ var result = new StringBuilder(text.Length);
522
+ int i = 0;
523
+ while (i < text.Length)
524
+ {
525
+ char c = text[i];
526
+ if (char.IsLetter(c) || c == '_')
527
+ {
528
+ int start = i;
529
+ int lastDot = -1;
530
+ while (i < text.Length && (char.IsLetterOrDigit(text[i]) || text[i] is '_' or '.'))
531
+ {
532
+ if (text[i] == '.')
533
+ lastDot = i;
534
+ i++;
535
+ }
536
+ result.Append(MapTypeKeyword(text[(lastDot >= 0 ? lastDot + 1 : start)..i]));
537
+ }
538
+ else if (c == '`')
539
+ {
540
+ // Directly after an identifier: a generic arity suffix (List`1) — drop it.
541
+ // Standalone: a generic parameter reference (``0) — render as T.
542
+ bool arity = i > 0 && char.IsLetterOrDigit(text[i - 1]);
543
+ while (i < text.Length && (text[i] == '`' || char.IsDigit(text[i])))
544
+ i++;
545
+ if (!arity)
546
+ result.Append('T');
547
+ }
548
+ else
549
+ {
550
+ result.Append(c switch { '{' => '<', '}' => '>', '@' => '&', _ => c });
551
+ if (c == ',')
552
+ result.Append(' ');
553
+ i++;
554
+ }
555
+ }
556
+ return result.ToString();
557
+ }
558
+
559
+ private static string MapTypeKeyword(string name) => name switch
560
+ {
561
+ "String" => "string",
562
+ "Boolean" => "bool",
563
+ "Int32" => "int",
564
+ "Int64" => "long",
565
+ "Int16" => "short",
566
+ "Double" => "double",
567
+ "Single" => "float",
568
+ "Object" => "object",
569
+ "Void" => "void",
570
+ "Byte" => "byte",
571
+ "SByte" => "sbyte",
572
+ "Char" => "char",
573
+ "Decimal" => "decimal",
574
+ "UInt16" => "ushort",
575
+ "UInt32" => "uint",
576
+ "UInt64" => "ulong",
577
+ _ => name,
578
+ };
579
+
580
+ private static string StripArity(string name)
581
+ {
582
+ int tick = name.IndexOf('`');
583
+ return tick >= 0 ? name[..tick] : name;
584
+ }
585
+
586
+ private static string CollapseWhitespace(string text)
587
+ {
588
+ var sb = new StringBuilder(text.Length);
589
+ bool pendingSpace = false;
590
+ foreach (char c in text)
591
+ {
592
+ if (char.IsWhiteSpace(c))
593
+ {
594
+ pendingSpace = sb.Length > 0;
595
+ continue;
596
+ }
597
+ if (pendingSpace)
598
+ {
599
+ sb.Append(' ');
600
+ pendingSpace = false;
601
+ }
602
+ sb.Append(c);
603
+ }
604
+ return sb.ToString();
605
+ }
606
+
607
+ private static string? Cap(string? text, int max)
608
+ => text is null || text.Length <= max ? text : text[..max] + "…";
609
+ }
610
+ }