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}