Skip to main content

runmat_runtime/builtins/strings/text_analytics/
html.rs

1//! MATLAB-compatible HTML text extraction helpers.
2
3use 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&nbsp;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 &amp; 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 &amp; 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}