nx_search_query_parser.nx source
↩ module page · 542 lines · 22796 B
1// nx_search_query_parser.nx -- HUB primitive; HTTP query string -> NxSearchQuery.
2//
3// COMPOSES (per NISHI_SMALL_SHARP_COMPOSABLE_STANDARD §4.1 M7):
4// nx_search_inverted (nx_inv_is_token_char + nx_inv_hash_bytes_lower
5// token-character convention reuse)
6// nx_hygiene_prims (NxOptPtr; safe-arith; bounded-loop budget)
7//
8// COMPOSED BY:
9// wiki/nx_wiki_search_handler.nx (next commit; wiki wiring)
10// (future) obd-config-site search handler
11// (future) sprinkler-config-site search handler
12// any site that needs to parse search query strings
13//
14// Status: V1 SEED. 2026-05-27. SITE-AGNOSTIC HUB PRIMITIVE per
15// NISHI_SMALL_SHARP_COMPOSABLE_STANDARD §4.1 M3.
16//
17// WINNER-TIER: BASELINE-C provisional
18// INCUMBENTS: Elasticsearch's QueryParser (Lucene), Whoosh
19// (Python), Tantivy (Rust), Bleve (Go), Algolia's
20// query parser, Meilisearch's query parser
21// NUMBERS: V1 ships query parsing (URL-decode + tokenize +
22// field extraction); paired latency bench vs
23// Lucene QueryParser pending real query workload
24// GAP: Lucene QueryParser handles boolean operators
25// (AND/OR/NOT), phrase quotes, fuzzy ~, boost ^,
26// proximity ~N -- V1 ships JUST term-list parsing
27// (implicit AND); V2 adds the operators
28// PLAN: M-next: V2 boolean operators + phrase quotes;
29// paired latency bench vs Lucene on identical
30// workload
31// EXEMPTION REASON: n/a; provisional pending measurement
32//
33// V1 SCOPE per NISHI_SEARCH_CHARTER.md §6:
34// - URL-decode query string (% escapes + plus-is-space)
35// - Extract q= terms (implicit AND; V1)
36// - Extract scope= (ONSITE default / OFFSITE / BOTH; sealed)
37// - Extract n= max results
38// - Tokenize per nx_search_inverted conventions
39// (lowercase + nx_inv_is_token_char + length 2..64)
40// - Sealed NxSearchQuery emit (caller-allocated buffers)
41//
42// V2 SCOPE (TODO):
43// - Boolean operators: foo AND bar; foo OR bar; foo NOT bar
44// - Phrase quotes: "exact phrase"
45// - Field-scoped: title:foo, tags:bar
46// - Boost: term^2.5
47// - Fuzzy: term~2
48
49import "nx_syscalls.nx"
50import "nx_search_inverted.nx"
51import "nx_hygiene_prims.nx"
52
53// ===== Sealed verdict surface (codes 1500-1509) =================================================
54const NX_SQP_OK: i64 = 0
55const NX_SQP_BAD_INPUT: i64 = 1500
56const NX_SQP_QUERY_TOO_LONG: i64 = 1501
57const NX_SQP_TOO_MANY_TERMS: i64 = 1502
58const NX_SQP_TERM_TOO_LONG: i64 = 1503
59const NX_SQP_TERMS_BUF_OVERFLOW: i64 = 1504
60const NX_SQP_BAD_URL_ESCAPE: i64 = 1505
61const NX_SQP_BAD_SCOPE: i64 = 1506
62const NX_SQP_LOOP_BUDGET: i64 = 1507
63const NX_SQP_NOT_IMPLEMENTED: i64 = 1508
64
65// ===== Sealed scope enum (per NISHI_SEARCH_CHARTER §9) =================================================
66const NX_SEARCH_SCOPE_ONSITE: i64 = 0 // default; local docs only
67const NX_SEARCH_SCOPE_OFFSITE: i64 = 1 // crawled offsite content only (V3 ships engine)
68const NX_SEARCH_SCOPE_BOTH: i64 = 2 // federated; merged
69const NX_SEARCH_SCOPE_N: i64 = 3
70
71func nx_search_scope_is_valid(s: i64) -> i64 {
72 if s < 0 { return 0 }
73 if s >= NX_SEARCH_SCOPE_N { return 0 }
74 return 1
75}
76
77// ===== Named sizing constants (M7 no magic numbers) =================================================
78const NX_SQP_MAX_QUERY_LEN: i64 = 2048 // total query-string bytes
79const NX_SQP_MAX_TERMS: i64 = 32 // hard cap per query (per NX_INV_MAX_POSTINGS_PER hint)
80const NX_SQP_MAX_TERM_LEN: i64 = 64 // per NX_INV_MAX_TOKEN_LEN
81const NX_SQP_MIN_TERM_LEN: i64 = 2 // per NX_INV_MIN_TOKEN_LEN
82const NX_SQP_TERMS_BUF_MIN_CAP: i64 = 1024 // 32 terms * 32 avg bytes
83const NX_SQP_DEFAULT_MAX_RESULTS: i64 = 20
84const NX_SQP_HARD_MAX_RESULTS: i64 = 1000
85const NX_SQP_LOOP_BUDGET: i64 = 100000 // per-parse iteration cap
86
87// ASCII byte literals (M7).
88const NX_SQP_ASCII_PLUS: i64 = 0x2B // '+'
89const NX_SQP_ASCII_PERCENT: i64 = 0x25 // '%'
90const NX_SQP_ASCII_AMP: i64 = 0x26 // '&'
91const NX_SQP_ASCII_EQ: i64 = 0x3D // '='
92const NX_SQP_ASCII_SP: i64 = 0x20 // ' '
93
94// Sealed param-name literals.
95const NX_SQP_PARAM_Q: *u8 = "q" as *u8
96const NX_SQP_PARAM_Q_N: i64 = 1
97const NX_SQP_PARAM_SCOPE: *u8 = "scope" as *u8
98const NX_SQP_PARAM_SCOPE_N: i64 = 5
99const NX_SQP_PARAM_N: *u8 = "n" as *u8
100const NX_SQP_PARAM_N_N: i64 = 1
101
102// Sealed scope-value literals.
103const NX_SQP_SCOPE_VAL_ONSITE: *u8 = "onsite" as *u8
104const NX_SQP_SCOPE_VAL_ONSITE_N: i64 = 6
105const NX_SQP_SCOPE_VAL_OFFSITE: *u8 = "offsite" as *u8
106const NX_SQP_SCOPE_VAL_OFFSITE_N: i64 = 7
107const NX_SQP_SCOPE_VAL_BOTH: *u8 = "both" as *u8
108const NX_SQP_SCOPE_VAL_BOTH_N: i64 = 4
109
110// ===== NxSearchQuery struct (caller-allocated per M4) =================================================
111//
112// Terms are packed: per term [u8 len_byte][len bytes utf-8].
113// Iterate via nx_search_query_term_at.
114
115struct NxSearchQuery {
116 terms_buf: *u8 // caller-allocated; cap NX_SQP_TERMS_BUF_MIN_CAP+
117 terms_cap: i64
118 terms_used: i64 // bytes filled
119 terms_count: i64 // # of terms
120 scope: i64 // sealed enum value
121 max_results: i64
122 valid: i64
123 // V2: per-term kind (sealed enum SHOULD/MUST/MUST_NOT). Allocated
124 // by _init via sys_mmap (NX_SQP_MAX_TERMS bytes).
125 term_kinds: *u8
126}
127
128// Sealed enum for boolean operator kind per term (V2).
129const NX_SQP_TERM_KIND_SHOULD: i64 = 0 // bare term (V1 default; preserved)
130const NX_SQP_TERM_KIND_MUST: i64 = 1 // "+term" -- doc must contain
131const NX_SQP_TERM_KIND_MUST_NOT: i64 = 2 // "-term" -- doc must NOT contain
132const NX_SQP_TERM_KIND_N: i64 = 3
133
134// Sealed-init: stamps every field; refuses half-state.
135func nx_search_query_init(q: *NxSearchQuery,
136 terms_buf: *u8, terms_cap: i64) -> i64 {
137 if (q as i64) == 0 { return 0 - NX_SQP_BAD_INPUT }
138 if (terms_buf as i64) == 0 { return 0 - NX_SQP_BAD_INPUT }
139 if terms_cap < NX_SQP_TERMS_BUF_MIN_CAP { return 0 - NX_SQP_BAD_INPUT }
140 q.terms_buf = terms_buf
141 q.terms_cap = terms_cap
142 q.terms_used = 0
143 q.terms_count = 0
144 q.scope = NX_SEARCH_SCOPE_ONSITE // default per charter §5.1 M2
145 q.max_results = NX_SQP_DEFAULT_MAX_RESULTS
146 // V2: per-term kind array.
147 q.term_kinds = sys_mmap(NX_SQP_MAX_TERMS)
148 q.valid = 1
149 return NX_SQP_OK
150}
151
152// ===== URL-decode helpers (bounded; M3 + M5) =================================================
153//
154// Decodes one byte; returns:
155// >= 0 -> the decoded byte value (0..255) via out-param;
156// returns bytes_consumed in source (1, 2, or 3)
157// < 0 -> verdict
158
159// Hex char to int 0..15; -1 if not hex.
160func nx_sqp_hex_val(c: i64) -> i64 {
161 if c >= 0x30 { if c <= 0x39 { return c - 0x30 } } // '0'..'9'
162 if c >= 0x41 { if c <= 0x46 { return c - 0x37 } } // 'A'..'F'
163 if c >= 0x61 { if c <= 0x66 { return c - 0x57 } } // 'a'..'f'
164 return 0 - 1
165}
166
167// Read one source byte (URL-decoded). Returns bytes-consumed via
168// caller's idx; decoded byte via out-param.
169func nx_sqp_decode_one(src: *u8, src_n: i64, idx: i64,
170 out_byte: *i64, out_consumed: *i64) -> i64 {
171 if (out_byte as i64) == 0 { return 0 - NX_SQP_BAD_INPUT }
172 if (out_consumed as i64) == 0 { return 0 - NX_SQP_BAD_INPUT }
173 if idx < 0 { return 0 - NX_SQP_BAD_INPUT }
174 if idx >= src_n { return 0 - NX_SQP_BAD_INPUT }
175
176 let c: i64 = src[idx] as i64
177 if c == NX_SQP_ASCII_PLUS {
178 out_byte[0] = NX_SQP_ASCII_SP
179 out_consumed[0] = 1
180 return NX_SQP_OK
181 }
182 if c == NX_SQP_ASCII_PERCENT {
183 if idx + 2 >= src_n { return 0 - NX_SQP_BAD_URL_ESCAPE }
184 let h: i64 = nx_sqp_hex_val(src[idx + 1] as i64)
185 let l: i64 = nx_sqp_hex_val(src[idx + 2] as i64)
186 if h < 0 { return 0 - NX_SQP_BAD_URL_ESCAPE }
187 if l < 0 { return 0 - NX_SQP_BAD_URL_ESCAPE }
188 out_byte[0] = (h << 4) | l
189 out_consumed[0] = 3
190 return NX_SQP_OK
191 }
192 out_byte[0] = c
193 out_consumed[0] = 1
194 return NX_SQP_OK
195}
196
197// ===== Bytes-equal helper (case-sensitive; bounded) =================================================
198
199func nx_sqp_bytes_eq(a: *u8, a_n: i64, b: *u8, b_n: i64) -> i64 {
200 if a_n != b_n { return 0 }
201 if (a as i64) == 0 { return 0 }
202 if (b as i64) == 0 { return 0 }
203 var i: i64 = 0
204 while i < a_n {
205 if i >= NX_SQP_MAX_QUERY_LEN { return 0 }
206 if a[i] != b[i] { return 0 }
207 i = i + 1
208 }
209 return 1
210}
211
212// ===== Term append (filters length + tokenizes + lowercases) =================================================
213//
214// Appends one term to q.terms_buf. Filters: length in
215// [NX_SQP_MIN_TERM_LEN, NX_SQP_MAX_TERM_LEN]; only valid token chars
216// per nx_inv_is_token_char. Lowercases ASCII letters.
217
218func nx_search_query_append_term_kind(q: *NxSearchQuery,
219 src: *u8, src_n: i64, kind: i64) -> i64 {
220 if q.valid != 1 { return 0 - NX_SQP_BAD_INPUT }
221 if src_n < NX_SQP_MIN_TERM_LEN { return NX_SQP_OK } // silently drop too-short
222 if src_n > NX_SQP_MAX_TERM_LEN { return 0 - NX_SQP_TERM_TOO_LONG }
223 if q.terms_count >= NX_SQP_MAX_TERMS { return 0 - NX_SQP_TOO_MANY_TERMS }
224 if kind < 0 { return 0 - NX_SQP_BAD_INPUT }
225 if kind >= NX_SQP_TERM_KIND_N { return 0 - NX_SQP_BAD_INPUT }
226 // Need 1 byte for len prefix + src_n bytes for content.
227 if q.terms_used + 1 + src_n > q.terms_cap {
228 return 0 - NX_SQP_TERMS_BUF_OVERFLOW
229 }
230 // Stamp kind in parallel array.
231 q.term_kinds[q.terms_count] = (kind & 0xff) as u8
232 // Write length prefix.
233 q.terms_buf[q.terms_used] = (src_n & 0xff) as u8
234 var i: i64 = 0
235 while i < src_n {
236 if i >= NX_SQP_MAX_TERM_LEN { return 0 - NX_SQP_TERM_TOO_LONG }
237 var c: i64 = src[i] as i64
238 // Lowercase ASCII (per nx_search_inverted convention).
239 if c >= 0x41 { if c <= 0x5A { c = c + 32 } }
240 // Validate via nx_search_inverted's char predicate.
241 if nx_inv_is_token_char(c) != 1 {
242 // Skip invalid chars within a term; substitute nothing.
243 // For V1 we treat invalid mid-term as bad-input on the
244 // tokenizer side; here we've already broken on token
245 // boundaries so this branch shouldn't fire. Bail loud.
246 return 0 - NX_SQP_BAD_INPUT
247 }
248 q.terms_buf[q.terms_used + 1 + i] = (c & 0xff) as u8
249 i = i + 1
250 }
251 q.terms_used = q.terms_used + 1 + src_n
252 q.terms_count = q.terms_count + 1
253 return NX_SQP_OK
254}
255
256// V1 backward-compat: bare-term default kind is MUST (preserves V1
257// implicit-AND semantics; existing callers unchanged).
258func nx_search_query_append_term(q: *NxSearchQuery,
259 src: *u8, src_n: i64) -> i64 {
260 return nx_search_query_append_term_kind(q, src, src_n, NX_SQP_TERM_KIND_MUST)
261}
262
263// ===== Tokenize a URL-decoded query body =================================================
264//
265// Walks the decoded q= value; emits terms on every whitespace /
266// non-token boundary. Bounded by NX_SQP_LOOP_BUDGET.
267
268func nx_search_query_tokenize_value(q: *NxSearchQuery,
269 value_buf: *u8, value_n: i64) -> i64 {
270 if q.valid != 1 { return 0 - NX_SQP_BAD_INPUT }
271 if value_n < 0 { return 0 - NX_SQP_BAD_INPUT }
272 if value_n > NX_SQP_MAX_QUERY_LEN { return 0 - NX_SQP_QUERY_TOO_LONG }
273
274 var i: i64 = 0
275 var iter: i64 = 0
276 while i < value_n {
277 if iter >= NX_SQP_LOOP_BUDGET { return 0 - NX_SQP_LOOP_BUDGET }
278 iter = iter + 1
279 // V2: detect boolean prefix on token start.
280 var pending_kind: i64 = NX_SQP_TERM_KIND_MUST // bare term default (V1 implicit AND preserved)
281 let c0: i64 = value_buf[i] as i64
282 if c0 == 0x2B { // '+' MUST prefix
283 if i + 1 < value_n {
284 if nx_inv_is_token_char(value_buf[i + 1] as i64) == 1 {
285 pending_kind = NX_SQP_TERM_KIND_MUST
286 i = i + 1
287 }
288 }
289 }
290 if c0 == 0x2D { // '-' MUST_NOT prefix
291 if i + 1 < value_n {
292 if nx_inv_is_token_char(value_buf[i + 1] as i64) == 1 {
293 pending_kind = NX_SQP_TERM_KIND_MUST_NOT
294 i = i + 1
295 }
296 }
297 }
298 // Skip non-token chars.
299 let c: i64 = value_buf[i] as i64
300 if nx_inv_is_token_char(c) != 1 {
301 i = i + 1
302 }
303 if nx_inv_is_token_char(c) == 1 {
304 // Find end of this token.
305 let start: i64 = i
306 var j: i64 = i
307 while j < value_n {
308 if iter >= NX_SQP_LOOP_BUDGET { return 0 - NX_SQP_LOOP_BUDGET }
309 iter = iter + 1
310 if nx_inv_is_token_char(value_buf[j] as i64) != 1 { j = value_n }
311 if j < value_n {
312 if nx_inv_is_token_char(value_buf[j] as i64) == 1 { j = j + 1 }
313 }
314 }
315 // Find the actual end (j may have been forced to value_n).
316 var actual_end: i64 = start
317 while actual_end < value_n {
318 if iter >= NX_SQP_LOOP_BUDGET { return 0 - NX_SQP_LOOP_BUDGET }
319 iter = iter + 1
320 if nx_inv_is_token_char(value_buf[actual_end] as i64) != 1 { actual_end = value_n + 1 }
321 if actual_end <= value_n { if actual_end < value_n {
322 if nx_inv_is_token_char(value_buf[actual_end] as i64) == 1 { actual_end = actual_end + 1 }
323 }}
324 }
325 // actual_end overshot by 1 due to sentinel; trim.
326 let real_end: i64 = if actual_end > value_n then value_n else actual_end - 1
327 let term_n: i64 = real_end - start
328 if term_n > 0 {
329 let term_ptr: *u8 = (value_buf as i64 + start) as *u8
330 let rc: i64 = nx_search_query_append_term_kind(q, term_ptr, term_n, pending_kind)
331 if rc != NX_SQP_OK { return rc }
332 }
333 i = real_end
334 if i <= start { i = start + 1 } // safety: forward progress
335 }
336 }
337 return NX_SQP_OK
338}
339
340// ===== Scope-value parser =================================================
341
342func nx_search_query_parse_scope_value(q: *NxSearchQuery,
343 val: *u8, val_n: i64) -> i64 {
344 if q.valid != 1 { return 0 - NX_SQP_BAD_INPUT }
345 if nx_sqp_bytes_eq(val, val_n, NX_SQP_SCOPE_VAL_ONSITE,
346 NX_SQP_SCOPE_VAL_ONSITE_N) == 1 {
347 q.scope = NX_SEARCH_SCOPE_ONSITE
348 return NX_SQP_OK
349 }
350 if nx_sqp_bytes_eq(val, val_n, NX_SQP_SCOPE_VAL_OFFSITE,
351 NX_SQP_SCOPE_VAL_OFFSITE_N) == 1 {
352 q.scope = NX_SEARCH_SCOPE_OFFSITE
353 return NX_SQP_OK
354 }
355 if nx_sqp_bytes_eq(val, val_n, NX_SQP_SCOPE_VAL_BOTH,
356 NX_SQP_SCOPE_VAL_BOTH_N) == 1 {
357 q.scope = NX_SEARCH_SCOPE_BOTH
358 return NX_SQP_OK
359 }
360 return 0 - NX_SQP_BAD_SCOPE
361}
362
363// ===== n= (max_results) parser =================================================
364
365func nx_search_query_parse_n_value(q: *NxSearchQuery,
366 val: *u8, val_n: i64) -> i64 {
367 if val_n < 1 { return 0 - NX_SQP_BAD_INPUT }
368 if val_n > 10 { return 0 - NX_SQP_BAD_INPUT } // i32 fits in 10 digits
369 var v: i64 = 0
370 var i: i64 = 0
371 while i < val_n {
372 let c: i64 = val[i] as i64
373 if c < 0x30 { return 0 - NX_SQP_BAD_INPUT }
374 if c > 0x39 { return 0 - NX_SQP_BAD_INPUT }
375 v = v * 10 + (c - 0x30)
376 if v > NX_SQP_HARD_MAX_RESULTS { v = NX_SQP_HARD_MAX_RESULTS }
377 i = i + 1
378 }
379 if v < 1 { v = NX_SQP_DEFAULT_MAX_RESULTS }
380 q.max_results = v
381 return NX_SQP_OK
382}
383
384// ===== Top-level: parse a full HTTP query string =================================================
385//
386// Input: "q=hygiene+standard&scope=onsite&n=20" (no leading '?').
387// Walks param-by-param; URL-decodes values; dispatches to per-param
388// handler.
389
390func nx_search_query_parse(q: *NxSearchQuery,
391 query_str: *u8, query_str_n: i64) -> i64 {
392 if q.valid != 1 { return 0 - NX_SQP_BAD_INPUT }
393 if query_str_n < 0 { return 0 - NX_SQP_BAD_INPUT }
394 if query_str_n > NX_SQP_MAX_QUERY_LEN { return 0 - NX_SQP_QUERY_TOO_LONG }
395 if query_str_n == 0 { return NX_SQP_OK } // empty query is valid (defaults)
396
397 // Working buffer for URL-decoded values.
398 let decoded: *u8 = sys_mmap(NX_SQP_MAX_QUERY_LEN)
399
400 var i: i64 = 0
401 var iter: i64 = 0
402 while i < query_str_n {
403 if iter >= NX_SQP_LOOP_BUDGET { return 0 - NX_SQP_LOOP_BUDGET }
404 iter = iter + 1
405
406 // Find name (up to '=' or '&').
407 let name_start: i64 = i
408 var name_end: i64 = i
409 var found_eq: i64 = 0
410 while name_end < query_str_n {
411 if iter >= NX_SQP_LOOP_BUDGET { return 0 - NX_SQP_LOOP_BUDGET }
412 iter = iter + 1
413 let cc: i64 = query_str[name_end] as i64
414 if cc == NX_SQP_ASCII_EQ { found_eq = 1; name_end = query_str_n + 1 }
415 if cc == NX_SQP_ASCII_AMP { name_end = query_str_n + 1 }
416 if name_end <= query_str_n { name_end = name_end + 1 }
417 }
418 let real_name_end: i64 = if name_end > query_str_n then query_str_n else name_end - 1
419 let name_n: i64 = real_name_end - name_start
420 if name_n == 0 { return NX_SQP_OK } // trailing '&'; done
421
422 let name_ptr: *u8 = (query_str as i64 + name_start) as *u8
423
424 // If '=' found, value is from real_name_end+1 to next '&' or end.
425 var value_start: i64 = real_name_end
426 var value_end: i64 = real_name_end
427 if found_eq == 1 {
428 value_start = real_name_end + 1
429 value_end = value_start
430 while value_end < query_str_n {
431 if iter >= NX_SQP_LOOP_BUDGET { return 0 - NX_SQP_LOOP_BUDGET }
432 iter = iter + 1
433 let cc2: i64 = query_str[value_end] as i64
434 if cc2 == NX_SQP_ASCII_AMP { value_end = query_str_n + 1 }
435 if value_end <= query_str_n { value_end = value_end + 1 }
436 }
437 let real_value_end: i64 = if value_end > query_str_n then query_str_n else value_end - 1
438 value_end = real_value_end
439 }
440
441 // URL-decode the value into `decoded`.
442 var decoded_n: i64 = 0
443 if found_eq == 1 {
444 var k: i64 = value_start
445 let value_ptr: *u8 = (query_str as i64 + 0) as *u8
446 while k < value_end {
447 if iter >= NX_SQP_LOOP_BUDGET { return 0 - NX_SQP_LOOP_BUDGET }
448 iter = iter + 1
449 let byte_out: *i64 = (sys_mmap(8)) as *i64
450 let consumed_out: *i64 = (sys_mmap(8)) as *i64
451 byte_out[0] = 0
452 consumed_out[0] = 0
453 let drc: i64 = nx_sqp_decode_one(value_ptr, query_str_n, k,
454 byte_out, consumed_out)
455 if drc != NX_SQP_OK { return drc }
456 if decoded_n >= NX_SQP_MAX_QUERY_LEN { return 0 - NX_SQP_QUERY_TOO_LONG }
457 decoded[decoded_n] = (byte_out[0] & 0xff) as u8
458 decoded_n = decoded_n + 1
459 k = k + consumed_out[0]
460 }
461 }
462
463 // Dispatch by param name.
464 if nx_sqp_bytes_eq(name_ptr, name_n, NX_SQP_PARAM_Q, NX_SQP_PARAM_Q_N) == 1 {
465 let rc: i64 = nx_search_query_tokenize_value(q, decoded, decoded_n)
466 if rc != NX_SQP_OK { return rc }
467 }
468 if nx_sqp_bytes_eq(name_ptr, name_n, NX_SQP_PARAM_SCOPE, NX_SQP_PARAM_SCOPE_N) == 1 {
469 let rc: i64 = nx_search_query_parse_scope_value(q, decoded, decoded_n)
470 if rc != NX_SQP_OK { return rc }
471 }
472 if nx_sqp_bytes_eq(name_ptr, name_n, NX_SQP_PARAM_N, NX_SQP_PARAM_N_N) == 1 {
473 let rc: i64 = nx_search_query_parse_n_value(q, decoded, decoded_n)
474 if rc != NX_SQP_OK { return rc }
475 }
476 // Unknown params silently ignored (V1 forgiving; V2 may
477 // promote to BAD_INPUT once param vocabulary sealed).
478
479 // Advance past this param.
480 if found_eq == 1 { i = value_end } else { i = real_name_end }
481 // Skip the '&'.
482 if i < query_str_n {
483 if query_str[i] == (NX_SQP_ASCII_AMP as u8) { i = i + 1 }
484 }
485 }
486 return NX_SQP_OK
487}
488
489// ===== Reader API: iterate over parsed terms =================================================
490
491// Returns term count (>=0) or 0 - verdict.
492func nx_search_query_count(q: *NxSearchQuery) -> i64 {
493 if q.valid != 1 { return 0 - NX_SQP_BAD_INPUT }
494 return q.terms_count
495}
496
497// Look up term at index i; fills out_ptr + out_len.
498func nx_search_query_term_at(q: *NxSearchQuery, idx: i64,
499 out_ptr: *i64, out_len: *i64) -> i64 {
500 if q.valid != 1 { return 0 - NX_SQP_BAD_INPUT }
501 if idx < 0 { return 0 - NX_SQP_BAD_INPUT }
502 if idx >= q.terms_count { return 0 - NX_SQP_BAD_INPUT }
503 if (out_ptr as i64) == 0 { return 0 - NX_SQP_BAD_INPUT }
504 if (out_len as i64) == 0 { return 0 - NX_SQP_BAD_INPUT }
505
506 // Walk terms_buf to find idx-th term.
507 var off: i64 = 0
508 var k: i64 = 0
509 while k < idx {
510 if k >= NX_SQP_MAX_TERMS { return 0 - NX_SQP_BAD_INPUT }
511 if off >= q.terms_used { return 0 - NX_SQP_BAD_INPUT }
512 let len: i64 = q.terms_buf[off] as i64
513 off = off + 1 + len
514 k = k + 1
515 }
516 if off >= q.terms_used { return 0 - NX_SQP_BAD_INPUT }
517 let len: i64 = q.terms_buf[off] as i64
518 out_ptr[0] = (q.terms_buf as i64) + off + 1
519 out_len[0] = len
520 return NX_SQP_OK
521}
522
523// ===== Scope + max-results accessors (M1 no NULL; M5 bounds) =================================================
524
525func nx_search_query_scope(q: *NxSearchQuery) -> i64 {
526 if q.valid != 1 { return NX_SEARCH_SCOPE_ONSITE }
527 return q.scope
528}
529
530func nx_search_query_max_results(q: *NxSearchQuery) -> i64 {
531 if q.valid != 1 { return NX_SQP_DEFAULT_MAX_RESULTS }
532 return q.max_results
533}
534
535// V2: per-term kind accessor. Returns sealed enum value or
536// NX_SQP_TERM_KIND_SHOULD on out-of-range (defensive default).
537func nx_search_query_term_kind_at(q: *NxSearchQuery, idx: i64) -> i64 {
538 if q.valid != 1 { return NX_SQP_TERM_KIND_SHOULD }
539 if idx < 0 { return NX_SQP_TERM_KIND_SHOULD }
540 if idx >= q.terms_count { return NX_SQP_TERM_KIND_SHOULD }
541 return q.term_kinds[idx] as i64
542}