pi-revit 0.2.9 → 0.2.11

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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,47 @@ Format follows [Keep a Changelog](https://keepachangelog.com/); version headers
7
7
  Every published version gets an entry with **Added** / **Changed** / **Fixed** sections
8
8
  describing what the user will notice — not internal refactors.
9
9
 
10
+ ## [0.2.11] - 2026-07-22
11
+
12
+ ### Fixed
13
+ - `search_api_docs`: signature queries now accept .NET type names and qualified names —
14
+ `Wall.Create(Document, Curve, ElementId, Boolean` and `...(System.String` match the
15
+ rendered `bool` / `string`, and `(Autodesk.Revit.DB.Document` matches `Document`.
16
+ Parameter types in the query are reduced exactly the way the index renders them
17
+ (namespace stripped, CLR name → C# keyword, case-insensitive).
18
+ - `search_api_docs`: Creation-factory calls resolve — `Document.Create.NewRoom(Level, UV`
19
+ finds `Document.NewRoom`, and `Document.Create.NewFamilyInstance` finds the
20
+ `ItemFactoryBase` overloads (factory members documented on a base class). A note in the
21
+ result explains the rewrite. Applies only to the literal `Document.Create.` /
22
+ `Application.Create.` prefixes; ordinary members like `Wall.Create` are untouched, and
23
+ nonsense like `Document.Create.Banana` still honestly returns nothing.
24
+
25
+ Both were observed live: pi stumbled on these five times across two modeling sessions.
26
+ The benchmark gained six regression probes for them.
27
+
28
+ Requires redeploying the Revit add-in (`scripts\deploy.ps1` with Revit closed, then
29
+ restart Revit).
30
+
31
+ ## [0.2.10] - 2026-07-22
32
+
33
+ ### Fixed
34
+ - `search_api_docs`: constructor overloads can now be targeted with the natural C#
35
+ spelling — `FilteredElementCollector(Document` matches even though constructors are
36
+ rendered as `new FilteredElementCollector(Document)`. Previously only the `new `-prefixed
37
+ form matched (the same typography-cliff family as the 0.2.8 comma-spacing fix).
38
+
39
+ ### Added
40
+ - `scripts/benchmark-search-docs.py`: a reproducible ~630-query benchmark of
41
+ `search_api_docs` scored against Autodesk's own RevitAPI.xml — exact-name recall,
42
+ signature/spacing variants, hard syntax, namespace ambiguity, adversarial honesty
43
+ controls, documentation fidelity, and latency percentiles. Run it against any Revit
44
+ version with the bridge loaded. Measured on Revit 2025 at 0.2.9: 100% recall / 98.8%
45
+ top-1 on exact names, 100% spacing-variant agreement, 0 false positives across 99
46
+ adversarial mutations, 50/50 correct parameter docs, p50 8 ms.
47
+
48
+ Requires redeploying the Revit add-in (`scripts\deploy.ps1` with Revit closed, then
49
+ restart Revit).
50
+
10
51
  ## [0.2.9] - 2026-07-21
11
52
 
12
53
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-revit",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "description": "Native Pi connector for Autodesk Revit. Run npx.cmd -y pi-revit for the full Windows install.",
5
5
  "author": "Ahmad Altahlawi",
6
6
  "license": "MIT",
@@ -0,0 +1,313 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Reproducible benchmark of pi-revit search_api_docs against RevitAPI.xml ground truth.
3
+
4
+ Usage: python scripts/benchmark-search-docs.py [results.json] [path-to-RevitAPI.xml]
5
+ Requires: Revit running with the pi-revit bridge add-in loaded (a document need not
6
+ be open) and Python 3.8+. Ground truth is Autodesk's own RevitAPI.xml, so every
7
+ number is reproducible on any machine with the same Revit version.
8
+
9
+ Categories:
10
+ A exact-name recall/ranking B signature + spacing variants
11
+ C complex syntax (arrays, nested generics, ref/out, ctors)
12
+ D namespace ambiguity E honesty/adversarial controls
13
+ F documentation fidelity G latency stats
14
+ """
15
+ import glob, json, os, random, re, sys, time, urllib.request, xml.etree.ElementTree as ET
16
+ from collections import defaultdict
17
+
18
+ random.seed(20260722) # reproducible sampling
19
+
20
+ APPDATA = os.environ.get("APPDATA") or os.path.expanduser(r"~\AppData\Roaming")
21
+ BRIDGE = json.load(open(os.path.join(APPDATA, "RevitBridge", "bridge.json")))
22
+ BASE, TOKEN = BRIDGE["baseUrl"], BRIDGE["token"]
23
+ if len(sys.argv) > 2:
24
+ XML_PATH = sys.argv[2]
25
+ else:
26
+ revit_version = BRIDGE.get("revitVersion", "")
27
+ candidates = glob.glob(rf"C:\Program Files\Autodesk\Revit {revit_version}*\RevitAPI.xml") \
28
+ or glob.glob(r"C:\Program Files\Autodesk\Revit 20*\RevitAPI.xml")
29
+ if not candidates:
30
+ sys.exit("RevitAPI.xml not found; pass its path as the second argument.")
31
+ XML_PATH = candidates[-1]
32
+
33
+ LAT = []
34
+ def search(query, max_results=10):
35
+ body = json.dumps({"query": query, "max_results": max_results}).encode("utf-8")
36
+ url = f"{BASE}/tools/search_api_docs/execute?token={TOKEN}&timeout_ms=60000"
37
+ req = urllib.request.Request(url, data=body, headers={"content-type": "application/json"}, method="POST")
38
+ t0 = time.perf_counter()
39
+ with urllib.request.urlopen(req, timeout=90) as resp:
40
+ payload = json.load(resp)
41
+ LAT.append((time.perf_counter() - t0) * 1000)
42
+ det = (payload.get("details") or {}).get("payload") or {}
43
+ return det.get("matches") or [], det.get("totalMatches", 0)
44
+
45
+ # ---------------------------------------------------------------- ground truth
46
+ PRIM = {"System.String": "string", "System.Boolean": "bool", "System.Double": "double",
47
+ "System.Int32": "int", "System.Int64": "long", "System.Object": "object",
48
+ "System.Byte": "byte", "System.Single": "float", "System.UInt32": "uint"}
49
+
50
+ def split_top(params): # split on commas outside {}
51
+ out, depth, cur = [], 0, ""
52
+ for ch in params:
53
+ if ch == "{": depth += 1
54
+ elif ch == "}": depth -= 1
55
+ if ch == "," and depth == 0: out.append(cur); cur = ""
56
+ else: cur += ch
57
+ if cur: out.append(cur)
58
+ return out
59
+
60
+ def short_type(t):
61
+ t = t.strip()
62
+ byref = t.endswith("@")
63
+ if byref: t = t[:-1]
64
+ array = ""
65
+ while t.endswith("[]"): array += "[]"; t = t[:-2]
66
+ if t in PRIM: base = PRIM[t]
67
+ elif "{" in t:
68
+ outer, inner = t[:t.index("{")], t[t.index("{")+1:t.rindex("}")]
69
+ outer = outer.split(".")[-1].split("`")[0]
70
+ base = f"{outer}<{', '.join(short_type(x) for x in split_top(inner))}>"
71
+ else:
72
+ base = t.split(".")[-1]
73
+ return base + array + ("&" if byref else "")
74
+
75
+ class Member:
76
+ __slots__ = ("kind","full","typename","member","params","summary","pnames","since")
77
+ def __init__(s, kind, full, typename, member, params, summary, pnames, since):
78
+ s.kind, s.full, s.typename, s.member = kind, full, typename, member
79
+ s.params, s.summary, s.pnames, s.since = params, summary, pnames, since
80
+ @property
81
+ def short_sig_prefix(s):
82
+ shorts = [short_type(p) for p in s.params]
83
+ head = s.typename if s.member == "#ctor" else f"{s.typename}.{s.member}"
84
+ return f"{head}(" + ", ".join(shorts)
85
+
86
+ def text_of(el):
87
+ if el is None: return None
88
+ return re.sub(r"\s+", " ", "".join(el.itertext())).strip() or None
89
+
90
+ print("parsing RevitAPI.xml ...", flush=True)
91
+ root = ET.parse(XML_PATH).getroot()
92
+ methods, props, types = [], [], []
93
+ for m in root.iter("member"):
94
+ name = m.get("name", "")
95
+ summary = text_of(m.find("summary"))
96
+ since = text_of(m.find("since"))
97
+ pnames = [p.get("name") for p in m.findall("param")]
98
+ if name.startswith("M:"):
99
+ body = name[2:]
100
+ params = ""
101
+ if "(" in body:
102
+ body, params = body[:body.index("(")], body[body.index("(")+1:body.rindex(")")]
103
+ parts = body.split(".")
104
+ if len(parts) < 2: continue
105
+ member, typename = parts[-1], parts[-2]
106
+ if "`" in typename or "`" in member: continue
107
+ full = ".".join(parts)
108
+ methods.append(Member("M", full, typename, member, split_top(params) if params else [], summary, pnames, since))
109
+ elif name.startswith("P:"):
110
+ parts = name[2:].split(".")
111
+ if len(parts) < 2 or "`" in parts[-2]: continue
112
+ props.append(Member("P", name[2:], parts[-2], parts[-1], [], summary, pnames, since))
113
+ elif name.startswith("T:"):
114
+ parts = name[2:].split(".")
115
+ if "`" in parts[-1]: continue
116
+ types.append(Member("T", name[2:], parts[-1], "", [], summary, pnames, since))
117
+
118
+ print(f"golden pool: {len(methods)} methods, {len(props)} properties, {len(types)} types", flush=True)
119
+
120
+ by_ns = defaultdict(list)
121
+ for mm in methods: by_ns[".".join(mm.full.split(".")[:-2])].append(mm)
122
+ real_methods = {(m.typename, m.member) for m in methods}
123
+ overload_count = defaultdict(int)
124
+ for m in methods: overload_count[(m.typename, m.member)] += 1
125
+
126
+ def stratified(pool, n):
127
+ groups = defaultdict(list)
128
+ for m in pool: groups[".".join(m.full.split(".")[:4])].append(m)
129
+ out, keys = [], sorted(groups)
130
+ while len(out) < n and keys:
131
+ for k in list(keys):
132
+ if groups[k]: out.append(groups[k].pop(random.randrange(len(groups[k]))))
133
+ else: keys.remove(k)
134
+ if len(out) >= n: break
135
+ return out
136
+
137
+ R = {} # results
138
+
139
+ # ---------------------------------------------------------- A: exact-name recall
140
+ A = {"n":0,"top1":0,"top3":0,"recall":0}
141
+ sampleA = stratified(methods, 90) + stratified(props, 40) + stratified(types, 30)
142
+ for m in sampleA:
143
+ q = f"{m.typename}.{m.member}" if m.kind in "MP" else m.typename
144
+ matches, _ = search(q)
145
+ target = m.full if m.kind in "MP" else m.full
146
+ names = [x.get("name","") for x in matches]
147
+ A["n"] += 1
148
+ A["recall"] += any(n == target for n in names)
149
+ A["top1"] += bool(names and names[0] == target)
150
+ A["top3"] += any(n == target for n in names[:3])
151
+ print("A done", A, flush=True)
152
+ R["A exact-name"] = A
153
+
154
+ # ------------------------------------------------- B: signatures + spacing variants
155
+ B = {"n":0,"top1_sig":0,"variants_agree":0,"variant_sets":0}
156
+ sigpool = [m for m in methods if 2 <= len(m.params) <= 5]
157
+ sampleB = stratified(sigpool, 60)
158
+ for m in sampleB:
159
+ try: prefix = m.short_sig_prefix
160
+ except Exception: continue
161
+ canonical = prefix # "Type.Member(P1, P2..."
162
+ matches, _ = search(canonical)
163
+ B["n"] += 1
164
+ sigs = [x.get("signature","") for x in matches]
165
+ want = f"new {m.typename}(" if m.member == "#ctor" else f"{m.typename}.{m.member}("
166
+ ok = bool(sigs and sigs[0].startswith(want))
167
+ B["top1_sig"] += ok
168
+ variants = [canonical.replace(", ", ","),
169
+ canonical.replace(", ", " , "),
170
+ canonical.replace(", ", ", "),
171
+ canonical.replace("(", "( ", 1)]
172
+ counts = {len(matches)}
173
+ for v in variants:
174
+ vm, _ = search(v)
175
+ counts.add(len(vm))
176
+ B["variant_sets"] += 1
177
+ B["variants_agree"] += (len(counts) == 1)
178
+ print("B done", B, flush=True)
179
+ R["B signature+spacing"] = B
180
+
181
+ # --------------------------------------------------------- C: complex syntax shapes
182
+ C = {"cases": []}
183
+ def probe(label, q, expect_nonzero=True):
184
+ try: matches, total = search(q)
185
+ except Exception as e:
186
+ C["cases"].append((label, q, f"ERROR {e}")); return
187
+ ok = (total > 0) if expect_nonzero else (total == 0)
188
+ top = matches[0]["signature"] if matches else "-"
189
+ C["cases"].append((label, q, f"{'OK' if ok else 'MISS'} total={total} top={top[:70]}"))
190
+
191
+ arr = next((m for m in methods if any(p.endswith("[]") for p in m.params)), None)
192
+ if arr: probe("array param", arr.short_sig_prefix)
193
+ nest = next((m for m in methods if any("{" in p and "{" in p[p.index("{")+1:] for p in m.params)), None)
194
+ if nest: probe("nested generic", nest.short_sig_prefix)
195
+ byref = next((m for m in methods if any(p.endswith("@") for p in m.params)), None)
196
+ if byref: probe("ref/out param", byref.short_sig_prefix)
197
+ ctor = next((m for m in methods if m.member == "#ctor" and m.typename == "FilteredElementCollector"), None)
198
+ probe("constructor", "FilteredElementCollector(Document")
199
+ probe("property", "PDFExportOptions.FileName")
200
+ probe("fully qualified type", "Autodesk.Revit.DB.Document")
201
+ probe("case-insensitive", "wall.create(document, curve")
202
+ probe("Double vs double", "UnitUtils.ConvertToInternalUnits(Double, ForgeTypeId")
203
+ probe("trailing garbage brackets", "Wall.Create(Document, Curve]]", True) # observational
204
+ probe("double periods", "Wall..Create", True) # observational
205
+ # 0.2.11: CLR alias + namespace reduction in signature queries, Creation-factory rewrite
206
+ probe("CLR alias Boolean", "Wall.Create(Document, Curve, ElementId, Boolean")
207
+ probe("qualified param type", "Wall.Create(Autodesk.Revit.DB.Document, Curve")
208
+ probe("System-qualified alias", "ParameterFilterElement.Create(Document, System.String")
209
+ probe("factory rewrite", "Document.Create.NewRoom(Level, UV")
210
+ probe("factory inherited member", "Document.Create.NewFamilyInstance")
211
+ probe("factory honesty control", "Document.Create.Banana", expect_nonzero=False)
212
+ print("C done", flush=True)
213
+ R["C complex-syntax"] = C
214
+
215
+ # ------------------------------------------------------------ D: namespace ambiguity
216
+ D = {"cases": []}
217
+ def ns_case(q, want_substr):
218
+ matches, total = search(q)
219
+ top = matches[0]["name"] if matches else "-"
220
+ D["cases"].append((q, want_substr, top, "OK" if want_substr in top else "MISS"))
221
+ for q, want in [
222
+ ("Autodesk.Revit.DB.Architecture.Room", ".Architecture.Room"),
223
+ ("Autodesk.Revit.DB.Mechanical.Space", ".Mechanical.Space"),
224
+ ("Architecture.Room", ".Architecture.Room"),
225
+ ("Mechanical.Duct", ".Mechanical.Duct"),
226
+ ("Plumbing.Pipe", ".Plumbing.Pipe"),
227
+ ("Electrical.Wire", ".Electrical.Wire"),
228
+ ("Structure.Rebar", ".Structure.Rebar"),
229
+ ("Room", "Room"), ("Space", "Space"), ("Area", "Area"),
230
+ ("Connector", "Connector"),
231
+ ]: ns_case(q, want)
232
+ print("D done", flush=True)
233
+ R["D namespace"] = D
234
+
235
+ # ------------------------------------------------------- E: honesty / adversarial
236
+ E = {"n":0,"exact_fp":0,"zero":0,"kinds":defaultdict(lambda:[0,0,0])}
237
+ adv = []
238
+ pool = stratified([m for m in methods if 1 <= len(m.params) <= 4 and m.member != "#ctor"], 120)
239
+ for m in pool[:30]: # misspelled member (drop 2nd letter)
240
+ if len(m.member) < 5: continue
241
+ adv.append(("misspelled-member", f"{m.typename}.{m.member[0]+m.member[2:]}", m))
242
+ for m in pool[30:60]: # nonsense parameter
243
+ adv.append(("nonsense-param", f"{m.typename}.{m.member}(Banana", m))
244
+ for m in pool[60:90]: # wrong declaring class
245
+ other = random.choice(types).typename
246
+ if (other, m.member) in real_methods: continue
247
+ adv.append(("wrong-class", f"{other}.{m.member}", (other, m.member)))
248
+ for m in pool[90:120]: # swapped params (only if that order is not a real overload)
249
+ if len(m.params) < 2 or m.params[0] == m.params[1]: continue
250
+ sw = [m.params[1], m.params[0]] + m.params[2:]
251
+ exists = any(o.params[:len(sw)] == sw for o in methods if (o.typename,o.member)==(m.typename,m.member))
252
+ if exists: continue
253
+ shorts = [short_type(p) for p in sw]
254
+ adv.append(("swapped-params", f"{m.typename}.{m.member}(" + ", ".join(shorts), m))
255
+ for kind, q, _meta in adv:
256
+ matches, total = search(q)
257
+ E["n"] += 1
258
+ zero = (total == 0)
259
+ # exact false positive: a returned entry CLAIMS the impossible thing exists
260
+ fp = False
261
+ if kind == "wrong-class":
262
+ t, mem = _meta
263
+ fp = any(x.get("name","").endswith(f".{t}.{mem}") or f".{t}." in x.get("name","") and x.get("name","").endswith(f".{mem}") for x in matches)
264
+ elif kind == "swapped-params":
265
+ fp = any(x.get("signature","").lower() == (q.lower()+")") for x in matches)
266
+ elif kind == "nonsense-param":
267
+ fp = any("banana" in x.get("signature","").lower() for x in matches)
268
+ elif kind == "misspelled-member":
269
+ bad = q.split(".")[-1].lower()
270
+ fp = any(x.get("name","").lower().endswith("."+bad) for x in matches)
271
+ E["exact_fp"] += fp
272
+ E["zero"] += zero
273
+ k = E["kinds"][kind]; k[0] += 1; k[1] += fp; k[2] += zero
274
+ print("E done", {k: list(v) for k, v in E["kinds"].items()}, flush=True)
275
+ E["kinds"] = {k: list(v) for k, v in E["kinds"].items()}
276
+ R["E honesty"] = E
277
+
278
+ # ------------------------------------------------------------- F: doc fidelity
279
+ F = {"n":0,"param_names_ok":0,"summary_ok":0,"since_ok":0,"since_present":0,"overload_docs_ok":0,"overload_checked":0}
280
+ fsample = stratified([m for m in methods if m.pnames and m.summary and m.member != "#ctor"], 50)
281
+ for m in fsample:
282
+ try: q = m.short_sig_prefix
283
+ except Exception: continue
284
+ matches, _ = search(q)
285
+ if not matches: continue
286
+ top = matches[0]
287
+ F["n"] += 1
288
+ got_pnames = [p.get("name") for p in (top.get("parameters") or [])]
289
+ F["param_names_ok"] += (got_pnames == m.pnames)
290
+ gs, ws = (top.get("summary") or ""), m.summary
291
+ F["summary_ok"] += (re.sub(r"\W","",gs)[:80] == re.sub(r"\W","",ws)[:80])
292
+ if m.since:
293
+ F["since_present"] += 1
294
+ F["since_ok"] += (top.get("since") == m.since)
295
+ # overload-doc attribution: right param COUNT on the top signature
296
+ if overload_count[(m.typename, m.member)] > 1:
297
+ F["overload_checked"] += 1
298
+ sig = top.get("signature","")
299
+ inner = sig[sig.index("(")+1:sig.rindex(")")] if "(" in sig else ""
300
+ F["overload_docs_ok"] += (len(split_top(inner)) == len(m.params) if inner else len(m.params)==0)
301
+ print("F done", F, flush=True)
302
+ R["F fidelity"] = F
303
+
304
+ # ------------------------------------------------------------------ G: latency
305
+ LAT.sort()
306
+ def pct(p): return LAT[min(len(LAT)-1, int(len(LAT)*p))]
307
+ R["G latency"] = {"queries": len(LAT), "cold_first_ms": round(LAT[-1] if not LAT else None or 0),
308
+ "p50_ms": round(pct(0.50)), "p95_ms": round(pct(0.95)), "p99_ms": round(pct(0.99)),
309
+ "max_ms": round(LAT[-1])}
310
+
311
+ json.dump(R, open(sys.argv[1] if len(sys.argv)>1 else "benchmark-results.json","w"), indent=1, default=str)
312
+ print("ALL DONE", flush=True)
313
+ print(json.dumps(R, indent=1, default=str)[:3000])
@@ -81,7 +81,7 @@ namespace RevitBridge.Tools
81
81
  if (index.Members.Count == 0)
82
82
  throw new InvalidOperationException("The Revit API documentation index is empty. " + string.Join(" ", index.Warnings));
83
83
 
84
- var (top, total) = Search(index, query, kindFilter, maxResults);
84
+ var (top, total, rewriteNote) = Search(index, query, kindFilter, maxResults);
85
85
 
86
86
  var matches = new List<Dictionary<string, object?>>(top.Count);
87
87
  for (int i = 0; i < top.Count; i++)
@@ -116,22 +116,56 @@ namespace RevitBridge.Tools
116
116
  indexedMembers = index.Members.Count,
117
117
  sources = index.SourceFiles,
118
118
  warnings = index.Warnings.Count > 0 ? index.Warnings : null,
119
- }, BuildMarkdown(query, top, total, index));
119
+ }, BuildMarkdown(query, top, total, index) + (rewriteNote is null ? string.Empty : $"\nNote: {rewriteNote}"));
120
120
  }
121
121
 
122
122
  // ----------------------------------------------------------------- search
123
123
 
124
124
  private static readonly char[] WordSeparators = { '.', ' ', '_', '(', ')', ',', ':', '-', '/' };
125
125
 
126
- private static (List<ApiMember> Top, int Total) Search(DocIndex index, string query, char? kindFilter, int maxResults)
126
+ private static (List<ApiMember> Top, int Total, string? RewriteNote) Search(DocIndex index, string query, char? kindFilter, int maxResults)
127
127
  {
128
128
  // Signatures are rendered exactly one way ("Name(Type, Type)"): normalize the
129
129
  // query's spacing around commas and parentheses so 'Wall.Create(Document,Curve'
130
130
  // and 'Wall.Create( Document, Curve' hit the same rendered text instead of
131
- // failing on typography.
132
- string q = query.ToLowerInvariant();
131
+ // failing on typography. Parameter types are additionally reduced the same way
132
+ // the renderer reduces them (namespaces stripped, CLR names -> C# keywords), so
133
+ // 'Wall.Create(Document, Curve, ElementId, Boolean' and
134
+ // '...(System.String' match the rendered 'bool' / 'string'.
135
+ string q = NormalizeSignatureQuery(query).ToLowerInvariant();
133
136
  q = Regex.Replace(q, @"\s*,\s*", ", ");
134
137
  q = Regex.Replace(q, @"\(\s+", "(");
138
+
139
+ // The Creation-factory pattern: code says doc.Create.NewRoom(...) but the docs
140
+ // live on Autodesk.Revit.Creation.Document (rendered 'Document.NewRoom') or a
141
+ // base class like ItemFactoryBase. Try the factory rewrite, then the bare
142
+ // member, before giving up. Deterministic rewrites of an exact idiom — never
143
+ // applied unless the literal 'document.create.' / 'application.create.' prefix
144
+ // is present, so ordinary names like Wall.Create are untouched.
145
+ var candidates = new List<(string Query, string? Note)> { (q, null) };
146
+ foreach (string factory in new[] { "document.create.", "application.create." })
147
+ {
148
+ if (!q.StartsWith(factory, StringComparison.Ordinal))
149
+ continue;
150
+ string owner = factory[..(factory.IndexOf('.') + 1)]; // "document."
151
+ string rest = q[factory.Length..];
152
+ candidates.Add((owner + rest, $"'{owner}Create.*' is the Creation factory — matched as '{owner}{rest}'."));
153
+ string bareMember = rest.Split('(')[0];
154
+ if (bareMember.Length > 0)
155
+ 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}'."));
156
+ }
157
+
158
+ foreach (var (candidate, note) in candidates)
159
+ {
160
+ var (top, total) = RunScoring(index, candidate, kindFilter, maxResults);
161
+ if (total > 0)
162
+ return (top, total, note);
163
+ }
164
+ return (new List<ApiMember>(), 0, null);
165
+ }
166
+
167
+ private static (List<ApiMember> Top, int Total) RunScoring(DocIndex index, string q, char? kindFilter, int maxResults)
168
+ {
135
169
  string[] words = q.Split(WordSeparators, StringSplitOptions.RemoveEmptyEntries);
136
170
 
137
171
  var scored = new List<(ApiMember Member, int Score)>();
@@ -155,6 +189,65 @@ namespace RevitBridge.Tools
155
189
  return (top, scored.Count);
156
190
  }
157
191
 
192
+ /// <summary>Reduces the parameter part of a signature query exactly the way the
193
+ /// renderer reduces signatures: each identifier keeps only its last dot-segment
194
+ /// and CLR primitive names map to C# keywords (case-insensitively — queries say
195
+ /// 'Boolean' or 'boolean'; signatures render 'bool'). The member path before the
196
+ /// first '(' is left untouched.</summary>
197
+ private static string NormalizeSignatureQuery(string query)
198
+ {
199
+ int paren = query.IndexOf('(');
200
+ if (paren < 0)
201
+ return query;
202
+
203
+ string tail = query[(paren + 1)..];
204
+ var result = new StringBuilder(tail.Length);
205
+ int i = 0;
206
+ while (i < tail.Length)
207
+ {
208
+ char c = tail[i];
209
+ if (char.IsLetter(c) || c == '_')
210
+ {
211
+ int start = i, lastDot = -1;
212
+ while (i < tail.Length && (char.IsLetterOrDigit(tail[i]) || tail[i] is '_' or '.'))
213
+ {
214
+ if (tail[i] == '.')
215
+ lastDot = i;
216
+ i++;
217
+ }
218
+ string identifier = tail[(lastDot >= 0 ? lastDot + 1 : start)..i];
219
+ result.Append(QueryTypeAliases.TryGetValue(identifier, out string? keyword) ? keyword : identifier);
220
+ }
221
+ else
222
+ {
223
+ result.Append(c);
224
+ i++;
225
+ }
226
+ }
227
+ return query[..(paren + 1)] + result;
228
+ }
229
+
230
+ /// <summary>Case-insensitive inverse of MapTypeKeyword for query text.</summary>
231
+ private static readonly Dictionary<string, string> QueryTypeAliases = new(StringComparer.OrdinalIgnoreCase)
232
+ {
233
+ ["String"] = "string",
234
+ ["Boolean"] = "bool",
235
+ ["Int32"] = "int",
236
+ ["Int64"] = "long",
237
+ ["Int16"] = "short",
238
+ ["Double"] = "double",
239
+ ["Single"] = "float",
240
+ ["Object"] = "object",
241
+ ["Void"] = "void",
242
+ ["Byte"] = "byte",
243
+ ["SByte"] = "sbyte",
244
+ ["Char"] = "char",
245
+ ["Decimal"] = "decimal",
246
+ ["UInt16"] = "ushort",
247
+ ["UInt32"] = "uint",
248
+ ["UInt64"] = "ulong",
249
+ };
250
+
158
251
  private static int Score(ApiMember member, string q, string[] words)
159
252
  {
160
253
  // A query with a parenthesis targets a specific overload by signature,
@@ -165,7 +258,11 @@ namespace RevitBridge.Tools
165
258
  int score;
166
259
  if (member.CompositeLower == q) score = 1000;
167
260
  else if (signatureQuery && member.SignatureLower == q) score = 980;
261
+ // Constructors render as "new Type(...)": accept the natural C# spelling
262
+ // 'FilteredElementCollector(Document' without requiring the 'new' prefix.
263
+ else if (signatureQuery && member.SignatureLower == "new " + q) score = 975;
168
264
  else if (signatureQuery && member.SignatureLower.StartsWith(q, StringComparison.Ordinal)) score = 950;
265
+ else if (signatureQuery && member.SignatureLower.StartsWith("new " + q, StringComparison.Ordinal)) score = 945;
169
266
  else if (member.ShortNameLower == q) score = 900;
170
267
  else if (member.CompositeLower.StartsWith(q, StringComparison.Ordinal)) score = 700;
171
268
  else if (member.ShortNameLower.StartsWith(q, StringComparison.Ordinal)) score = 650;