1use 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 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 & 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 & 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}