1use quarb::names::Names;
23use quarb::{AstAdapter, NodeId, Value};
24mod sink;
25use std::collections::HashMap;
26
27struct Node {
28 tag: Option<u32>,
31 attrs: Vec<(u32, String)>,
33 pieces: Vec<Piece>,
39 text: std::cell::OnceCell<String>,
40 parent: Option<NodeId>,
41 children: Vec<NodeId>,
42}
43
44enum Piece {
46 Text(String),
47 Child(usize),
48}
49
50impl Node {
51 fn attr(&self, names: &Names, name: &str) -> Option<&str> {
52 let k = names.key(name)?;
53 self.attrs
54 .iter()
55 .find(|(a, _)| *a == k)
56 .map(|(_, v)| v.as_str())
57 }
58}
59
60pub fn declared_encoding(bytes: &[u8]) -> Option<String> {
70 if let Some(label) = xml_prologue_encoding(bytes) {
71 return Some(label);
72 }
73 let head = &bytes[..bytes.len().min(1024)];
74 let lower: Vec<u8> = head.iter().map(|b| b.to_ascii_lowercase()).collect();
75 let mut at = 0;
76 while let Some(i) = find(&lower[at..], b"<meta") {
77 let start = at + i + 5;
78 let end = start + find(&lower[start..], b">").unwrap_or(lower.len() - start);
79 let attrs = meta_attributes(&lower[start..end]);
80 let get = |name: &str| {
81 attrs
82 .iter()
83 .find(|(k, _)| k == name)
84 .map(|(_, v)| v.as_str())
85 };
86 if let Some(label) = get("charset").filter(|l| !l.is_empty()) {
87 return Some(label.to_string());
88 }
89 if get("http-equiv") == Some("content-type")
90 && let Some(content) = get("content")
91 && let Some(p) = content.find("charset=")
92 {
93 let label = content[p + 8..]
94 .trim_matches(|c: char| c == '"' || c == '\'' || c.is_ascii_whitespace())
95 .split([';', ' ', '"', '\''])
96 .next()
97 .unwrap_or("");
98 if !label.is_empty() {
99 return Some(label.to_string());
100 }
101 }
102 at = end;
103 }
104 None
105}
106
107fn xml_prologue_encoding(bytes: &[u8]) -> Option<String> {
112 let head = bytes.strip_prefix(b"<?xml")?;
113 if !head.first().is_some_and(|b| b.is_ascii_whitespace()) {
114 return None;
115 }
116 let end = find(head, b"?>")?;
117 let prologue = std::str::from_utf8(&head[..end]).ok()?;
118 let rest = prologue
119 .split_once("encoding")?
120 .1
121 .trim_start()
122 .strip_prefix('=')?
123 .trim_start();
124 let quote = rest.chars().next()?;
125 (quote == '"' || quote == '\'')
126 .then(|| {
127 rest[1..]
128 .split(quote)
129 .next()
130 .unwrap_or("")
131 .trim()
132 .to_string()
133 })
134 .filter(|l| !l.is_empty())
135}
136
137fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
138 hay.windows(needle.len()).position(|w| w == needle)
139}
140
141fn meta_attributes(tag: &[u8]) -> Vec<(String, String)> {
146 let mut out = Vec::new();
147 let mut i = 0;
148 let n = tag.len();
149 while i < n {
150 while i < n && (tag[i].is_ascii_whitespace() || tag[i] == b'/') {
151 i += 1;
152 }
153 let name_start = i;
154 while i < n && !tag[i].is_ascii_whitespace() && tag[i] != b'=' && tag[i] != b'/' {
155 i += 1;
156 }
157 if i == name_start {
158 break;
159 }
160 let name = String::from_utf8_lossy(&tag[name_start..i]).into_owned();
161 while i < n && tag[i].is_ascii_whitespace() {
162 i += 1;
163 }
164 let mut value = String::new();
165 if i < n && tag[i] == b'=' {
166 i += 1;
167 while i < n && tag[i].is_ascii_whitespace() {
168 i += 1;
169 }
170 if i < n && (tag[i] == b'"' || tag[i] == b'\'') {
171 let q = tag[i];
172 i += 1;
173 let v = i;
174 while i < n && tag[i] != q {
175 i += 1;
176 }
177 value = String::from_utf8_lossy(&tag[v..i]).trim().to_string();
178 i = (i + 1).min(n);
179 } else {
180 let v = i;
181 while i < n && !tag[i].is_ascii_whitespace() {
182 i += 1;
183 }
184 value = String::from_utf8_lossy(&tag[v..i]).to_string();
185 }
186 }
187 out.push((name, value));
188 }
189 out
190}
191
192pub struct HtmlAdapter {
194 nodes: Vec<Node>,
195 names: Names,
197 ids: HashMap<String, NodeId>,
199 root: NodeId,
200 document_url: Option<url::Url>,
205}
206
207impl HtmlAdapter {
208 fn rel_ok(&self, node: NodeId, hint: Option<&str>) -> bool {
215 match hint {
216 None | Some("*") => true,
217 Some(h) => self.nodes[node.0 as usize]
218 .attr(&self.names, "rel")
219 .is_some_and(|rel| rel.split_whitespace().any(|w| w.eq_ignore_ascii_case(h))),
220 }
221 }
222
223 pub fn set_document_url(&mut self, url: &str) {
226 self.document_url = url::Url::parse(url).ok();
227 }
228
229 fn text_of(&self, node: NodeId) -> &str {
234 self.nodes[node.0 as usize].text.get_or_init(|| {
235 let mut out = String::new();
236 let mut stack: Vec<(usize, usize, usize)> = vec![(node.0 as usize, 0, 0)];
241 while let Some(top) = stack.last_mut() {
242 let (cur, at, start) = *top;
243 let n = &self.nodes[cur];
244 if at >= n.pieces.len() {
245 if cur != node.0 as usize {
246 let _ = n.text.set(out[start..].to_string());
247 }
248 stack.pop();
249 continue;
250 }
251 top.1 += 1;
252 match &n.pieces[at] {
253 Piece::Text(t) => out.push_str(t),
254 Piece::Child(k) => {
255 let c = n.children[*k].0 as usize;
256 match self.nodes[c].text.get() {
257 Some(t) => out.push_str(t),
258 None => stack.push((c, 0, out.len())),
259 }
260 }
261 }
262 }
263 out
264 })
265 }
266
267 pub fn parse(html: &str) -> Self {
269 use html5ever::tendril::TendrilSink;
270 let sink =
271 html5ever::driver::parse_document(sink::Sink::new(), Default::default()).one(html);
272 let mut nodes = vec![Node {
273 tag: None,
274 attrs: Vec::new(),
275 pieces: vec![Piece::Child(0)],
276 text: std::cell::OnceCell::new(),
277 parent: None,
278 children: Vec::new(),
279 }];
280 let mut ids = HashMap::new();
281 let root = NodeId(0);
282 let sink::Sink {
283 nodes: mut sunk,
284 mut names,
285 } = sink;
286 let html_el = sunk[0].kids.iter().find_map(|k| match k {
289 sink::Kid::Node(n) if !sunk[*n].skip => Some(*n),
290 _ => None,
291 });
292 if let Some(html_el) = html_el {
293 let html_node = build(
294 html_el,
295 Some(root),
296 &mut sunk,
297 &mut nodes,
298 &mut names,
299 &mut ids,
300 );
301 nodes[0].children = vec![html_node];
302 }
303
304 HtmlAdapter {
305 nodes,
306 names,
307 ids,
308 root,
309 document_url: None,
310 }
311 }
312
313 pub fn locator(&self, node: NodeId) -> String {
317 let mut segments = Vec::new();
318 let mut cur = Some(node);
319 while let Some(id) = cur {
320 let n = &self.nodes[id.0 as usize];
321 if let Some(tag) = n.tag {
322 segments.push(self.segment(id, tag));
323 }
324 cur = n.parent;
325 }
326 segments.reverse();
327 format!("/{}", segments.join("/"))
328 }
329
330 fn segment(&self, node: NodeId, tag: u32) -> String {
331 let name = self.names.get(tag);
332 let Some(parent) = self.nodes[node.0 as usize].parent else {
333 return name.to_string();
334 };
335 let siblings = &self.nodes[parent.0 as usize].children;
336 let same_tag: Vec<NodeId> = siblings
337 .iter()
338 .copied()
339 .filter(|&s| self.nodes[s.0 as usize].tag == Some(tag))
340 .collect();
341 if same_tag.len() > 1 {
342 let n = same_tag.iter().position(|&s| s == node).unwrap() + 1;
343 format!("{name}[{n}]")
344 } else {
345 name.to_string()
346 }
347 }
348}
349
350fn build(
357 el: usize,
358 parent: Option<NodeId>,
359 sunk: &mut [sink::SinkNode],
360 nodes: &mut Vec<Node>,
361 names: &mut Names,
362 ids: &mut HashMap<String, NodeId>,
363) -> NodeId {
364 let root_this = NodeId(nodes.len() as u64);
365 let mut stack = vec![(el, parent)];
366 while let Some((el, parent)) = stack.pop() {
367 let this = NodeId(nodes.len() as u64);
368 let s = &mut sunk[el];
369 let tag = names.intern(&s.name.as_ref().expect("an element").local);
370 let attrs = std::mem::take(&mut s.attrs);
371 let kids = std::mem::take(&mut s.kids);
372 let mut pieces = Vec::new();
373 let mut children: Vec<usize> = Vec::new();
374 let mut ordinal = 0;
375 for k in kids {
376 match k {
377 sink::Kid::Text(t) => pieces.push(Piece::Text(t)),
378 sink::Kid::Node(n) => {
379 if sunk[n].skip {
380 continue;
381 }
382 pieces.push(Piece::Child(ordinal));
383 ordinal += 1;
384 children.push(n);
385 }
386 }
387 }
388 if let Some(k) = names.key("id")
389 && let Some((_, id)) = attrs.iter().find(|(a, _)| *a == k)
390 {
391 ids.entry(id.clone()).or_insert(this);
392 }
393 nodes.push(Node {
394 tag: Some(tag),
395 attrs,
396 pieces,
397 text: std::cell::OnceCell::new(),
398 parent,
399 children: Vec::new(),
400 });
401 if let Some(p) = parent {
402 nodes[p.0 as usize].children.push(this);
403 }
404 for child in children.into_iter().rev() {
405 stack.push((child, Some(this)));
406 }
407 }
408 root_this
409}
410
411const BLOCK: &[&str] = &[
413 "address",
414 "article",
415 "aside",
416 "blockquote",
417 "body",
418 "details",
419 "dialog",
420 "dd",
421 "div",
422 "dl",
423 "dt",
424 "fieldset",
425 "figcaption",
426 "figure",
427 "footer",
428 "form",
429 "h1",
430 "h2",
431 "h3",
432 "h4",
433 "h5",
434 "h6",
435 "header",
436 "hgroup",
437 "hr",
438 "html",
439 "li",
440 "main",
441 "nav",
442 "ol",
443 "p",
444 "pre",
445 "section",
446 "table",
447 "ul",
448];
449
450fn is_heading(tag: &str) -> bool {
451 matches!(tag, "h1" | "h2" | "h3" | "h4" | "h5" | "h6")
452}
453
454impl AstAdapter for HtmlAdapter {
455 fn estimate(&self, scope: &[&str], plan: &quarb::StepPlan<'_>) -> Option<quarb::Estimate> {
457 quarb::adapter::arena_estimate(self, scope, plan, quarb::Latency::Memory, None)
458 }
459
460 fn root(&self) -> NodeId {
461 self.root
462 }
463
464 fn walks_for_references(&self) -> bool {
467 true
468 }
469
470 fn children(&self, node: NodeId) -> Vec<NodeId> {
471 self.nodes[node.0 as usize].children.clone()
472 }
473
474 fn name(&self, node: NodeId) -> Option<String> {
475 self.nodes[node.0 as usize]
476 .tag
477 .map(|t| self.names.get(t).to_string())
478 }
479
480 fn parent(&self, node: NodeId) -> Option<NodeId> {
481 self.nodes[node.0 as usize].parent
482 }
483
484 fn traits(&self, node: NodeId) -> Vec<String> {
487 let n = &self.nodes[node.0 as usize];
488 let Some(tag) = n.tag.map(|t| self.names.get(t)) else {
489 return Vec::new();
490 };
491 let mut out = Vec::new();
492 if BLOCK.contains(&tag) {
493 out.push("block".to_string());
494 } else {
495 out.push("inline".to_string());
496 }
497 if is_heading(tag) {
498 out.push("heading".to_string());
499 }
500 if tag == "a" && n.attr(&self.names, "href").is_some() {
501 out.push("link".to_string());
502 }
503 out
504 }
505
506 fn property(&self, node: NodeId, name: &str) -> Option<Value> {
509 self.nodes[node.0 as usize]
510 .attr(&self.names, name)
511 .map(|v| Value::Str(v.to_string()))
512 }
513
514 fn default_value(&self, node: NodeId) -> Option<Value> {
516 Some(Value::Str(self.text_of(node).to_string()))
517 }
518
519 fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
527 let n = &self.nodes[node.0 as usize];
528 match key {
529 "tag" => n.tag.map(|t| Value::Str(self.names.get(t).to_string())),
530 "classes" => n.attr(&self.names, "class").map(|c| {
531 Value::list(
532 c.split_whitespace()
533 .map(|s| Value::Str(s.to_string()))
534 .collect(),
535 )
536 }),
537 "attrs" => {
538 let mut names: Vec<&str> =
539 n.attrs.iter().map(|(k, _)| self.names.get(*k)).collect();
540 names.sort_unstable();
541 Some(Value::list(
542 names
543 .into_iter()
544 .map(|k| Value::Str(k.to_string()))
545 .collect(),
546 ))
547 }
548 "n-attrs" => Some(Value::Int(n.attrs.len() as i64)),
549 _ => None,
550 }
551 }
552
553 fn resolve(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<NodeId> {
556 if property == "href" && !self.rel_ok(node, hint) {
557 return None;
558 }
559 let value = self.nodes[node.0 as usize].attr(&self.names, property)?;
560 let fragment = value.strip_prefix('#')?;
561 self.ids.get(fragment).copied()
562 }
563
564 fn external_ref(
574 &self,
575 node: NodeId,
576 property: &str,
577 hint: Option<&str>,
578 ) -> Option<quarb::ExternalRef> {
579 if property == "href" && !self.rel_ok(node, hint) {
580 return None;
581 }
582 let value = self.nodes[node.0 as usize].attr(&self.names, property)?;
583 let t = value.trim();
584 if t.is_empty() || t.starts_with('#') {
585 return None;
586 }
587 let u = match url::Url::parse(t) {
588 Ok(u) => u,
589 Err(url::ParseError::RelativeUrlWithoutBase) => match &self.document_url {
594 Some(base) => base.join(t).ok()?,
595 None => return Some(quarb::ExternalRef::uri(t)),
596 },
597 Err(_) => return None,
598 };
599 if u.scheme() != "http" && u.scheme() != "https" {
600 return None;
601 }
602 Some(quarb::ExternalRef::uri(u.as_str()))
603 }
604
605 fn resolve_fragment(&self, _node: NodeId, fragment: &str) -> Option<NodeId> {
608 self.ids.get(fragment).copied()
609 }
610
611 fn property_names(&self, node: NodeId) -> Vec<String> {
615 self.nodes[node.0 as usize]
616 .attrs
617 .iter()
618 .map(|(k, _)| self.names.get(*k).to_string())
619 .collect()
620 }
621
622 fn can_refer_to(&self, _source: NodeId, _target: NodeId) -> Option<bool> {
625 Some(true)
626 }
627
628 fn ref_property(&self, node: NodeId) -> Option<String> {
629 let n = &self.nodes[node.0 as usize];
630 let (prop, tags): (&str, &[&str]) = match n.tag.map(|t| self.names.get(t)) {
631 Some(t) if ["a", "link", "area"].contains(&t) => ("href", &["a", "link", "area"]),
632 Some(t) if ["iframe", "frame"].contains(&t) => ("src", &["iframe", "frame"]),
633 _ => return None,
634 };
635 let _ = tags;
636 n.attr(&self.names, prop).map(|_| prop.to_string())
637 }
638
639 fn ref_label(&self, node: NodeId, property: &str) -> Option<String> {
644 if property != "href" {
645 return None;
646 }
647 let rel = self.nodes[node.0 as usize].attr(&self.names, "rel")?;
648 let rel = rel.trim();
649 (!rel.is_empty()).then(|| rel.to_string())
650 }
651}
652
653#[cfg(test)]
654mod declared_tests {
655 use super::declared_encoding;
656
657 #[test]
658 fn meta_charset_forms() {
659 assert_eq!(
660 declared_encoding(
661 b"<!DOCTYPE html><html><head><meta charset=\"windows-1251\"><title>x</title>"
662 ),
663 Some("windows-1251".into())
664 );
665 assert_eq!(
666 declared_encoding(b"<html><head><META http-equiv=\"Content-Type\" content=\"text/html; charset=koi8-r\">"),
667 Some("koi8-r".into())
668 );
669 assert_eq!(
670 declared_encoding(b"<html><head><meta charset=utf-8></head>"),
671 Some("utf-8".into())
672 );
673 assert_eq!(
674 declared_encoding(b"<html><head><meta name=\"charset-note\" content=\"x\"></head>"),
675 None
676 );
677 assert_eq!(
678 declared_encoding(b"<?xml version=\"1.0\" encoding=\"ISO-8859-5\"?><html/>"),
679 Some("ISO-8859-5".into())
680 );
681 assert_eq!(
683 declared_encoding(
684 b"<meta name=\"description\" content=\"why charset=koi8-r was used\"><title>x</title>"
685 ),
686 None
687 );
688 assert_eq!(
690 declared_encoding(b"<?xml-stylesheet href=\"a.xsl\" encoding=\"koi8-r\"?><html/>"),
691 None
692 );
693 let mut late = vec![b' '; 1100];
694 late.extend_from_slice(b"<meta charset=\"koi8-r\">");
695 assert_eq!(declared_encoding(&late), None);
696 }
697}