cppmanlite 0.1.6__tar.gz → 0.1.7__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cppmanlite
3
- Version: 0.1.6
3
+ Version: 0.1.7
4
4
  Summary: Lightweight serverless C++ documentation lookup — pure Python, no C deps
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://dive4dec.github.io/cppmanlite/
@@ -11,6 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  import html
13
13
  import json
14
+ import math
14
15
  import re
15
16
  import textwrap
16
17
  from pathlib import Path
@@ -101,13 +102,127 @@ def _fetch_url_sync(url: str) -> str:
101
102
  # --------------------------------------------------------------------------- #
102
103
  # Search
103
104
  # --------------------------------------------------------------------------- #
105
+ #
106
+ # Deterministic scorer that mirrors site/app.js exactly (same tokenisation,
107
+ # alias table, field weights, AND penalty and sort). Replacing the old naive
108
+ # substring scorer so the Python package and the static site rank identically.
109
+ # Why not BM25/lunr here? There is no lunr dependency in the package (it must
110
+ # stay pure-stdlib / Pyodide-safe), and a hand-rolled scorer puts exact-title
111
+ # hits first in a fully predictable way.
112
+
113
+ _SEARCH_TOKEN_RE = re.compile(r"[a-z_][a-z0-9_]*|[0-9]+")
114
+
115
+ # cppreference-style aliases: map a common name to the canonical identifier
116
+ # used in page titles. (std::string == std::basic_string, ...)
117
+ _SEARCH_ALIAS = {
118
+ "string": "basic_string", "wstring": "basic_wstring",
119
+ "u8string": "basic_u8string", "u16string": "basic_u16string",
120
+ "u32string": "basic_u32string", "str": "basic_string", "wstr": "basic_wstring",
121
+ }
122
+
123
+ # field weights: (whole-token match, substring-inside-larger-token)
124
+ _W = {"title": (100, 40), "terms": (80, 32), "url": (40, 16), "snippet": (8, 3)}
125
+
126
+
127
+ def _tok_set(s: str) -> set:
128
+ return set(_SEARCH_TOKEN_RE.findall(s.lower()))
129
+
130
+
131
+ def _strip_ns(s: str) -> str:
132
+ return re.sub(r"^(std::)+", "", s)
133
+
134
+
135
+ def _strip_args(s: str) -> str:
136
+ return re.sub(r"\(.*\)\s*$", "", s).strip()
137
+
138
+
139
+ def _alias_expand(s: str) -> str:
140
+ parts = [p for p in re.split(r"[\s_]+", s) if p]
141
+ return "_".join(_SEARCH_ALIAS.get(p, p) for p in parts)
142
+
143
+
144
+ def _match_tok(tok: str, s_low: str, tokset: set, full: int, sub: int) -> int:
145
+ if tok in tokset:
146
+ return full
147
+ if tok in s_low:
148
+ return sub
149
+ return 0
150
+
151
+
152
+ def _term_score(tok: str, term_freq: dict, terms_low: str) -> float:
153
+ # base "terms" weight, scaled up (log) by how often the identifier appears
154
+ # in the page body → the defining page outranks a page that just mentions it.
155
+ n = term_freq.get(tok, 0)
156
+ if n:
157
+ return _W["terms"][0] * (1 + math.log2(n))
158
+ if tok in terms_low:
159
+ return _W["terms"][1]
160
+ return 0
161
+
162
+
163
+ def _doc_score(meta: dict, search_toks: list, whole_cands: list) -> float:
164
+ score = 0
165
+ matched = 0
166
+ # Whole-title relations: EXACT always; PREFIX only for specific/compound queries.
167
+ for wc in whole_cands:
168
+ if not wc or len(wc) < 2:
169
+ continue
170
+ d_title = _strip_ns(meta["t_low"])
171
+ if d_title == wc:
172
+ score += 1000
173
+ else:
174
+ specific = len(search_toks) >= 2 or "_" in wc or len(wc) >= 8
175
+ if specific and d_title.startswith(wc + " "):
176
+ score += 400
177
+ for tok in search_toks:
178
+ s = max(
179
+ _match_tok(tok, meta["t_low"], meta["t_tok"], _W["title"][0], _W["title"][1]),
180
+ _term_score(tok, meta["term_freq"], meta["terms_low"]),
181
+ _match_tok(tok, meta["u_low"], meta["u_tok"], _W["url"][0], _W["url"][1]),
182
+ _match_tok(tok, meta["s_low"], meta["s_tok"], _W["snippet"][0], _W["snippet"][1]),
183
+ )
184
+ if s > 0:
185
+ score += s
186
+ matched += 1
187
+ # AND semantics: penalise if not every query token matched somewhere.
188
+ if len(search_toks) > 1 and matched < len(search_toks):
189
+ score *= matched / len(search_toks)
190
+ return score
191
+
192
+
193
+ def _build_meta(entry: dict) -> dict:
194
+ t = entry.get("title", "")
195
+ u = entry.get("url", "")
196
+ s = entry.get("snippet", "")
197
+ # terms is a {identifier: count} map; tolerate an older list format.
198
+ terms = entry.get("terms") or {}
199
+ if isinstance(terms, list):
200
+ term_freq = {t: 1 for t in terms}
201
+ else:
202
+ term_freq = terms
203
+ t_low = t.lower()
204
+ return {
205
+ "t_low": t_low,
206
+ "u_low": u.lower(),
207
+ "s_low": s.lower(),
208
+ "t_tok": _tok_set(t),
209
+ "u_tok": _tok_set(u),
210
+ "s_tok": _tok_set(s),
211
+ "term_freq": term_freq,
212
+ "terms_low": " ".join(term_freq.keys()),
213
+ "title_len": len(t_low),
214
+ }
104
215
 
105
216
 
106
217
  def search(query: str, limit: int = 20) -> list[dict[str, str]]:
107
218
  """Search C++ documentation pages.
108
219
 
220
+ Deterministic scorer (mirrors the static site): exact-title matches rank
221
+ first, then alias titles, then identifiers found in a page's body (e.g.
222
+ ``int64_t`` → Fixed width integer types), then URL/snippet substring hits.
223
+
109
224
  Args:
110
- query: Search term (e.g. "vector", "std::sort", "shared_ptr").
225
+ query: Search term (e.g. "vector", "std::max", "int64_t", "shared_ptr").
111
226
  limit: Maximum number of results.
112
227
 
113
228
  Returns:
@@ -116,27 +231,40 @@ def search(query: str, limit: int = 20) -> list[dict[str, str]]:
116
231
  idx = _load_index()
117
232
  if not idx:
118
233
  return []
119
- q = query.lower().strip()
120
- # Normalise std:: prefix
121
- q_norm = re.sub(r"^std::", "", q)
122
- results = []
234
+ q_norm = query.lower().strip()
235
+ if not q_norm:
236
+ return []
237
+
238
+ q_toks = _SEARCH_TOKEN_RE.findall(q_norm)
239
+ # drop a leading "std" namespace token (std::vector → ["std","vector"])
240
+ if q_toks and q_toks[0] == "std" and q_norm.startswith("std::"):
241
+ q_toks = q_toks[1:]
242
+ if not q_toks:
243
+ return []
244
+
245
+ # expand each token through the alias table, dedup, preserve order
246
+ seen = set()
247
+ search_toks = []
248
+ for t in q_toks:
249
+ for cand in (t, _SEARCH_ALIAS.get(t, "")):
250
+ if cand and cand not in seen:
251
+ seen.add(cand)
252
+ search_toks.append(cand)
253
+
254
+ q_title_whole = _strip_args(_strip_ns(q_norm))
255
+ whole_cands = []
256
+ for c in (q_title_whole, _alias_expand(q_title_whole)):
257
+ if len(c) >= 2 and c not in whole_cands:
258
+ whole_cands.append(c)
259
+
260
+ scored = []
123
261
  for entry in idx:
124
- title = entry.get("title", "").lower()
125
- url = entry.get("url", "").lower()
126
- # Score: exact match > starts with > contains in title > contains in URL
127
- score = 0
128
- if title == q or title == q_norm:
129
- score = 100
130
- elif title.startswith(q) or title.startswith(q_norm):
131
- score = 80
132
- elif q in title or q_norm in title:
133
- score = 60
134
- elif q in url or q_norm in url:
135
- score = 40
262
+ score = _doc_score(_build_meta(entry), search_toks, whole_cands)
136
263
  if score > 0:
137
- results.append({**entry, "_score": score})
138
- results.sort(key=lambda x: (-x["_score"], x.get("title", "")))
139
- return [{k: v for k, v in r.items() if k != "_score"} for r in results[:limit]]
264
+ scored.append((score, entry))
265
+
266
+ scored.sort(key=lambda x: (-x[0], len(x[1].get("title", "").lower()), x[1].get("url", "")))
267
+ return [entry for _, entry in scored[:limit]]
140
268
 
141
269
 
142
270
  def list_pages(limit: int = 0) -> list[dict[str, str]]: