1use std::collections::{BTreeMap, BTreeSet};
4
5use runmat_builtins::{
6 BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
7 BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
8 CellArray, ObjectInstance, ResolveContext, StringArray, StructValue, Type, Value,
9};
10use runmat_macros::runtime_builtin;
11
12use crate::builtins::strings::core::compat::{scalar_text, text_items};
13use crate::{build_runtime_error, gather_if_needed_async, make_cell_with_shape, BuiltinResult};
14
15const HTML_TREE_CLASS: &str = "htmlTree";
16const MISSING: &str = "<missing>";
17
18const OUT_TREE: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
19 name: "tree",
20 ty: BuiltinParamType::Any,
21 arity: BuiltinParamArity::Required,
22 default: None,
23 description: "Parsed HTML tree.",
24}];
25
26const OUT_TEXT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
27 name: "str",
28 ty: BuiltinParamType::Any,
29 arity: BuiltinParamArity::Required,
30 default: None,
31 description: "Extracted text.",
32}];
33
34const OUT_SUBTREES: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
35 name: "subtrees",
36 ty: BuiltinParamType::Any,
37 arity: BuiltinParamArity::Required,
38 default: None,
39 description: "Matching htmlTree subtrees.",
40}];
41
42const IN_CODE: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
43 name: "code",
44 ty: BuiltinParamType::Any,
45 arity: BuiltinParamArity::Required,
46 default: None,
47 description: "HTML code or htmlTree object.",
48}];
49
50const IN_CODE_METHOD: [BuiltinParamDescriptor; 3] = [
51 BuiltinParamDescriptor {
52 name: "code",
53 ty: BuiltinParamType::Any,
54 arity: BuiltinParamArity::Required,
55 default: None,
56 description: "HTML code or htmlTree object.",
57 },
58 BuiltinParamDescriptor {
59 name: "Name",
60 ty: BuiltinParamType::StringScalar,
61 arity: BuiltinParamArity::Required,
62 default: Some("ExtractionMethod"),
63 description: "Extraction method option name.",
64 },
65 BuiltinParamDescriptor {
66 name: "method",
67 ty: BuiltinParamType::StringScalar,
68 arity: BuiltinParamArity::Required,
69 default: Some("tree"),
70 description: "Extraction method: tree, article, or all-text.",
71 },
72];
73
74const IN_TREE_SELECTOR: [BuiltinParamDescriptor; 2] = [
75 BuiltinParamDescriptor {
76 name: "tree",
77 ty: BuiltinParamType::Any,
78 arity: BuiltinParamArity::Required,
79 default: None,
80 description: "htmlTree object or cell array of htmlTree objects.",
81 },
82 BuiltinParamDescriptor {
83 name: "selector",
84 ty: BuiltinParamType::StringScalar,
85 arity: BuiltinParamArity::Required,
86 default: None,
87 description: "CSS selector.",
88 },
89];
90
91const IN_TREE_ATTR: [BuiltinParamDescriptor; 2] = [
92 BuiltinParamDescriptor {
93 name: "tree",
94 ty: BuiltinParamType::Any,
95 arity: BuiltinParamArity::Required,
96 default: None,
97 description: "htmlTree object or cell array of htmlTree objects.",
98 },
99 BuiltinParamDescriptor {
100 name: "attr",
101 ty: BuiltinParamType::StringScalar,
102 arity: BuiltinParamArity::Required,
103 default: None,
104 description: "Attribute name.",
105 },
106];
107
108const ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
109 code: "RM.HTML.INVALID_INPUT",
110 identifier: Some("RunMat:html:InvalidInput"),
111 when: "Inputs are not a supported htmlTree or extractHTMLText form.",
112 message: "HTML Text Analytics helper received invalid input",
113};
114
115const ERRORS: [BuiltinErrorDescriptor; 1] = [ERROR_INVALID_INPUT];
116
117pub const HTML_TREE_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
118 signatures: &[BuiltinSignatureDescriptor {
119 label: "tree = htmlTree(code)",
120 inputs: &IN_CODE,
121 outputs: &OUT_TREE,
122 }],
123 output_mode: BuiltinOutputMode::Fixed,
124 completion_policy: BuiltinCompletionPolicy::Public,
125 errors: &ERRORS,
126};
127
128pub const EXTRACT_HTML_TEXT_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
129 signatures: &[
130 BuiltinSignatureDescriptor {
131 label: "str = extractHTMLText(code)",
132 inputs: &IN_CODE,
133 outputs: &OUT_TEXT,
134 },
135 BuiltinSignatureDescriptor {
136 label: "str = extractHTMLText(___, 'ExtractionMethod', method)",
137 inputs: &IN_CODE_METHOD,
138 outputs: &OUT_TEXT,
139 },
140 ],
141 output_mode: BuiltinOutputMode::Fixed,
142 completion_policy: BuiltinCompletionPolicy::Public,
143 errors: &ERRORS,
144};
145
146pub const FIND_ELEMENT_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
147 signatures: &[BuiltinSignatureDescriptor {
148 label: "subtrees = findElement(tree, selector)",
149 inputs: &IN_TREE_SELECTOR,
150 outputs: &OUT_SUBTREES,
151 }],
152 output_mode: BuiltinOutputMode::Fixed,
153 completion_policy: BuiltinCompletionPolicy::Public,
154 errors: &ERRORS,
155};
156
157pub const GET_ATTRIBUTE_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
158 signatures: &[BuiltinSignatureDescriptor {
159 label: "str = getAttribute(tree, attr)",
160 inputs: &IN_TREE_ATTR,
161 outputs: &OUT_TEXT,
162 }],
163 output_mode: BuiltinOutputMode::Fixed,
164 completion_policy: BuiltinCompletionPolicy::Public,
165 errors: &ERRORS,
166};
167
168fn any_type(_args: &[Type], _ctx: &ResolveContext) -> Type {
169 Type::Unknown
170}
171
172fn string_type(_args: &[Type], _ctx: &ResolveContext) -> Type {
173 Type::String
174}
175
176fn html_error(fn_name: &str, message: impl Into<String>) -> crate::RuntimeError {
177 let mut builder = build_runtime_error(message).with_builtin(fn_name);
178 if let Some(identifier) = ERROR_INVALID_INPUT.identifier {
179 builder = builder.with_identifier(identifier);
180 }
181 builder.build()
182}
183
184#[runtime_builtin(
185 name = "htmlTree",
186 category = "strings/text_analytics",
187 summary = "Parse HTML code into a lightweight htmlTree object.",
188 keywords = "htmlTree,HTML,text analytics,DOM",
189 accel = "metadata",
190 type_resolver(any_type),
191 descriptor(crate::builtins::strings::text_analytics::html::HTML_TREE_DESCRIPTOR),
192 builtin_path = "crate::builtins::strings::text_analytics::html"
193)]
194async fn html_tree_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
195 if args.len() != 1 {
196 return Err(html_error(
197 "htmlTree",
198 "htmlTree: expected exactly one input",
199 ));
200 }
201 let value = gather_if_needed_async(&args[0]).await.map_err(|err| {
202 html_error(
203 "htmlTree",
204 format!("htmlTree: failed to gather input: {err}"),
205 )
206 })?;
207 html_tree_value(value)
208}
209
210#[runtime_builtin(
211 name = "extractHTMLText",
212 category = "strings/text_analytics",
213 summary = "Extract visible text from HTML code or htmlTree objects.",
214 keywords = "extractHTMLText,HTML,text analytics,parse",
215 accel = "sink",
216 type_resolver(string_type),
217 descriptor(crate::builtins::strings::text_analytics::html::EXTRACT_HTML_TEXT_DESCRIPTOR),
218 builtin_path = "crate::builtins::strings::text_analytics::html"
219)]
220async fn extract_html_text_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
221 let (source, method) = parse_extract_args(args).await?;
222 extract_html_text_value(source, method)
223}
224
225#[runtime_builtin(
226 name = "findElement",
227 category = "strings/text_analytics",
228 summary = "Find elements in an htmlTree using common CSS selectors.",
229 keywords = "findElement,htmlTree,HTML,CSS selector,text analytics",
230 accel = "metadata",
231 type_resolver(any_type),
232 descriptor(crate::builtins::strings::text_analytics::html::FIND_ELEMENT_DESCRIPTOR),
233 builtin_path = "crate::builtins::strings::text_analytics::html"
234)]
235async fn find_element_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
236 if args.len() != 2 {
237 return Err(html_error(
238 "findElement",
239 "findElement: expected htmlTree and selector",
240 ));
241 }
242 let tree = gather_if_needed_async(&args[0]).await.map_err(|err| {
243 html_error(
244 "findElement",
245 format!("findElement: failed to gather tree: {err}"),
246 )
247 })?;
248 let selector = gather_if_needed_async(&args[1]).await.map_err(|err| {
249 html_error(
250 "findElement",
251 format!("findElement: failed to gather selector: {err}"),
252 )
253 })?;
254 let selector = scalar_text(&selector, "findElement")?;
255 find_element_value(tree, &selector)
256}
257
258#[runtime_builtin(
259 name = "getAttribute",
260 category = "strings/text_analytics",
261 summary = "Read an HTML attribute from htmlTree root nodes.",
262 keywords = "getAttribute,htmlTree,HTML,attribute,text analytics",
263 accel = "metadata",
264 type_resolver(string_type),
265 descriptor(crate::builtins::strings::text_analytics::html::GET_ATTRIBUTE_DESCRIPTOR),
266 builtin_path = "crate::builtins::strings::text_analytics::html"
267)]
268async fn get_attribute_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
269 if args.len() != 2 {
270 return Err(html_error(
271 "getAttribute",
272 "getAttribute: expected htmlTree and attribute name",
273 ));
274 }
275 let tree = gather_if_needed_async(&args[0]).await.map_err(|err| {
276 html_error(
277 "getAttribute",
278 format!("getAttribute: failed to gather tree: {err}"),
279 )
280 })?;
281 let attr = gather_if_needed_async(&args[1]).await.map_err(|err| {
282 html_error(
283 "getAttribute",
284 format!("getAttribute: failed to gather attribute name: {err}"),
285 )
286 })?;
287 let attr = scalar_text(&attr, "getAttribute")?;
288 get_attribute_value(tree, &attr)
289}
290
291async fn parse_extract_args(args: Vec<Value>) -> BuiltinResult<(Value, ExtractionMethod)> {
292 match args.len() {
293 1 => {
294 let source = gather_if_needed_async(&args[0]).await.map_err(|err| {
295 html_error(
296 "extractHTMLText",
297 format!("extractHTMLText: failed to gather input: {err}"),
298 )
299 })?;
300 Ok((source, ExtractionMethod::Tree))
301 }
302 3 => {
303 let source = gather_if_needed_async(&args[0]).await.map_err(|err| {
304 html_error(
305 "extractHTMLText",
306 format!("extractHTMLText: failed to gather input: {err}"),
307 )
308 })?;
309 let name = gather_if_needed_async(&args[1]).await.map_err(|err| {
310 html_error(
311 "extractHTMLText",
312 format!("extractHTMLText: failed to gather option name: {err}"),
313 )
314 })?;
315 let method = gather_if_needed_async(&args[2]).await.map_err(|err| {
316 html_error(
317 "extractHTMLText",
318 format!("extractHTMLText: failed to gather option value: {err}"),
319 )
320 })?;
321 let option = scalar_text(&name, "extractHTMLText")?.to_ascii_lowercase();
322 if option != "extractionmethod" {
323 return Err(html_error(
324 "extractHTMLText",
325 format!("extractHTMLText: unsupported option '{option}'"),
326 ));
327 }
328 Ok((
329 source,
330 ExtractionMethod::parse(&scalar_text(&method, "extractHTMLText")?)?,
331 ))
332 }
333 _ => Err(html_error(
334 "extractHTMLText",
335 "extractHTMLText: expected input or input with 'ExtractionMethod', method",
336 )),
337 }
338}
339
340fn html_tree_value(value: Value) -> BuiltinResult<Value> {
341 if let Value::Object(object) = value {
342 if object.is_class(HTML_TREE_CLASS) {
343 return Ok(Value::Object(object));
344 }
345 return Err(html_error(
346 "htmlTree",
347 format!(
348 "htmlTree: expected HTML text, got object {}",
349 object.class_name
350 ),
351 ));
352 }
353
354 let list = text_items(value, "htmlTree")?;
355 let mut objects = Vec::with_capacity(list.items.len());
356 for item in list.items {
357 let html = item.unwrap_or_else(|| MISSING.to_string());
358 objects.push(Value::Object(parse_html_tree(&html)?.into_object(None)?));
359 }
360 if objects.len() == 1 {
361 Ok(objects.remove(0))
362 } else {
363 make_cell_with_shape(objects, list.shape).map_err(|err| html_error("htmlTree", err))
364 }
365}
366
367pub(in crate::builtins::strings::text_analytics) fn extract_html_text_value(
368 value: Value,
369 method: ExtractionMethod,
370) -> BuiltinResult<Value> {
371 match value {
372 Value::Object(object) if object.is_class(HTML_TREE_CLASS) => {
373 Ok(Value::String(extract_from_object(&object, method)?))
374 }
375 Value::Object(object) => Err(html_error(
376 "extractHTMLText",
377 format!(
378 "extractHTMLText: expected HTML text or htmlTree, got object {}",
379 object.class_name
380 ),
381 )),
382 Value::Cell(cell) if cell.data.iter().any(is_html_tree_value) => {
383 extract_html_text_from_cell(cell, method)
384 }
385 other => {
386 let list = text_items(other, "extractHTMLText")?;
387 let shape = list.shape.clone();
388 let mut texts = Vec::with_capacity(list.items.len());
389 for item in list.items {
390 match item {
391 Some(html) => {
392 let tree = parse_html_tree(&html)?;
393 texts.push(tree.extract_text(method));
394 }
395 None => texts.push(MISSING.to_string()),
396 }
397 }
398 string_output(texts, shape, "extractHTMLText")
399 }
400 }
401}
402
403fn extract_html_text_from_cell(cell: CellArray, method: ExtractionMethod) -> BuiltinResult<Value> {
404 let shape = cell.shape.clone();
405 let mut texts = Vec::with_capacity(cell.data.len());
406 for value in cell.data {
407 match value {
408 Value::Object(object) if object.is_class(HTML_TREE_CLASS) => {
409 texts.push(extract_from_object(&object, method)?);
410 }
411 Value::Object(object) => {
412 return Err(html_error(
413 "extractHTMLText",
414 format!(
415 "extractHTMLText: expected HTML text or htmlTree, got object {}",
416 object.class_name
417 ),
418 ));
419 }
420 other => {
421 let html = scalar_text(&other, "extractHTMLText")?;
422 let tree = parse_html_tree(&html)?;
423 texts.push(tree.extract_text(method));
424 }
425 }
426 }
427 string_output(texts, shape, "extractHTMLText")
428}
429
430fn is_html_tree_value(value: &Value) -> bool {
431 matches!(value, Value::Object(object) if object.is_class(HTML_TREE_CLASS))
432}
433
434fn string_output(data: Vec<String>, shape: Vec<usize>, fn_name: &str) -> BuiltinResult<Value> {
435 if data.len() == 1 {
436 Ok(Value::String(data.into_iter().next().unwrap_or_default()))
437 } else {
438 StringArray::new(data, shape)
439 .map(Value::StringArray)
440 .map_err(|err| html_error(fn_name, err))
441 }
442}
443
444fn extract_from_object(object: &ObjectInstance, method: ExtractionMethod) -> BuiltinResult<String> {
445 Ok(node_from_object(object, "extractHTMLText")?.extract_text(method))
446}
447
448fn find_element_value(value: Value, selector: &str) -> BuiltinResult<Value> {
449 let selector = Selector::parse(selector)?;
450 let mut matches = Vec::new();
451 match value {
452 Value::Object(object) if object.is_class(HTML_TREE_CLASS) => {
453 let root = node_from_object(&object, "findElement")?;
454 collect_selector_matches(&root, &selector, &mut matches)?;
455 }
456 Value::Cell(cell) => {
457 for item in cell.data {
458 match item {
459 Value::Object(object) if object.is_class(HTML_TREE_CLASS) => {
460 let root = node_from_object(&object, "findElement")?;
461 collect_selector_matches(&root, &selector, &mut matches)?;
462 }
463 other => {
464 return Err(html_error(
465 "findElement",
466 format!("findElement: expected htmlTree object, got {other:?}"),
467 ));
468 }
469 }
470 }
471 }
472 Value::Object(object) => {
473 return Err(html_error(
474 "findElement",
475 format!(
476 "findElement: expected htmlTree, got object {}",
477 object.class_name
478 ),
479 ));
480 }
481 other => {
482 return Err(html_error(
483 "findElement",
484 format!("findElement: expected htmlTree object, got {other:?}"),
485 ));
486 }
487 }
488
489 let objects = matches
490 .into_iter()
491 .map(|matched| {
492 matched
493 .node
494 .into_object(matched.parent.as_deref())
495 .map(Value::Object)
496 })
497 .collect::<BuiltinResult<Vec<_>>>()?;
498 let rows = objects.len();
499 Ok(Value::Cell(CellArray::new(objects, rows, 1).map_err(
500 |err| html_error("findElement", format!("findElement: {err}")),
501 )?))
502}
503
504fn get_attribute_value(value: Value, attr: &str) -> BuiltinResult<Value> {
505 let attr = attr.trim().to_ascii_lowercase();
506 if attr.is_empty() {
507 return Err(html_error(
508 "getAttribute",
509 "getAttribute: attribute name must be nonempty",
510 ));
511 }
512 match value {
513 Value::Object(object) if object.is_class(HTML_TREE_CLASS) => {
514 Ok(Value::String(attribute_from_object(&object, &attr)?))
515 }
516 Value::Object(object) => Err(html_error(
517 "getAttribute",
518 format!(
519 "getAttribute: expected htmlTree, got object {}",
520 object.class_name
521 ),
522 )),
523 Value::Cell(cell) => {
524 let shape = cell.shape.clone();
525 let mut values = Vec::with_capacity(cell.data.len());
526 for item in cell.data {
527 match item {
528 Value::Object(object) if object.is_class(HTML_TREE_CLASS) => {
529 values.push(attribute_from_object(&object, &attr)?);
530 }
531 other => {
532 return Err(html_error(
533 "getAttribute",
534 format!("getAttribute: expected htmlTree object, got {other:?}"),
535 ));
536 }
537 }
538 }
539 string_output(values, shape, "getAttribute")
540 }
541 other => Err(html_error(
542 "getAttribute",
543 format!("getAttribute: expected htmlTree object, got {other:?}"),
544 )),
545 }
546}
547
548fn attribute_from_object(object: &ObjectInstance, attr: &str) -> BuiltinResult<String> {
549 let node = node_from_object(object, "getAttribute")?;
550 match node {
551 HtmlNode::Element(element) => Ok(element
552 .attrs
553 .get(attr)
554 .cloned()
555 .unwrap_or_else(|| MISSING.to_string())),
556 HtmlNode::Text(_) => Ok(MISSING.to_string()),
557 }
558}
559
560fn node_from_object(object: &ObjectInstance, fn_name: &str) -> BuiltinResult<HtmlNode> {
561 match object.properties.get("RawHTML") {
562 Some(Value::String(html)) => parse_html_tree(html),
563 Some(Value::CharArray(_)) | Some(Value::StringArray(_)) => {
564 let html = scalar_text(
565 object
566 .properties
567 .get("RawHTML")
568 .expect("RawHTML checked above"),
569 fn_name,
570 )?;
571 parse_html_tree(&html)
572 }
573 _ => Err(html_error(
574 fn_name,
575 format!("{fn_name}: invalid htmlTree object"),
576 )),
577 }
578}
579
580fn collect_selector_matches(
581 root: &HtmlNode,
582 selector: &Selector,
583 out: &mut Vec<MatchedNode>,
584) -> BuiltinResult<()> {
585 let mut seen = BTreeSet::<Vec<usize>>::new();
586 for chain in &selector.chains {
587 for path in execute_selector_chain(root, chain)? {
588 seen.insert(path);
589 }
590 }
591 for path in seen {
592 out.push(MatchedNode {
593 node: node_at(root, &path)
594 .ok_or_else(|| html_error("findElement", "findElement: invalid match"))?
595 .clone(),
596 parent: parent_name(root, &path).map(str::to_ascii_uppercase),
597 });
598 }
599 Ok(())
600}
601
602fn execute_selector_chain(
603 root: &HtmlNode,
604 chain: &SelectorChain,
605) -> BuiltinResult<Vec<Vec<usize>>> {
606 let Some(first) = chain.parts.first() else {
607 return Ok(Vec::new());
608 };
609 let mut candidates = all_paths(root)
610 .into_iter()
611 .filter(|path| matches_simple_selector(root, path, &first.simple))
612 .collect::<Vec<_>>();
613
614 for part in chain.parts.iter().skip(1) {
615 let mut next = BTreeSet::<Vec<usize>>::new();
616 for path in &candidates {
617 match part.combinator.unwrap_or(Combinator::Descendant) {
618 Combinator::Descendant => {
619 for descendant in descendant_paths(root, path) {
620 if matches_simple_selector(root, &descendant, &part.simple) {
621 next.insert(descendant);
622 }
623 }
624 }
625 Combinator::Child => {
626 for child in child_paths(root, path) {
627 if matches_simple_selector(root, &child, &part.simple) {
628 next.insert(child);
629 }
630 }
631 }
632 Combinator::AdjacentSibling => {
633 if let Some(sibling) = next_sibling_path(root, path) {
634 if matches_simple_selector(root, &sibling, &part.simple) {
635 next.insert(sibling);
636 }
637 }
638 }
639 Combinator::GeneralSibling => {
640 for sibling in following_sibling_paths(root, path) {
641 if matches_simple_selector(root, &sibling, &part.simple) {
642 next.insert(sibling);
643 }
644 }
645 }
646 }
647 }
648 candidates = next.into_iter().collect();
649 }
650 Ok(candidates)
651}
652
653fn all_paths(root: &HtmlNode) -> Vec<Vec<usize>> {
654 let mut out = Vec::new();
655 collect_paths(root, &mut Vec::new(), &mut out);
656 out
657}
658
659fn collect_paths(node: &HtmlNode, path: &mut Vec<usize>, out: &mut Vec<Vec<usize>>) {
660 out.push(path.clone());
661 if let HtmlNode::Element(element) = node {
662 for (idx, child) in element.children.iter().enumerate() {
663 path.push(idx);
664 collect_paths(child, path, out);
665 path.pop();
666 }
667 }
668}
669
670fn descendant_paths(root: &HtmlNode, path: &[usize]) -> Vec<Vec<usize>> {
671 let Some(node) = node_at(root, path) else {
672 return Vec::new();
673 };
674 let mut out = Vec::new();
675 let mut current = path.to_vec();
676 if let HtmlNode::Element(element) = node {
677 for (idx, child) in element.children.iter().enumerate() {
678 current.push(idx);
679 collect_paths(child, &mut current, &mut out);
680 current.pop();
681 }
682 }
683 out
684}
685
686fn child_paths(root: &HtmlNode, path: &[usize]) -> Vec<Vec<usize>> {
687 match node_at(root, path) {
688 Some(HtmlNode::Element(element)) => (0..element.children.len())
689 .map(|idx| {
690 let mut child = path.to_vec();
691 child.push(idx);
692 child
693 })
694 .collect(),
695 _ => Vec::new(),
696 }
697}
698
699fn next_sibling_path(root: &HtmlNode, path: &[usize]) -> Option<Vec<usize>> {
700 let (last, parent_path) = path.split_last()?;
701 let parent = node_at(root, parent_path)?;
702 let HtmlNode::Element(element) = parent else {
703 return None;
704 };
705 let mut next = *last + 1;
706 while next < element.children.len() {
707 if matches!(element.children.get(next), Some(HtmlNode::Element(_))) {
708 let mut sibling = parent_path.to_vec();
709 sibling.push(next);
710 return Some(sibling);
711 }
712 next += 1;
713 }
714 None
715}
716
717fn following_sibling_paths(root: &HtmlNode, path: &[usize]) -> Vec<Vec<usize>> {
718 let Some((last, parent_path)) = path.split_last() else {
719 return Vec::new();
720 };
721 let Some(HtmlNode::Element(parent)) = node_at(root, parent_path) else {
722 return Vec::new();
723 };
724 let mut out = Vec::new();
725 for index in (*last + 1)..parent.children.len() {
726 if matches!(parent.children.get(index), Some(HtmlNode::Element(_))) {
727 let mut sibling = parent_path.to_vec();
728 sibling.push(index);
729 out.push(sibling);
730 }
731 }
732 out
733}
734
735fn node_at<'a>(root: &'a HtmlNode, path: &[usize]) -> Option<&'a HtmlNode> {
736 let mut node = root;
737 for index in path {
738 let HtmlNode::Element(element) = node else {
739 return None;
740 };
741 node = element.children.get(*index)?;
742 }
743 Some(node)
744}
745
746fn matches_simple_selector(root: &HtmlNode, path: &[usize], selector: &SimpleSelector) -> bool {
747 node_at(root, path)
748 .map(|node| selector.matches(root, path, node))
749 .unwrap_or(false)
750}
751
752fn parent_name<'a>(root: &'a HtmlNode, path: &[usize]) -> Option<&'a str> {
753 let (_, parent_path) = path.split_last()?;
754 match node_at(root, parent_path) {
755 Some(HtmlNode::Element(element)) => Some(element.name.as_str()),
756 _ => None,
757 }
758}
759
760#[derive(Clone, Copy, Debug, PartialEq, Eq)]
761pub(in crate::builtins::strings::text_analytics) enum ExtractionMethod {
762 Tree,
763 Article,
764 AllText,
765}
766
767impl ExtractionMethod {
768 pub(in crate::builtins::strings::text_analytics) fn parse(value: &str) -> BuiltinResult<Self> {
769 match value.to_ascii_lowercase().as_str() {
770 "tree" => Ok(Self::Tree),
771 "article" => Ok(Self::Article),
772 "all-text" => Ok(Self::AllText),
773 other => Err(html_error(
774 "extractHTMLText",
775 format!(
776 "extractHTMLText: ExtractionMethod must be 'tree', 'article', or 'all-text', got '{other}'"
777 ),
778 )),
779 }
780 }
781}
782
783#[derive(Clone, Debug, PartialEq)]
784enum HtmlNode {
785 Element(HtmlElement),
786 Text(String),
787}
788
789#[derive(Clone, Debug, PartialEq)]
790struct HtmlElement {
791 name: String,
792 attrs: BTreeMap<String, String>,
793 children: Vec<HtmlNode>,
794 raw: String,
795}
796
797#[derive(Clone, Debug)]
798struct Selector {
799 chains: Vec<SelectorChain>,
800}
801
802#[derive(Clone, Debug)]
803struct SelectorChain {
804 parts: Vec<SelectorPart>,
805}
806
807#[derive(Clone, Copy, Debug, PartialEq, Eq)]
808enum Combinator {
809 Descendant,
810 Child,
811 AdjacentSibling,
812 GeneralSibling,
813}
814
815#[derive(Clone, Debug)]
816struct SelectorPart {
817 combinator: Option<Combinator>,
818 simple: SimpleSelector,
819}
820
821#[derive(Clone, Debug, Default)]
822struct SimpleSelector {
823 tag: Option<String>,
824 id: Option<String>,
825 classes: Vec<String>,
826 attrs: Vec<AttrSelector>,
827 empty: Option<bool>,
828 first_child: Option<bool>,
829 first_of_type: Option<bool>,
830}
831
832#[derive(Clone, Debug)]
833struct AttrSelector {
834 name: String,
835 op: AttrSelectorOp,
836 value: Option<String>,
837}
838
839#[derive(Clone, Copy, Debug, PartialEq, Eq)]
840enum AttrSelectorOp {
841 Exists,
842 Exact,
843 WhitespaceListContains,
844 DashPrefix,
845 Prefix,
846 Suffix,
847 Contains,
848}
849
850#[derive(Clone, Debug)]
851struct MatchedNode {
852 node: HtmlNode,
853 parent: Option<String>,
854}
855
856impl Selector {
857 fn parse(input: &str) -> BuiltinResult<Self> {
858 let input = input.trim();
859 if input.is_empty() {
860 return Err(html_error(
861 "findElement",
862 "findElement: selector must be nonempty",
863 ));
864 }
865 let mut chains = Vec::new();
866 for group in split_selector_groups(input)? {
867 let chain = SelectorChain::parse(group.trim())?;
868 if !chain.parts.is_empty() {
869 chains.push(chain);
870 }
871 }
872 if chains.is_empty() {
873 return Err(html_error(
874 "findElement",
875 "findElement: selector must contain at least one simple selector",
876 ));
877 }
878 Ok(Self { chains })
879 }
880}
881
882impl SelectorChain {
883 fn parse(input: &str) -> BuiltinResult<Self> {
884 let mut parts = Vec::new();
885 let mut index = 0;
886 let mut pending = None;
887
888 while index < input.len() {
889 let (after_space, had_space) = skip_selector_space(input, index);
890 index = after_space;
891 if index >= input.len() {
892 break;
893 }
894 let ch = input[index..].chars().next().expect("in bounds");
895 match ch {
896 '>' | '+' | '~' => {
897 if parts.is_empty() {
898 return Err(html_error(
899 "findElement",
900 "findElement: selector cannot start with a combinator",
901 ));
902 }
903 if pending.is_some() {
904 return Err(html_error(
905 "findElement",
906 "findElement: selector cannot contain repeated combinators",
907 ));
908 }
909 pending = Some(match ch {
910 '>' => Combinator::Child,
911 '+' => Combinator::AdjacentSibling,
912 '~' => Combinator::GeneralSibling,
913 _ => unreachable!("matched selector combinator"),
914 });
915 index += ch.len_utf8();
916 continue;
917 }
918 _ => {}
919 }
920 if had_space && !parts.is_empty() && pending.is_none() {
921 pending = Some(Combinator::Descendant);
922 }
923
924 let end = simple_selector_end(input, index)?;
925 if end == index {
926 return Err(html_error(
927 "findElement",
928 format!("findElement: invalid selector near '{}'", &input[index..]),
929 ));
930 }
931 let simple = SimpleSelector::parse(&input[index..end])?;
932 let combinator = if parts.is_empty() {
933 if pending.is_some() {
934 return Err(html_error(
935 "findElement",
936 "findElement: selector cannot start with a combinator",
937 ));
938 }
939 None
940 } else {
941 pending.take().or(Some(Combinator::Descendant))
942 };
943 parts.push(SelectorPart { combinator, simple });
944 index = end;
945 }
946
947 if pending.is_some() {
948 return Err(html_error(
949 "findElement",
950 "findElement: selector cannot end with a combinator",
951 ));
952 }
953 Ok(Self { parts })
954 }
955}
956
957impl SimpleSelector {
958 fn parse(input: &str) -> BuiltinResult<Self> {
959 let mut selector = Self::default();
960 let mut index = 0;
961
962 if let Some(ch) = input.chars().next() {
963 if ch == '*' {
964 index += ch.len_utf8();
965 } else if is_selector_ident_start(ch) {
966 let end = consume_selector_ident(input, index);
967 selector.tag = Some(input[index..end].to_ascii_lowercase());
968 index = end;
969 }
970 }
971
972 while index < input.len() {
973 let ch = input[index..].chars().next().expect("in bounds");
974 match ch {
975 '.' => {
976 index += ch.len_utf8();
977 let end = consume_selector_ident(input, index);
978 if end == index {
979 return Err(html_error(
980 "findElement",
981 "findElement: class selector requires a name",
982 ));
983 }
984 selector.classes.push(input[index..end].to_string());
985 index = end;
986 }
987 '#' => {
988 index += ch.len_utf8();
989 let end = consume_selector_ident(input, index);
990 if end == index {
991 return Err(html_error(
992 "findElement",
993 "findElement: id selector requires a name",
994 ));
995 }
996 selector.id = Some(input[index..end].to_string());
997 index = end;
998 }
999 '[' => {
1000 let Some(end) = matching_selector_bracket(input, index, '[', ']')? else {
1001 return Err(html_error(
1002 "findElement",
1003 "findElement: attribute selector is missing ']'",
1004 ));
1005 };
1006 selector
1007 .attrs
1008 .push(AttrSelector::parse(&input[index + 1..end])?);
1009 index = end + 1;
1010 }
1011 ':' => {
1012 let end = parse_pseudo_selector(input, index, &mut selector)?;
1013 index = end;
1014 }
1015 _ => {
1016 return Err(html_error(
1017 "findElement",
1018 format!("findElement: unsupported selector token '{ch}'"),
1019 ));
1020 }
1021 }
1022 }
1023
1024 Ok(selector)
1025 }
1026
1027 fn matches(&self, root: &HtmlNode, path: &[usize], node: &HtmlNode) -> bool {
1028 let HtmlNode::Element(element) = node else {
1029 return false;
1030 };
1031 if let Some(tag) = &self.tag {
1032 if !element.name.eq_ignore_ascii_case(tag) {
1033 return false;
1034 }
1035 }
1036 if let Some(id) = &self.id {
1037 if element.attrs.get("id") != Some(id) {
1038 return false;
1039 }
1040 }
1041 if !self.classes.is_empty() {
1042 let classes = element
1043 .attrs
1044 .get("class")
1045 .map(|value| value.split_whitespace().collect::<Vec<_>>())
1046 .unwrap_or_default();
1047 if self
1048 .classes
1049 .iter()
1050 .any(|class| !classes.iter().any(|existing| existing == class))
1051 {
1052 return false;
1053 }
1054 }
1055 for attr in &self.attrs {
1056 let Some(value) = element.attrs.get(&attr.name) else {
1057 return false;
1058 };
1059 if let Some(expected) = &attr.value {
1060 if !attr.op.matches(value, expected) {
1061 return false;
1062 }
1063 }
1064 }
1065 if let Some(expect_empty) = self.empty {
1066 let is_empty = element.children.iter().all(|child| match child {
1067 HtmlNode::Element(_) => false,
1068 HtmlNode::Text(text) => text.trim().is_empty(),
1069 });
1070 if is_empty != expect_empty {
1071 return false;
1072 }
1073 }
1074 if let Some(expect_first_child) = self.first_child {
1075 if is_first_element_child(root, path) != expect_first_child {
1076 return false;
1077 }
1078 }
1079 if let Some(expect_first_of_type) = self.first_of_type {
1080 if is_first_element_of_type(root, path, &element.name) != expect_first_of_type {
1081 return false;
1082 }
1083 }
1084 true
1085 }
1086}
1087
1088impl AttrSelector {
1089 fn parse(input: &str) -> BuiltinResult<Self> {
1090 let input = input.trim();
1091 if input.is_empty() {
1092 return Err(html_error(
1093 "findElement",
1094 "findElement: attribute selector requires a name",
1095 ));
1096 }
1097 let Some((name, op, raw_value)) = split_attr_selector(input) else {
1098 return Ok(Self {
1099 name: input.to_ascii_lowercase(),
1100 op: AttrSelectorOp::Exists,
1101 value: None,
1102 });
1103 };
1104 let name = name.trim();
1105 if name.is_empty() {
1106 return Err(html_error(
1107 "findElement",
1108 "findElement: attribute selector requires a name",
1109 ));
1110 }
1111 let value = strip_selector_quotes(raw_value.trim())?;
1112 Ok(Self {
1113 name: name.to_ascii_lowercase(),
1114 op,
1115 value: Some(decode_html_entities(value)),
1116 })
1117 }
1118}
1119
1120impl AttrSelectorOp {
1121 fn matches(self, actual: &str, expected: &str) -> bool {
1122 match self {
1123 Self::Exists => true,
1124 Self::Exact => actual == expected,
1125 Self::WhitespaceListContains => actual.split_whitespace().any(|part| part == expected),
1126 Self::DashPrefix => actual == expected || actual.starts_with(&format!("{expected}-")),
1127 Self::Prefix => actual.starts_with(expected),
1128 Self::Suffix => actual.ends_with(expected),
1129 Self::Contains => actual.contains(expected),
1130 }
1131 }
1132}
1133
1134fn split_attr_selector(input: &str) -> Option<(&str, AttrSelectorOp, &str)> {
1135 let mut quote = None;
1136 for (idx, ch) in input.char_indices() {
1137 match (quote, ch) {
1138 (Some(active), current) if current == active => quote = None,
1139 (Some(_), _) => {}
1140 (None, '"' | '\'') => quote = Some(ch),
1141 (None, '=') => {
1142 let before = input[..idx].trim_end();
1143 let Some((op, name)) = before
1144 .strip_suffix('~')
1145 .map(|name| (AttrSelectorOp::WhitespaceListContains, name))
1146 .or_else(|| {
1147 before
1148 .strip_suffix('|')
1149 .map(|name| (AttrSelectorOp::DashPrefix, name))
1150 })
1151 .or_else(|| {
1152 before
1153 .strip_suffix('^')
1154 .map(|name| (AttrSelectorOp::Prefix, name))
1155 })
1156 .or_else(|| {
1157 before
1158 .strip_suffix('$')
1159 .map(|name| (AttrSelectorOp::Suffix, name))
1160 })
1161 .or_else(|| {
1162 before
1163 .strip_suffix('*')
1164 .map(|name| (AttrSelectorOp::Contains, name))
1165 })
1166 else {
1167 return Some((before, AttrSelectorOp::Exact, &input[idx + ch.len_utf8()..]));
1168 };
1169 return Some((name, op, &input[idx + ch.len_utf8()..]));
1170 }
1171 _ => {}
1172 }
1173 }
1174 None
1175}
1176
1177fn split_selector_groups(input: &str) -> BuiltinResult<Vec<&str>> {
1178 let mut groups = Vec::new();
1179 let mut start = 0;
1180 let mut quote = None;
1181 let mut bracket_depth = 0usize;
1182 let mut paren_depth = 0usize;
1183 for (idx, ch) in input.char_indices() {
1184 match (quote, ch) {
1185 (Some(active), current) if current == active => quote = None,
1186 (Some(_), _) => {}
1187 (None, '"' | '\'') => quote = Some(ch),
1188 (None, '[') => bracket_depth += 1,
1189 (None, ']') => bracket_depth = bracket_depth.saturating_sub(1),
1190 (None, '(') => paren_depth += 1,
1191 (None, ')') => paren_depth = paren_depth.saturating_sub(1),
1192 (None, ',') if bracket_depth == 0 && paren_depth == 0 => {
1193 groups.push(&input[start..idx]);
1194 start = idx + ch.len_utf8();
1195 }
1196 _ => {}
1197 }
1198 }
1199 groups.push(&input[start..]);
1200 if groups.iter().any(|group| group.trim().is_empty()) {
1201 return Err(html_error(
1202 "findElement",
1203 "findElement: selector group must be nonempty",
1204 ));
1205 }
1206 Ok(groups)
1207}
1208
1209fn skip_selector_space(input: &str, mut index: usize) -> (usize, bool) {
1210 let mut skipped = false;
1211 while index < input.len() {
1212 let ch = input[index..].chars().next().expect("in bounds");
1213 if !ch.is_whitespace() {
1214 break;
1215 }
1216 skipped = true;
1217 index += ch.len_utf8();
1218 }
1219 (index, skipped)
1220}
1221
1222fn simple_selector_end(input: &str, start: usize) -> BuiltinResult<usize> {
1223 let mut quote = None;
1224 let mut bracket_depth = 0usize;
1225 let mut paren_depth = 0usize;
1226 for (rel, ch) in input[start..].char_indices() {
1227 let idx = start + rel;
1228 match (quote, ch) {
1229 (Some(active), current) if current == active => quote = None,
1230 (Some(_), _) => {}
1231 (None, '"' | '\'') => quote = Some(ch),
1232 (None, '[') => bracket_depth += 1,
1233 (None, ']') => {
1234 bracket_depth = bracket_depth.checked_sub(1).ok_or_else(|| {
1235 html_error("findElement", "findElement: unmatched ']' in selector")
1236 })?;
1237 }
1238 (None, '(') => paren_depth += 1,
1239 (None, ')') => {
1240 paren_depth = paren_depth.checked_sub(1).ok_or_else(|| {
1241 html_error("findElement", "findElement: unmatched ')' in selector")
1242 })?;
1243 }
1244 (None, '>' | '+' | '~') if bracket_depth == 0 && paren_depth == 0 => return Ok(idx),
1245 (None, current)
1246 if current.is_whitespace() && bracket_depth == 0 && paren_depth == 0 =>
1247 {
1248 return Ok(idx);
1249 }
1250 _ => {}
1251 }
1252 }
1253 if quote.is_some() || bracket_depth != 0 || paren_depth != 0 {
1254 return Err(html_error(
1255 "findElement",
1256 "findElement: selector has unclosed quote, bracket, or parenthesis",
1257 ));
1258 }
1259 Ok(input.len())
1260}
1261
1262fn matching_selector_bracket(
1263 input: &str,
1264 start: usize,
1265 open: char,
1266 close: char,
1267) -> BuiltinResult<Option<usize>> {
1268 let mut quote = None;
1269 let mut depth = 0usize;
1270 for (rel, ch) in input[start..].char_indices() {
1271 let idx = start + rel;
1272 match (quote, ch) {
1273 (Some(active), current) if current == active => quote = None,
1274 (Some(_), _) => {}
1275 (None, '"' | '\'') => quote = Some(ch),
1276 (None, current) if current == open => depth += 1,
1277 (None, current) if current == close => {
1278 depth = depth.checked_sub(1).ok_or_else(|| {
1279 html_error("findElement", "findElement: unmatched selector delimiter")
1280 })?;
1281 if depth == 0 {
1282 return Ok(Some(idx));
1283 }
1284 }
1285 _ => {}
1286 }
1287 }
1288 Ok(None)
1289}
1290
1291fn parse_pseudo_selector(
1292 input: &str,
1293 start: usize,
1294 selector: &mut SimpleSelector,
1295) -> BuiltinResult<usize> {
1296 let name_start = start + 1;
1297 let name_end = consume_selector_ident(input, name_start);
1298 if name_end == name_start {
1299 return Err(html_error(
1300 "findElement",
1301 "findElement: pseudo-class selector requires a name",
1302 ));
1303 }
1304 let name = &input[name_start..name_end];
1305 if name.eq_ignore_ascii_case("empty") {
1306 selector.empty = Some(true);
1307 return Ok(name_end);
1308 }
1309 if name.eq_ignore_ascii_case("first-child") {
1310 selector.first_child = Some(true);
1311 return Ok(name_end);
1312 }
1313 if name.eq_ignore_ascii_case("first-of-type") {
1314 selector.first_of_type = Some(true);
1315 return Ok(name_end);
1316 }
1317 if name.eq_ignore_ascii_case("not") {
1318 if !input[name_end..].starts_with('(') {
1319 return Err(html_error(
1320 "findElement",
1321 "findElement: :not selector requires parentheses",
1322 ));
1323 }
1324 let Some(close) = matching_selector_bracket(input, name_end, '(', ')')? else {
1325 return Err(html_error(
1326 "findElement",
1327 "findElement: :not selector is missing ')'",
1328 ));
1329 };
1330 let inner = input[name_end + 1..close].trim();
1331 apply_negated_simple_pseudo(inner, selector)?;
1332 return Ok(close + 1);
1333 }
1334 Err(html_error(
1335 "findElement",
1336 format!("findElement: unsupported pseudo-class ':{name}'"),
1337 ))
1338}
1339
1340fn apply_negated_simple_pseudo(input: &str, selector: &mut SimpleSelector) -> BuiltinResult<()> {
1341 let Some(name) = input.trim().strip_prefix(':') else {
1342 return Err(html_error(
1343 "findElement",
1344 "findElement: :not selector supports simple pseudo-classes only",
1345 ));
1346 };
1347 if name.eq_ignore_ascii_case("empty") {
1348 selector.empty = Some(false);
1349 return Ok(());
1350 }
1351 if name.eq_ignore_ascii_case("first-child") {
1352 selector.first_child = Some(false);
1353 return Ok(());
1354 }
1355 if name.eq_ignore_ascii_case("first-of-type") {
1356 selector.first_of_type = Some(false);
1357 return Ok(());
1358 }
1359 Err(html_error(
1360 "findElement",
1361 "findElement: :not selector supports :empty, :first-child, or :first-of-type",
1362 ))
1363}
1364
1365fn is_first_element_child(root: &HtmlNode, path: &[usize]) -> bool {
1366 let Some((last, parent_path)) = path.split_last() else {
1367 return false;
1368 };
1369 let Some(HtmlNode::Element(parent)) = node_at(root, parent_path) else {
1370 return false;
1371 };
1372 parent
1373 .children
1374 .iter()
1375 .position(|child| matches!(child, HtmlNode::Element(_)))
1376 == Some(*last)
1377}
1378
1379fn is_first_element_of_type(root: &HtmlNode, path: &[usize], name: &str) -> bool {
1380 let Some((last, parent_path)) = path.split_last() else {
1381 return false;
1382 };
1383 let Some(HtmlNode::Element(parent)) = node_at(root, parent_path) else {
1384 return false;
1385 };
1386 parent.children.iter().enumerate().find_map(|(idx, child)| {
1387 if matches!(child, HtmlNode::Element(element) if element.name.eq_ignore_ascii_case(name)) {
1388 Some(idx)
1389 } else {
1390 None
1391 }
1392 }) == Some(*last)
1393}
1394
1395fn strip_selector_quotes(input: &str) -> BuiltinResult<&str> {
1396 let Some(first) = input.chars().next() else {
1397 return Ok(input);
1398 };
1399 if first != '"' && first != '\'' {
1400 return Ok(input);
1401 }
1402 let Some(last) = input.chars().last() else {
1403 return Ok(input);
1404 };
1405 if first != last || input.len() < first.len_utf8() + last.len_utf8() {
1406 return Err(html_error(
1407 "findElement",
1408 "findElement: attribute selector has an unterminated quoted value",
1409 ));
1410 }
1411 Ok(&input[first.len_utf8()..input.len() - last.len_utf8()])
1412}
1413
1414fn consume_selector_ident(input: &str, start: usize) -> usize {
1415 let mut end = start;
1416 for (rel, ch) in input[start..].char_indices() {
1417 if rel == 0 {
1418 if !is_selector_ident_start(ch) {
1419 break;
1420 }
1421 } else if !is_selector_ident_continue(ch) {
1422 break;
1423 }
1424 end = start + rel + ch.len_utf8();
1425 }
1426 end
1427}
1428
1429fn is_selector_ident_start(ch: char) -> bool {
1430 ch.is_ascii_alphabetic() || ch == '_' || ch == '-'
1431}
1432
1433fn is_selector_ident_continue(ch: char) -> bool {
1434 is_selector_ident_start(ch) || ch.is_ascii_digit()
1435}
1436
1437#[derive(Debug)]
1438struct OpenElement {
1439 name: String,
1440 attrs: BTreeMap<String, String>,
1441 children: Vec<HtmlNode>,
1442 start: usize,
1443}
1444
1445fn parse_html_tree(html: &str) -> BuiltinResult<HtmlNode> {
1446 let mut stack = vec![OpenElement {
1447 name: "document".to_string(),
1448 attrs: BTreeMap::new(),
1449 children: Vec::new(),
1450 start: 0,
1451 }];
1452 let mut cursor = 0;
1453
1454 while let Some(rel_start) = html[cursor..].find('<') {
1455 let tag_start = cursor + rel_start;
1456 push_text(&mut stack, &html[cursor..tag_start]);
1457 let Some(tag_end) = find_tag_end(html, tag_start) else {
1458 push_text(&mut stack, &html[tag_start..]);
1459 cursor = html.len();
1460 break;
1461 };
1462 let token = &html[tag_start + 1..tag_end];
1463 cursor = tag_end + 1;
1464
1465 if token.starts_with("!--") {
1466 if let Some(close) = html[tag_start + 4..].find("-->") {
1467 cursor = tag_start + 4 + close + 3;
1468 }
1469 continue;
1470 }
1471
1472 let trimmed = token.trim();
1473 if trimmed.is_empty() || trimmed.starts_with('!') || trimmed.starts_with('?') {
1474 continue;
1475 }
1476
1477 if let Some(rest) = trimmed.strip_prefix('/') {
1478 let name = tag_name(rest).to_ascii_lowercase();
1479 close_element(&mut stack, &name, html, cursor);
1480 continue;
1481 }
1482
1483 let self_closing = trimmed.ends_with('/') || is_void_tag(tag_name(trimmed));
1484 let (name, attrs) = parse_open_tag(trimmed);
1485 if name.is_empty() {
1486 continue;
1487 }
1488 stack.push(OpenElement {
1489 name: name.to_ascii_lowercase(),
1490 attrs,
1491 children: Vec::new(),
1492 start: tag_start,
1493 });
1494 if self_closing {
1495 let lower = stack
1496 .last()
1497 .map(|open| open.name.clone())
1498 .unwrap_or_default();
1499 close_element(&mut stack, &lower, html, cursor);
1500 }
1501 }
1502
1503 if cursor < html.len() {
1504 push_text(&mut stack, &html[cursor..]);
1505 }
1506
1507 while stack.len() > 1 {
1508 close_top(&mut stack, html, html.len());
1509 }
1510
1511 let root = stack.pop().expect("root element");
1512 let element_children = root
1513 .children
1514 .iter()
1515 .filter(|child| matches!(child, HtmlNode::Element(_)))
1516 .count();
1517 if element_children == 1 && root.children.iter().all(|child| !is_nonblank_text(child)) {
1518 Ok(root
1519 .children
1520 .into_iter()
1521 .find(|child| matches!(child, HtmlNode::Element(_)))
1522 .expect("one element child"))
1523 } else {
1524 let raw = html.to_string();
1525 Ok(HtmlNode::Element(HtmlElement {
1526 name: "html".to_string(),
1527 attrs: BTreeMap::new(),
1528 children: root.children,
1529 raw,
1530 }))
1531 }
1532}
1533
1534fn push_text(stack: &mut [OpenElement], text: &str) {
1535 if !text.is_empty() {
1536 if let Some(current) = stack.last_mut() {
1537 current.children.push(HtmlNode::Text(text.to_string()));
1538 }
1539 }
1540}
1541
1542fn find_tag_end(html: &str, tag_start: usize) -> Option<usize> {
1543 let mut quote: Option<char> = None;
1544 for (offset, ch) in html[tag_start + 1..].char_indices() {
1545 match (quote, ch) {
1546 (Some(active), current) if current == active => quote = None,
1547 (None, '"' | '\'') => quote = Some(ch),
1548 (None, '>') => return Some(tag_start + 1 + offset),
1549 _ => {}
1550 }
1551 }
1552 None
1553}
1554
1555fn close_element(stack: &mut Vec<OpenElement>, name: &str, html: &str, end: usize) {
1556 let Some(pos) = stack.iter().rposition(|open| open.name == name) else {
1557 return;
1558 };
1559 while stack.len() > pos && stack.len() > 1 {
1560 close_top(stack, html, end);
1561 }
1562}
1563
1564fn close_top(stack: &mut Vec<OpenElement>, html: &str, end: usize) {
1565 let Some(open) = stack.pop() else {
1566 return;
1567 };
1568 let safe_end = end.min(html.len()).max(open.start.min(html.len()));
1569 let raw = html[open.start.min(html.len())..safe_end].to_string();
1570 let node = HtmlNode::Element(HtmlElement {
1571 name: open.name,
1572 attrs: open.attrs,
1573 children: open.children,
1574 raw,
1575 });
1576 if let Some(parent) = stack.last_mut() {
1577 parent.children.push(node);
1578 }
1579}
1580
1581fn tag_name(token: &str) -> &str {
1582 token
1583 .trim_start()
1584 .split(|ch: char| ch.is_whitespace() || ch == '/' || ch == '>')
1585 .next()
1586 .unwrap_or("")
1587}
1588
1589fn parse_open_tag(token: &str) -> (String, BTreeMap<String, String>) {
1590 let mut rest = token.trim().trim_end_matches('/').trim();
1591 let name = tag_name(rest).to_string();
1592 rest = rest.get(name.len()..).unwrap_or("").trim();
1593 (name, parse_attrs(rest))
1594}
1595
1596fn parse_attrs(mut rest: &str) -> BTreeMap<String, String> {
1597 let mut attrs = BTreeMap::new();
1598 while !rest.trim_start().is_empty() {
1599 rest = rest.trim_start();
1600 let name_end = rest
1601 .find(|ch: char| ch.is_whitespace() || ch == '=' || ch == '/')
1602 .unwrap_or(rest.len());
1603 if name_end == 0 {
1604 break;
1605 }
1606 let name = rest[..name_end].to_ascii_lowercase();
1607 rest = rest[name_end..].trim_start();
1608 let mut value = String::new();
1609 if let Some(after_eq) = rest.strip_prefix('=') {
1610 rest = after_eq.trim_start();
1611 if let Some(quote) = rest.chars().next().filter(|ch| *ch == '"' || *ch == '\'') {
1612 rest = &rest[quote.len_utf8()..];
1613 if let Some(end) = rest.find(quote) {
1614 value = decode_html_entities(&rest[..end]);
1615 rest = &rest[end + quote.len_utf8()..];
1616 } else {
1617 value = decode_html_entities(rest);
1618 rest = "";
1619 }
1620 } else {
1621 let value_end = rest
1622 .find(|ch: char| ch.is_whitespace() || ch == '/')
1623 .unwrap_or(rest.len());
1624 value = decode_html_entities(&rest[..value_end]);
1625 rest = &rest[value_end..];
1626 }
1627 }
1628 attrs.insert(name, value);
1629 }
1630 attrs
1631}
1632
1633fn is_void_tag(name: &str) -> bool {
1634 matches!(
1635 name.to_ascii_lowercase().as_str(),
1636 "area"
1637 | "base"
1638 | "br"
1639 | "col"
1640 | "embed"
1641 | "hr"
1642 | "img"
1643 | "input"
1644 | "link"
1645 | "meta"
1646 | "param"
1647 | "source"
1648 | "track"
1649 | "wbr"
1650 )
1651}
1652
1653fn is_nonblank_text(node: &HtmlNode) -> bool {
1654 matches!(node, HtmlNode::Text(text) if !text.trim().is_empty())
1655}
1656
1657impl HtmlNode {
1658 fn into_object(self, parent_name: Option<&str>) -> BuiltinResult<ObjectInstance> {
1659 let mut object = ObjectInstance::new(HTML_TREE_CLASS.to_string());
1660 match self {
1661 HtmlNode::Text(text) => {
1662 let decoded = normalize_space(&decode_html_entities(&text));
1663 object
1664 .properties
1665 .insert("Name".to_string(), Value::String("#text".to_string()));
1666 object
1667 .properties
1668 .insert("RawHTML".to_string(), Value::String(text));
1669 object
1670 .properties
1671 .insert("Text".to_string(), Value::String(decoded));
1672 object
1673 .properties
1674 .insert("Attributes".to_string(), Value::Struct(StructValue::new()));
1675 object.properties.insert(
1676 "Children".to_string(),
1677 Value::Cell(
1678 CellArray::new(Vec::new(), 0, 1)
1679 .map_err(|err| html_error("htmlTree", format!("htmlTree: {err}")))?,
1680 ),
1681 );
1682 }
1683 HtmlNode::Element(element) => {
1684 let name = element.name.to_ascii_uppercase();
1685 let text = HtmlNode::Element(element.clone()).extract_text(ExtractionMethod::Tree);
1686 let mut attr_struct = StructValue::new();
1687 for (key, value) in &element.attrs {
1688 attr_struct.insert(key.clone(), Value::String(value.clone()));
1689 }
1690 let children = element
1691 .children
1692 .into_iter()
1693 .map(|child| child.into_object(Some(&name)).map(Value::Object))
1694 .collect::<BuiltinResult<Vec<_>>>()?;
1695 object
1696 .properties
1697 .insert("Name".to_string(), Value::String(name));
1698 object
1699 .properties
1700 .insert("RawHTML".to_string(), Value::String(element.raw));
1701 object
1702 .properties
1703 .insert("Text".to_string(), Value::String(text));
1704 object
1705 .properties
1706 .insert("Attributes".to_string(), Value::Struct(attr_struct));
1707 object.properties.insert(
1708 "Children".to_string(),
1709 Value::Cell(
1710 CellArray::new(children.clone(), children.len(), 1)
1711 .map_err(|err| html_error("htmlTree", format!("htmlTree: {err}")))?,
1712 ),
1713 );
1714 }
1715 }
1716 object.properties.insert(
1717 "Parent".to_string(),
1718 Value::String(parent_name.unwrap_or(MISSING).to_string()),
1719 );
1720 Ok(object)
1721 }
1722
1723 fn extract_text(&self, method: ExtractionMethod) -> String {
1724 let body = find_body(self).unwrap_or(self);
1725 match method {
1726 ExtractionMethod::Tree | ExtractionMethod::Article => paragraph_text(body),
1727 ExtractionMethod::AllText => all_text(body),
1728 }
1729 }
1730}
1731
1732fn find_body(node: &HtmlNode) -> Option<&HtmlNode> {
1733 match node {
1734 HtmlNode::Element(element) if element.name.eq_ignore_ascii_case("body") => Some(node),
1735 HtmlNode::Element(element) => element.children.iter().find_map(find_body),
1736 HtmlNode::Text(_) => None,
1737 }
1738}
1739
1740fn paragraph_text(node: &HtmlNode) -> String {
1741 let mut blocks = Vec::new();
1742 collect_blocks(node, &mut blocks);
1743 if blocks.is_empty() {
1744 normalize_space(&all_text(node))
1745 } else {
1746 blocks.join("\n\n")
1747 }
1748}
1749
1750fn collect_blocks(node: &HtmlNode, blocks: &mut Vec<String>) {
1751 match node {
1752 HtmlNode::Text(_) => {}
1753 HtmlNode::Element(element) => {
1754 if should_skip_text(&element.name) {
1755 return;
1756 }
1757 if is_block_tag(&element.name) {
1758 let inline = inline_text(element);
1759 if !inline.is_empty() {
1760 blocks.push(inline);
1761 }
1762 }
1763 for child in &element.children {
1764 collect_blocks(child, blocks);
1765 }
1766 }
1767 }
1768}
1769
1770fn inline_text(element: &HtmlElement) -> String {
1771 let mut text = String::new();
1772 for child in &element.children {
1773 match child {
1774 HtmlNode::Text(raw) => push_decoded_text(&mut text, raw),
1775 HtmlNode::Element(child_element) => {
1776 if should_skip_text(&child_element.name) || is_block_tag(&child_element.name) {
1777 continue;
1778 }
1779 let nested = all_text(child);
1780 if !nested.is_empty() {
1781 if !text.ends_with(char::is_whitespace) && !text.is_empty() {
1782 text.push(' ');
1783 }
1784 text.push_str(&nested);
1785 }
1786 }
1787 }
1788 }
1789 normalize_space(&text)
1790}
1791
1792fn all_text(node: &HtmlNode) -> String {
1793 let mut text = String::new();
1794 collect_all_text(node, &mut text);
1795 normalize_space(&text)
1796}
1797
1798fn collect_all_text(node: &HtmlNode, out: &mut String) {
1799 match node {
1800 HtmlNode::Text(raw) => push_decoded_text(out, raw),
1801 HtmlNode::Element(element) => {
1802 if should_skip_text(&element.name) {
1803 return;
1804 }
1805 if element.name.eq_ignore_ascii_case("br") {
1806 out.push(' ');
1807 return;
1808 }
1809 for child in &element.children {
1810 collect_all_text(child, out);
1811 if matches!(child, HtmlNode::Element(child_element) if is_block_tag(&child_element.name))
1812 {
1813 out.push(' ');
1814 }
1815 }
1816 }
1817 }
1818}
1819
1820fn push_decoded_text(out: &mut String, raw: &str) {
1821 let decoded = decode_html_entities(raw);
1822 if !decoded.is_empty() {
1823 if !out.ends_with(char::is_whitespace) && !out.is_empty() {
1824 out.push(' ');
1825 }
1826 out.push_str(&decoded);
1827 }
1828}
1829
1830fn normalize_space(text: &str) -> String {
1831 let mut out = String::new();
1832 let mut in_space = false;
1833 for ch in text.chars() {
1834 if ch.is_whitespace() {
1835 in_space = true;
1836 } else {
1837 if in_space && !out.is_empty() {
1838 out.push(' ');
1839 }
1840 out.push(ch);
1841 in_space = false;
1842 }
1843 }
1844 out
1845}
1846
1847fn should_skip_text(name: &str) -> bool {
1848 matches!(
1849 name.to_ascii_lowercase().as_str(),
1850 "script" | "style" | "noscript" | "template" | "head"
1851 )
1852}
1853
1854fn is_block_tag(name: &str) -> bool {
1855 matches!(
1856 name.to_ascii_lowercase().as_str(),
1857 "address"
1858 | "article"
1859 | "aside"
1860 | "blockquote"
1861 | "body"
1862 | "dd"
1863 | "div"
1864 | "dl"
1865 | "dt"
1866 | "fieldset"
1867 | "figcaption"
1868 | "figure"
1869 | "footer"
1870 | "form"
1871 | "h1"
1872 | "h2"
1873 | "h3"
1874 | "h4"
1875 | "h5"
1876 | "h6"
1877 | "header"
1878 | "hr"
1879 | "li"
1880 | "main"
1881 | "nav"
1882 | "ol"
1883 | "p"
1884 | "pre"
1885 | "section"
1886 | "table"
1887 | "tbody"
1888 | "td"
1889 | "tfoot"
1890 | "th"
1891 | "thead"
1892 | "tr"
1893 | "ul"
1894 )
1895}
1896
1897fn decode_html_entities(input: &str) -> String {
1898 let mut out = String::with_capacity(input.len());
1899 let mut rest = input;
1900 while let Some(pos) = rest.find('&') {
1901 out.push_str(&rest[..pos]);
1902 let after_amp = &rest[pos + 1..];
1903 let Some(semi) = after_amp.find(';') else {
1904 out.push('&');
1905 rest = after_amp;
1906 continue;
1907 };
1908 let entity = &after_amp[..semi];
1909 if let Some(decoded) = decode_entity(entity) {
1910 out.push(decoded);
1911 } else {
1912 out.push('&');
1913 out.push_str(entity);
1914 out.push(';');
1915 }
1916 rest = &after_amp[semi + 1..];
1917 }
1918 out.push_str(rest);
1919 out
1920}
1921
1922fn decode_entity(entity: &str) -> Option<char> {
1923 match entity {
1924 "amp" => Some('&'),
1925 "lt" => Some('<'),
1926 "gt" => Some('>'),
1927 "quot" => Some('"'),
1928 "apos" => Some('\''),
1929 "nbsp" => Some(' '),
1930 "copy" => Some('\u{00A9}'),
1931 "reg" => Some('\u{00AE}'),
1932 "trade" => Some('\u{2122}'),
1933 value if value.starts_with("#x") || value.starts_with("#X") => {
1934 u32::from_str_radix(&value[2..], 16)
1935 .ok()
1936 .and_then(char::from_u32)
1937 }
1938 value if value.starts_with('#') => value[1..].parse::<u32>().ok().and_then(char::from_u32),
1939 _ => None,
1940 }
1941}
1942
1943#[cfg(test)]
1944mod tests {
1945 use super::*;
1946
1947 fn string_value(value: Value) -> String {
1948 match value {
1949 Value::String(text) => text,
1950 other => panic!("expected string, got {other:?}"),
1951 }
1952 }
1953
1954 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1955 #[test]
1956 fn extracts_text_from_scalar_html() {
1957 let html = Value::String(
1958 "<html><body><h1>THE SONNETS</h1><p>by William Shakespeare</p></body></html>"
1959 .to_string(),
1960 );
1961 let out =
1962 futures::executor::block_on(extract_html_text_builtin(vec![html])).expect("extract");
1963 assert_eq!(string_value(out), "THE SONNETS\n\nby William Shakespeare");
1964 }
1965
1966 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1967 #[test]
1968 fn extraction_preserves_string_array_shape() {
1969 let input = StringArray::new(
1970 vec![
1971 "<p>alpha</p>".to_string(),
1972 "<div>beta two</div>".to_string(),
1973 ],
1974 vec![1, 2],
1975 )
1976 .unwrap();
1977 let out =
1978 futures::executor::block_on(extract_html_text_builtin(vec![Value::StringArray(input)]))
1979 .expect("extract");
1980 let Value::StringArray(array) = out else {
1981 panic!("expected string array");
1982 };
1983 assert_eq!(array.shape, vec![1, 2]);
1984 assert_eq!(array.data, vec!["alpha", "beta two"]);
1985 }
1986
1987 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1988 #[test]
1989 fn all_text_skips_scripts_and_styles() {
1990 let html = Value::String(
1991 "<body><style>.x{}</style><p>visible</p><script>hidden()</script><span>tail</span></body>"
1992 .to_string(),
1993 );
1994 let out = futures::executor::block_on(extract_html_text_builtin(vec![
1995 html,
1996 Value::String("ExtractionMethod".to_string()),
1997 Value::String("all-text".to_string()),
1998 ]))
1999 .expect("extract");
2000 assert_eq!(string_value(out), "visible tail");
2001 }
2002
2003 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2004 #[test]
2005 fn quoted_attribute_gt_does_not_end_tag() {
2006 let html = Value::String("<p title=\"1 > 0\">ok & done</p>".to_string());
2007 let out =
2008 futures::executor::block_on(extract_html_text_builtin(vec![html])).expect("extract");
2009 assert_eq!(string_value(out), "ok & done");
2010 }
2011
2012 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2013 #[test]
2014 fn extraction_accepts_cell_array_of_text() {
2015 let cell = CellArray::new(
2016 vec![
2017 Value::String("<p>first</p>".to_string()),
2018 Value::String("<p>second</p>".to_string()),
2019 ],
2020 2,
2021 1,
2022 )
2023 .unwrap();
2024 let out = futures::executor::block_on(extract_html_text_builtin(vec![Value::Cell(cell)]))
2025 .expect("extract");
2026 let Value::StringArray(array) = out else {
2027 panic!("expected string array");
2028 };
2029 assert_eq!(array.shape, vec![2, 1]);
2030 assert_eq!(array.data, vec!["first", "second"]);
2031 }
2032
2033 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2034 #[test]
2035 fn html_tree_returns_object_with_core_properties() {
2036 let tree = futures::executor::block_on(html_tree_builtin(vec![Value::String(
2037 "<html><body><p class='lead'>RunMat</p></body></html>".to_string(),
2038 )]))
2039 .expect("tree");
2040 let Value::Object(object) = tree else {
2041 panic!("expected object");
2042 };
2043 assert_eq!(object.class_name, HTML_TREE_CLASS);
2044 assert_eq!(
2045 object.properties.get("Name"),
2046 Some(&Value::String("HTML".to_string()))
2047 );
2048 assert_eq!(
2049 object.properties.get("Text"),
2050 Some(&Value::String("RunMat".to_string()))
2051 );
2052 assert!(matches!(
2053 object.properties.get("Children"),
2054 Some(Value::Cell(_))
2055 ));
2056 }
2057
2058 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2059 #[test]
2060 fn html_tree_preserves_array_shape_as_cell_of_objects() {
2061 let input = StringArray::new(
2062 vec!["<p>one</p>".to_string(), "<p>two</p>".to_string()],
2063 vec![1, 2],
2064 )
2065 .unwrap();
2066 let out = futures::executor::block_on(html_tree_builtin(vec![Value::StringArray(input)]))
2067 .expect("tree");
2068 let Value::Cell(cell) = out else {
2069 panic!("expected cell");
2070 };
2071 assert_eq!(cell.shape, vec![1, 2]);
2072 assert!(cell.data.iter().all(
2073 |value| matches!(value, Value::Object(object) if object.is_class(HTML_TREE_CLASS))
2074 ));
2075 }
2076
2077 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2078 #[test]
2079 fn extracts_from_html_tree_object() {
2080 let tree = futures::executor::block_on(html_tree_builtin(vec![Value::String(
2081 "<article><h2>Title</h2><p>Body & tail</p></article>".to_string(),
2082 )]))
2083 .expect("tree");
2084 let out =
2085 futures::executor::block_on(extract_html_text_builtin(vec![tree])).expect("extract");
2086 assert_eq!(string_value(out), "Title\n\nBody & tail");
2087 }
2088
2089 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2090 #[test]
2091 fn find_element_supports_common_selectors_and_attribute_reads() {
2092 let tree = futures::executor::block_on(html_tree_builtin(vec![Value::String(
2093 "<div><a id='home' class='nav primary' href='/home'>Home</a><a class='nav'>Docs</a><p data-kind='lead'>Lead</p></div>"
2094 .to_string(),
2095 )]))
2096 .expect("tree");
2097 let links = futures::executor::block_on(find_element_builtin(vec![
2098 tree.clone(),
2099 Value::String("a.nav".to_string()),
2100 ]))
2101 .expect("find links");
2102 let Value::Cell(link_cell) = links else {
2103 panic!("expected cell of htmlTree objects");
2104 };
2105 assert_eq!(link_cell.shape, vec![2, 1]);
2106
2107 let link_text = futures::executor::block_on(extract_html_text_builtin(vec![Value::Cell(
2108 link_cell.clone(),
2109 )]))
2110 .expect("extract link text");
2111 let Value::StringArray(link_text) = link_text else {
2112 panic!("expected string array");
2113 };
2114 assert_eq!(link_text.shape, vec![2, 1]);
2115 assert_eq!(link_text.data, vec!["Home", "Docs"]);
2116
2117 let hrefs = futures::executor::block_on(get_attribute_builtin(vec![
2118 Value::Cell(link_cell),
2119 Value::String("href".to_string()),
2120 ]))
2121 .expect("hrefs");
2122 let Value::StringArray(hrefs) = hrefs else {
2123 panic!("expected string array");
2124 };
2125 assert_eq!(hrefs.shape, vec![2, 1]);
2126 assert_eq!(hrefs.data, vec!["/home", MISSING]);
2127
2128 let lead = futures::executor::block_on(find_element_builtin(vec![
2129 tree.clone(),
2130 Value::String("[data-kind=lead]".to_string()),
2131 ]))
2132 .expect("find attr");
2133 let Value::Cell(lead) = lead else {
2134 panic!("expected cell");
2135 };
2136 assert_eq!(lead.shape, vec![1, 1]);
2137
2138 let home = futures::executor::block_on(find_element_builtin(vec![
2139 tree,
2140 Value::String("#home".to_string()),
2141 ]))
2142 .expect("find id");
2143 let Value::Cell(home) = home else {
2144 panic!("expected cell");
2145 };
2146 assert_eq!(home.shape, vec![1, 1]);
2147 }
2148
2149 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2150 #[test]
2151 fn find_element_supports_combinators_empty_not_empty_and_groups() {
2152 let tree = futures::executor::block_on(html_tree_builtin(vec![Value::String(
2153 "<section><h1>Title</h1>\n<p class='lead'>Lead</p><label></label><label>Name</label></section>"
2154 .to_string(),
2155 )]))
2156 .expect("tree");
2157 let adjacent = futures::executor::block_on(find_element_builtin(vec![
2158 tree.clone(),
2159 Value::String("h1 + p".to_string()),
2160 ]))
2161 .expect("adjacent");
2162 let Value::Cell(adjacent) = adjacent else {
2163 panic!("expected cell");
2164 };
2165 assert_eq!(adjacent.shape, vec![1, 1]);
2166
2167 let child = futures::executor::block_on(find_element_builtin(vec![
2168 tree.clone(),
2169 Value::String("section > p.lead".to_string()),
2170 ]))
2171 .expect("child");
2172 let Value::Cell(child) = child else {
2173 panic!("expected cell");
2174 };
2175 assert_eq!(child.shape, vec![1, 1]);
2176
2177 let empty = futures::executor::block_on(find_element_builtin(vec![
2178 tree.clone(),
2179 Value::String("label:empty".to_string()),
2180 ]))
2181 .expect("empty");
2182 let Value::Cell(empty) = empty else {
2183 panic!("expected cell");
2184 };
2185 assert_eq!(empty.shape, vec![1, 1]);
2186
2187 let not_empty = futures::executor::block_on(find_element_builtin(vec![
2188 tree,
2189 Value::String("label:not(:empty), h1".to_string()),
2190 ]))
2191 .expect("not empty");
2192 let Value::Cell(not_empty) = not_empty else {
2193 panic!("expected cell");
2194 };
2195 assert_eq!(not_empty.shape, vec![2, 1]);
2196 let text =
2197 futures::executor::block_on(extract_html_text_builtin(vec![Value::Cell(not_empty)]))
2198 .expect("extract group order");
2199 let Value::StringArray(text) = text else {
2200 panic!("expected string array");
2201 };
2202 assert_eq!(text.data, vec!["Title", "Name"]);
2203 }
2204
2205 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2206 #[test]
2207 fn get_attribute_returns_missing_for_absent_scalar_attribute() {
2208 let tree = futures::executor::block_on(html_tree_builtin(vec![Value::String(
2209 "<a href='/home'>Home</a>".to_string(),
2210 )]))
2211 .expect("tree");
2212 let out = futures::executor::block_on(get_attribute_builtin(vec![
2213 tree,
2214 Value::String("title".to_string()),
2215 ]))
2216 .expect("attribute");
2217 assert_eq!(string_value(out), MISSING);
2218 }
2219
2220 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2221 #[test]
2222 fn find_element_handles_quoted_attribute_selectors_and_empty_results() {
2223 let tree = futures::executor::block_on(html_tree_builtin(vec![Value::String(
2224 "<div><p data-kind=\"lead item\">Lead</p></div>".to_string(),
2225 )]))
2226 .expect("tree");
2227 let quoted = futures::executor::block_on(find_element_builtin(vec![
2228 tree.clone(),
2229 Value::String("p[data-kind=\"lead item\"]".to_string()),
2230 ]))
2231 .expect("quoted attribute selector");
2232 let Value::Cell(quoted) = quoted else {
2233 panic!("expected cell");
2234 };
2235 assert_eq!(quoted.shape, vec![1, 1]);
2236
2237 let missing = futures::executor::block_on(find_element_builtin(vec![
2238 tree,
2239 Value::String("span.unknown".to_string()),
2240 ]))
2241 .expect("empty result");
2242 let Value::Cell(missing) = missing else {
2243 panic!("expected cell");
2244 };
2245 assert_eq!(missing.shape, vec![0, 1]);
2246 assert!(missing.data.is_empty());
2247 }
2248
2249 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2250 #[test]
2251 fn find_element_supports_css_attribute_operator_selectors() {
2252 let tree = futures::executor::block_on(html_tree_builtin(vec![Value::String(
2253 "<div><a href='manual.pdf' hreflang='en-US' rel='tag external' data-code='abc-123'>Manual</a><a href='guide.html' hreflang='en' rel='external' data-code='xyz'>Guide</a><a href='notes.txt' data-code='a$=b'>Notes</a></div>"
2254 .to_string(),
2255 )]))
2256 .expect("tree");
2257
2258 for (selector, expected) in [
2259 ("a[href$='.pdf']", vec!["Manual"]),
2260 ("a[href^='guide']", vec!["Guide"]),
2261 ("a[rel~='tag']", vec!["Manual"]),
2262 ("a[hreflang|='en']", vec!["Manual", "Guide"]),
2263 ("a[data-code*='bc-']", vec!["Manual"]),
2264 ("a[data-code='a$=b']", vec!["Notes"]),
2265 ] {
2266 let matches = futures::executor::block_on(find_element_builtin(vec![
2267 tree.clone(),
2268 Value::String(selector.to_string()),
2269 ]))
2270 .unwrap_or_else(|err| panic!("{selector} failed: {err}"));
2271 let text = futures::executor::block_on(extract_html_text_builtin(vec![matches]))
2272 .unwrap_or_else(|err| panic!("{selector} text extraction failed: {err}"));
2273 match text {
2274 Value::String(text) => assert_eq!(vec![text], expected),
2275 Value::StringArray(array) => assert_eq!(array.data, expected),
2276 other => panic!("expected extracted text for {selector}, got {other:?}"),
2277 }
2278 }
2279 }
2280
2281 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2282 #[test]
2283 fn find_element_supports_first_child_first_of_type_and_general_sibling() {
2284 let tree = futures::executor::block_on(html_tree_builtin(vec![Value::String(
2285 "<section><p>Intro</p><span>Aside</span><a>First link</a><a>Second link</a></section>"
2286 .to_string(),
2287 )]))
2288 .expect("tree");
2289
2290 let first_child = futures::executor::block_on(find_element_builtin(vec![
2291 tree.clone(),
2292 Value::String("p:first-child".to_string()),
2293 ]))
2294 .expect("first child");
2295 let text = futures::executor::block_on(extract_html_text_builtin(vec![first_child]))
2296 .expect("first child text");
2297 assert_eq!(string_value(text), "Intro");
2298
2299 let first_of_type = futures::executor::block_on(find_element_builtin(vec![
2300 tree.clone(),
2301 Value::String("a:first-of-type".to_string()),
2302 ]))
2303 .expect("first of type");
2304 let text = futures::executor::block_on(extract_html_text_builtin(vec![first_of_type]))
2305 .expect("first of type text");
2306 assert_eq!(string_value(text), "First link");
2307
2308 let following_links = futures::executor::block_on(find_element_builtin(vec![
2309 tree,
2310 Value::String("p ~ a".to_string()),
2311 ]))
2312 .expect("general sibling");
2313 let text = futures::executor::block_on(extract_html_text_builtin(vec![following_links]))
2314 .expect("general sibling text");
2315 let Value::StringArray(text) = text else {
2316 panic!("expected string array");
2317 };
2318 assert_eq!(text.data, vec!["First link", "Second link"]);
2319 }
2320
2321 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2322 #[test]
2323 fn find_element_rejects_unsupported_selectors() {
2324 let tree =
2325 futures::executor::block_on(html_tree_builtin(vec![Value::String("<p>x</p>".into())]))
2326 .expect("tree");
2327 let err = futures::executor::block_on(find_element_builtin(vec![
2328 tree,
2329 Value::String("p:hover".to_string()),
2330 ]))
2331 .expect_err("unsupported selector");
2332 assert!(err.to_string().contains("unsupported pseudo-class"));
2333
2334 let tree =
2335 futures::executor::block_on(html_tree_builtin(vec![Value::String("<p>x</p>".into())]))
2336 .expect("tree");
2337 let err = futures::executor::block_on(find_element_builtin(vec![
2338 tree,
2339 Value::String("div > + p".to_string()),
2340 ]))
2341 .expect_err("repeated combinator");
2342 assert!(err.to_string().contains("repeated combinators"));
2343 }
2344
2345 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2346 #[test]
2347 fn invalid_extraction_method_errors() {
2348 let err = futures::executor::block_on(extract_html_text_builtin(vec![
2349 Value::String("<p>x</p>".to_string()),
2350 Value::String("ExtractionMethod".to_string()),
2351 Value::String("summary".to_string()),
2352 ]))
2353 .expect_err("invalid method");
2354 assert!(err.to_string().contains("ExtractionMethod"));
2355 }
2356}