code wiki / (root) / nx_dom_query.nx

nx_dom_query.nx source

↩ module page · 405 lines · 15223 B

1// nx_dom_query.nx -- bits-up DOM query primitives for the Nishi test 2// harness arc. Subset of full nishi-browser (see NISHI_BROWSER_ROADMAP.md); 3// scoped specifically to what the .mjs Playwright harnesses need so they 4// can be retired per [[feedback-test-harness-is-the-last-non-bits-up-surface]]. 5// 6// Built on top of nx_html_tokenizer.nx (HtmlCursor + nx_html_next_token). 7// Four MVP queries the test harness needs: 8// 9// nx_dom_find_class -- first START_TAG whose class attr contains a word 10// nx_dom_find_attr_eq -- first START_TAG with attr="value" (exact) 11// nx_dom_count_tag -- count START_TAGs with given tag name 12// nx_dom_count_class -- count START_TAGs whose class attr contains word 13// 14// All scan the HTML byte buffer directly via the existing tokenizer. No 15// allocations beyond the caller's buffer; offsets returned into the source. 16 17import "nx_syscalls.nx" 18import "nx_html_tokenizer.nx" 19 20struct NxDomQueryResult { 21 found: i64, // 1 if found, 0 if not 22 tag_off: i64, // src offset of the START_TAG (the '<') 23 tag_len: i64, // src length (including '>') 24 name_off: i64, // tag-name offset 25 name_len: i64 // tag-name length 26} 27 28// ===== Byte helpers ================================================= 29 30func _dq_strlen(s: *u8) -> i64 { 31 var n: i64 = 0 32 while s[n] != 0 { n = n + 1 } 33 return n 34} 35 36func _dq_byte_eq(a: *u8, ao: i64, b: *u8, bo: i64, n: i64) -> i64 { 37 var i: i64 = 0 38 while i < n { 39 if a[ao + i] != b[bo + i] { return 0 } 40 i = i + 1 41 } 42 return 1 43} 44 45// Case-sensitive substring search. Returns offset within haystack 46// [hay_off, hay_off+hay_len) where needle starts, or -1. 47func _dq_find_substr(hay: *u8, hay_off: i64, hay_len: i64, 48 ndl: *u8, ndl_len: i64) -> i64 { 49 if ndl_len == 0 { return hay_off } 50 if hay_len < ndl_len { return -1 } 51 var i: i64 = hay_off 52 let end: i64 = hay_off + hay_len - ndl_len 53 while i <= end { 54 if _dq_byte_eq(hay, i, ndl, 0, ndl_len) == 1 { return i } 55 i = i + 1 56 } 57 return -1 58} 59 60// ASCII lowercase a byte (A-Z -> a-z; other bytes unchanged). 61func _dq_lc(b: i64) -> i64 { 62 if b >= 65 { if b <= 90 { return b + 32 } } 63 return b 64} 65 66// Case-insensitive byte-range equality (ASCII). HTML5 attribute names 67// are case-insensitive (spec) so attribute lookup uses this. 68func _dq_byte_eq_ci(a: *u8, ao: i64, b: *u8, bo: i64, n: i64) -> i64 { 69 var i: i64 = 0 70 while i < n { 71 if _dq_lc(a[ao + i] as i64) != _dq_lc(b[bo + i] as i64) { return 0 } 72 i = i + 1 73 } 74 return 1 75} 76 77// Case-insensitive substring search. Used for HTML5-spec-correct 78// attribute-name lookup (HREF == href == hReF). 79func _dq_find_substr_ci(hay: *u8, hay_off: i64, hay_len: i64, 80 ndl: *u8, ndl_len: i64) -> i64 { 81 if ndl_len == 0 { return hay_off } 82 if hay_len < ndl_len { return -1 } 83 var i: i64 = hay_off 84 let end: i64 = hay_off + hay_len - ndl_len 85 while i <= end { 86 if _dq_byte_eq_ci(hay, i, ndl, 0, ndl_len) == 1 { return i } 87 i = i + 1 88 } 89 return -1 90} 91 92// Is this byte a word-boundary char inside a class-attr value? Either 93// whitespace or one of the value delimiters (" or '). 94func _dq_is_word_boundary(b: i64) -> i64 { 95 if b == 32 { return 1 } // space 96 if b == 9 { return 1 } // tab 97 if b == 10 { return 1 } // LF 98 if b == 13 { return 1 } // CR 99 if b == 34 { return 1 } // " 100 if b == 39 { return 1 } // ' 101 return 0 102} 103 104// Is this token an ELEMENT start for query purposes? Both a normal START_TAG and a SELF_CLOSING 105// tag (HTML5 void elements img/input/meta/link/br/... which the tokenizer emits as SELF_CLOSING) 106// are queryable elements -- getElementById/querySelector/getAttribute must see <img> etc. 107func _dq_is_elem(kind: i64) -> i64 { 108 if kind == NX_HTML_TOK_START_TAG { return 1 } 109 if kind == NX_HTML_TOK_SELF_CLOSING { return 1 } 110 return 0 111} 112 113// ===== Attribute scanner ============================================= 114// 115// Given the bytes of a START_TAG, find an attribute value range. 116// tag_off + tag_len bracket the tag including '<' and '>'. 117// attr_name is a null-terminated cstring. 118// Returns (value_off, value_len) via out_off / out_len; or (-1, 0) if 119// the attr isn't present. Handles both "..." and '...' quoting. 120func nx_dom_find_attr(src: *u8, tag_off: i64, tag_len: i64, 121 attr_name: *u8, 122 out_off: *i64, out_len: *i64) -> i64 { 123 let nlen: i64 = _dq_strlen(attr_name) 124 if nlen == 0 { out_off[0] = -1; out_len[0] = 0; return 0 } 125 // Search for `<space>attr_name=` within the tag region (excluding '<'). 126 // To be lenient we scan for `attr_name=` and verify preceding byte 127 // is a word boundary (space, tab, newline) or the '<' opener. 128 let region_off: i64 = tag_off + 1 // skip the '<' 129 let region_len: i64 = tag_len - 1 130 var search_off: i64 = region_off 131 var search_len: i64 = region_len 132 while search_len > nlen + 1 { 133 let hit: i64 = _dq_find_substr_ci(src, search_off, search_len, 134 attr_name, nlen) 135 if hit < 0 { out_off[0] = -1; out_len[0] = 0; return 0 } 136 // Verify boundary BEFORE the hit (avoid matching `data-action` 137 // when we asked for `action`). 138 var prev_ok: i64 = 0 139 if hit == region_off { prev_ok = 1 } 140 if hit > region_off { 141 let pb: i64 = src[hit - 1] as i64 142 if pb == 32 { prev_ok = 1 } 143 if pb == 9 { prev_ok = 1 } 144 if pb == 10 { prev_ok = 1 } 145 if pb == 13 { prev_ok = 1 } 146 } 147 // Verify '=' follows the name. 148 var eq_ok: i64 = 0 149 if hit + nlen < region_off + region_len { 150 if src[hit + nlen] == 61 { eq_ok = 1 } // '=' 151 } 152 if prev_ok == 1 { 153 if eq_ok == 1 { 154 let val_start_quote: i64 = hit + nlen + 1 155 if val_start_quote >= region_off + region_len { 156 out_off[0] = -1; out_len[0] = 0; return 0 157 } 158 let qb: i64 = src[val_start_quote] as i64 159 if qb == 34 { 160 // " quoting 161 let vs: i64 = val_start_quote + 1 162 var ve: i64 = vs 163 while ve < region_off + region_len { 164 if src[ve] == 34 { 165 out_off[0] = vs 166 out_len[0] = ve - vs 167 return 1 168 } 169 ve = ve + 1 170 } 171 out_off[0] = -1; out_len[0] = 0; return 0 172 } 173 if qb == 39 { 174 // ' quoting 175 let vs: i64 = val_start_quote + 1 176 var ve: i64 = vs 177 while ve < region_off + region_len { 178 if src[ve] == 39 { 179 out_off[0] = vs 180 out_len[0] = ve - vs 181 return 1 182 } 183 ve = ve + 1 184 } 185 out_off[0] = -1; out_len[0] = 0; return 0 186 } 187 // Unquoted -- read until whitespace or '>'. 188 let vs: i64 = val_start_quote 189 var ve: i64 = vs 190 while ve < region_off + region_len { 191 let b: i64 = src[ve] as i64 192 if b == 32 { out_off[0] = vs; out_len[0] = ve - vs; return 1 } 193 if b == 62 { out_off[0] = vs; out_len[0] = ve - vs; return 1 } 194 ve = ve + 1 195 } 196 out_off[0] = vs; out_len[0] = ve - vs; return 1 197 } 198 } 199 // Advance past this false hit. 200 search_off = hit + 1 201 search_len = region_len - (search_off - region_off) 202 } 203 out_off[0] = -1; out_len[0] = 0 204 return 0 205} 206 207// Does a class-attribute VALUE contain a given word? Word-boundary aware 208// so `card` doesn't match `card-header`. 209func nx_dom_class_contains(src: *u8, val_off: i64, val_len: i64, 210 class_name: *u8) -> i64 { 211 let clen: i64 = _dq_strlen(class_name) 212 if clen == 0 { return 0 } 213 if val_len < clen { return 0 } 214 var i: i64 = val_off 215 let end: i64 = val_off + val_len - clen 216 while i <= end { 217 if _dq_byte_eq(src, i, class_name, 0, clen) == 1 { 218 // Check left boundary. 219 var lb: i64 = 0 220 if i == val_off { lb = 1 } 221 if i > val_off { 222 if _dq_is_word_boundary(src[i - 1] as i64) == 1 { lb = 1 } 223 } 224 // Check right boundary. 225 var rb: i64 = 0 226 if i + clen == val_off + val_len { rb = 1 } 227 if i + clen < val_off + val_len { 228 let nb: i64 = src[i + clen] as i64 229 if nb == 32 { rb = 1 } 230 if nb == 9 { rb = 1 } 231 if nb == 10 { rb = 1 } 232 if nb == 13 { rb = 1 } 233 } 234 if lb == 1 { 235 if rb == 1 { return 1 } 236 } 237 } 238 i = i + 1 239 } 240 return 0 241} 242 243// ===== Public queries ================================================ 244 245// Find the first START_TAG with the given class word. 246func nx_dom_find_class(html: *u8, html_len: i64, 247 class_name: *u8) -> *NxDomQueryResult { 248 let result: *NxDomQueryResult = sys_mmap(40) as *NxDomQueryResult 249 result.found = 0 250 result.tag_off = -1 251 result.tag_len = 0 252 result.name_off = 0 253 result.name_len = 0 254 255 let c: *HtmlCursor = sys_mmap(24) as *HtmlCursor 256 nx_html_cursor_init(c, html, html_len) 257 let tok: *HtmlToken = sys_mmap(56) as *HtmlToken 258 259 let class_attr: *u8 = "class" as *u8 260 let val_off: *i64 = sys_mmap(8) as *i64 261 let val_len: *i64 = sys_mmap(8) as *i64 262 263 while 1 == 1 { 264 nx_html_next_token(c, tok) 265 if tok.kind == NX_HTML_TOK_EOF { return result } 266 if _dq_is_elem(tok.kind) == 1 { 267 let has: i64 = nx_dom_find_attr(html, tok.src_off, tok.src_len, 268 class_attr, val_off, val_len) 269 if has == 1 { 270 if nx_dom_class_contains(html, val_off[0], val_len[0], 271 class_name) == 1 { 272 result.found = 1 273 result.tag_off = tok.src_off 274 result.tag_len = tok.src_len 275 result.name_off = tok.name_off 276 result.name_len = tok.name_len 277 return result 278 } 279 } 280 } 281 } 282 return result 283} 284 285// Find the first START_TAG with attr="value" (exact value match). 286func nx_dom_find_attr_eq(html: *u8, html_len: i64, 287 attr_name: *u8, attr_value: *u8) -> *NxDomQueryResult { 288 let result: *NxDomQueryResult = sys_mmap(40) as *NxDomQueryResult 289 result.found = 0 290 result.tag_off = -1 291 result.tag_len = 0 292 result.name_off = 0 293 result.name_len = 0 294 295 let c: *HtmlCursor = sys_mmap(24) as *HtmlCursor 296 nx_html_cursor_init(c, html, html_len) 297 let tok: *HtmlToken = sys_mmap(56) as *HtmlToken 298 299 let val_off: *i64 = sys_mmap(8) as *i64 300 let val_len: *i64 = sys_mmap(8) as *i64 301 let vlen: i64 = _dq_strlen(attr_value) 302 303 while 1 == 1 { 304 nx_html_next_token(c, tok) 305 if tok.kind == NX_HTML_TOK_EOF { return result } 306 if _dq_is_elem(tok.kind) == 1 { 307 let has: i64 = nx_dom_find_attr(html, tok.src_off, tok.src_len, 308 attr_name, val_off, val_len) 309 if has == 1 { 310 if val_len[0] == vlen { 311 if _dq_byte_eq(html, val_off[0], attr_value, 0, vlen) == 1 { 312 result.found = 1 313 result.tag_off = tok.src_off 314 result.tag_len = tok.src_len 315 result.name_off = tok.name_off 316 result.name_len = tok.name_len 317 return result 318 } 319 } 320 } 321 } 322 } 323 return result 324} 325 326// Count START_TAGs whose tag name equals the given name. 327func nx_dom_count_tag(html: *u8, html_len: i64, tag_name: *u8) -> i64 { 328 let c: *HtmlCursor = sys_mmap(24) as *HtmlCursor 329 nx_html_cursor_init(c, html, html_len) 330 let tok: *HtmlToken = sys_mmap(56) as *HtmlToken 331 let nlen: i64 = _dq_strlen(tag_name) 332 var count: i64 = 0 333 while 1 == 1 { 334 nx_html_next_token(c, tok) 335 if tok.kind == NX_HTML_TOK_EOF { return count } 336 if _dq_is_elem(tok.kind) == 1 { 337 if tok.name_len == nlen { 338 if _dq_byte_eq(html, tok.name_off, tag_name, 0, nlen) == 1 { 339 count = count + 1 340 } 341 } 342 } 343 } 344 return count 345} 346 347// Find the FIRST START_TAG whose tag name equals the given name (HTML5 case-insensitive). 348// Returns an NxDomQueryResult (found=0 if none) -- the query-by-type half of querySelector. 349func nx_dom_find_tag(html: *u8, html_len: i64, tag_name: *u8) -> *NxDomQueryResult { 350 let result: *NxDomQueryResult = sys_mmap(40) as *NxDomQueryResult 351 result.found = 0 352 result.tag_off = -1 353 result.tag_len = 0 354 result.name_off = 0 355 result.name_len = 0 356 357 let c: *HtmlCursor = sys_mmap(24) as *HtmlCursor 358 nx_html_cursor_init(c, html, html_len) 359 let tok: *HtmlToken = sys_mmap(56) as *HtmlToken 360 let nlen: i64 = _dq_strlen(tag_name) 361 362 while 1 == 1 { 363 nx_html_next_token(c, tok) 364 if tok.kind == NX_HTML_TOK_EOF { return result } 365 if _dq_is_elem(tok.kind) == 1 { 366 if tok.name_len == nlen { 367 if _dq_byte_eq_ci(html, tok.name_off, tag_name, 0, nlen) == 1 { 368 result.found = 1 369 result.tag_off = tok.src_off 370 result.tag_len = tok.src_len 371 result.name_off = tok.name_off 372 result.name_len = tok.name_len 373 return result 374 } 375 } 376 } 377 } 378 return result 379} 380 381// Count START_TAGs whose class attribute contains the given word. 382func nx_dom_count_class(html: *u8, html_len: i64, class_name: *u8) -> i64 { 383 let c: *HtmlCursor = sys_mmap(24) as *HtmlCursor 384 nx_html_cursor_init(c, html, html_len) 385 let tok: *HtmlToken = sys_mmap(56) as *HtmlToken 386 let class_attr: *u8 = "class" as *u8 387 let val_off: *i64 = sys_mmap(8) as *i64 388 let val_len: *i64 = sys_mmap(8) as *i64 389 var count: i64 = 0 390 while 1 == 1 { 391 nx_html_next_token(c, tok) 392 if tok.kind == NX_HTML_TOK_EOF { return count } 393 if _dq_is_elem(tok.kind) == 1 { 394 let has: i64 = nx_dom_find_attr(html, tok.src_off, tok.src_len, 395 class_attr, val_off, val_len) 396 if has == 1 { 397 if nx_dom_class_contains(html, val_off[0], val_len[0], 398 class_name) == 1 { 399 count = count + 1 400 } 401 } 402 } 403 } 404 return count 405}