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