use alloc::borrow::Cow;
use alloc::boxed::Box;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use core::fmt;
use crate::node::NodeKind;
use crate::query::{Selector, SelectorError};
use crate::tree::{Dom, NodeRef};
#[derive(Clone, Copy, Debug)]
pub enum Scope<'a> {
Document(&'a Dom),
Element(NodeRef<'a>),
}
impl<'a> From<&'a Dom> for Scope<'a> {
fn from(dom: &'a Dom) -> Self {
Scope::Document(dom)
}
}
impl<'a> From<NodeRef<'a>> for Scope<'a> {
fn from(node: NodeRef<'a>) -> Self {
Scope::Element(node)
}
}
impl<'a> Scope<'a> {
pub fn node(&self) -> Option<NodeRef<'a>> {
match *self {
Scope::Document(_) => None,
Scope::Element(node) => Some(node),
}
}
pub fn select_first(&self, selector: &Selector) -> Option<NodeRef<'a>> {
match *self {
Scope::Document(dom) => dom.select_first(selector),
Scope::Element(node) => node.select_first(selector),
}
}
pub fn select<'s>(
&self,
selector: &'s Selector,
) -> impl Iterator<Item = NodeRef<'a>> + use<'a, 's> {
let (doc, element) = match *self {
Scope::Document(dom) => (Some(dom.select(selector)), None),
Scope::Element(node) => (None, Some(node.select(selector))),
};
doc.into_iter()
.flatten()
.chain(element.into_iter().flatten())
}
pub fn text(&self) -> String {
match *self {
Scope::Document(dom) => dom.to_text(),
Scope::Element(node) => node.to_text(),
}
}
pub fn inner_html(&self) -> String {
match *self {
Scope::Document(dom) => dom.to_html(),
Scope::Element(node) => node.inner_html(),
}
}
pub fn outer_html(&self) -> String {
match *self {
Scope::Document(dom) => dom.to_html(),
Scope::Element(node) => node.outer_html(),
}
}
#[cfg(feature = "markdown")]
pub fn markdown(&self) -> String {
match *self {
Scope::Document(dom) => dom.to_markdown(),
Scope::Element(node) => node.to_markdown(),
}
}
pub fn attr(&self, name: &str) -> Option<&'a str> {
match *self {
Scope::Document(_) => None,
Scope::Element(node) => node.attr(name),
}
}
}
#[diagnostic::on_unimplemented(
message = "`{Self}` cannot be extracted from HTML",
label = "`{Self}` does not implement `FromHtml`",
note = "derive it with `#[derive(FromHtml)]`, or read a string value with `attr`, `html`, `outer_html` or `markdown` (which need `FromHtmlValue`) or convert one with `with = \"…\"`"
)]
pub trait FromHtml: Sized {
const SELECTOR: Option<&'static str> = None;
fn from_html(scope: Scope<'_>) -> Result<Self, ExtractError>;
}
#[diagnostic::on_unimplemented(
message = "`{Self}` cannot be parsed from an HTML string value",
label = "`{Self}` does not implement `FromHtmlValue`",
note = "implement `FromHtmlValue` for it, or convert the value with `with = \"path::to::fn\"`"
)]
pub trait FromHtmlValue: Sized {
fn from_html_value(value: &str) -> Result<Self, String>;
}
impl FromHtmlValue for String {
fn from_html_value(value: &str) -> Result<Self, String> {
Ok(value.into())
}
}
impl FromHtml for String {
fn from_html(scope: Scope<'_>) -> Result<Self, ExtractError> {
Ok(scope.text())
}
}
impl FromHtmlValue for bool {
fn from_html_value(value: &str) -> Result<Self, String> {
let value = value.trim();
if value.eq_ignore_ascii_case("true") {
Ok(true)
} else if value.eq_ignore_ascii_case("false") {
Ok(false)
} else {
Err(r#"expected "true" or "false""#.into())
}
}
}
macro_rules! from_str_impls {
($($t:ty),* $(,)?) => {$(
impl FromHtmlValue for $t {
fn from_html_value(value: &str) -> Result<Self, String> {
value.trim().parse().map_err(|e: <$t as core::str::FromStr>::Err| e.to_string())
}
}
)*};
}
from_str_impls!(
i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize, f32, f64, char
);
macro_rules! text_impls {
($($t:ty),* $(,)?) => {$(
impl FromHtml for $t {
fn from_html(scope: Scope<'_>) -> Result<Self, ExtractError> {
parse_value(Cow::Owned(scope.text()), ValueSource::Text)
}
}
)*};
}
text_impls!(
i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize, f32, f64, bool, char
);
fn parse_value<T: FromHtmlValue>(
value: Cow<'_, str>,
source: ValueSource,
) -> Result<T, ExtractError> {
T::from_html_value(&value).map_err(|message| {
ExtractError::new(ExtractErrorKind::Parse {
value: value.trim().into(),
type_name: short_type_name::<T>(),
message,
})
.with_source(source)
})
}
fn short_type_name<T>() -> String {
let full = core::any::type_name::<T>();
let mut out = String::with_capacity(full.len());
let mut chars = full.chars().peekable();
while let Some(c) = chars.next() {
if c == ':' && chars.peek() == Some(&':') {
chars.next();
let keep = out
.rfind(|c: char| !(c.is_alphanumeric() || c == '_'))
.map_or(0, |i| i + 1);
out.truncate(keep);
} else {
out.push(c);
}
}
out
}
impl Dom {
pub fn extract<T: FromHtml>(&self) -> Result<T, ExtractError> {
match T::SELECTOR {
None => T::from_html(Scope::Document(self)),
Some(css) => {
let sel = type_selector::<T>(css)?;
let node = self.select_first(&sel).ok_or_else(|| {
ExtractError::new(ExtractErrorKind::NoMatch)
.with_selector(css)
.in_type(short_type_name::<T>())
})?;
T::from_html(Scope::Element(node))
}
}
}
pub fn extract_all<T: FromHtml>(&self) -> Result<Vec<T>, ExtractError> {
let Some(css) = T::SELECTOR else {
return Err(ExtractError::new(ExtractErrorKind::MissingSelector)
.in_type(short_type_name::<T>()));
};
let sel = type_selector::<T>(css)?;
self.select(&sel)
.enumerate()
.map(|(i, node)| T::from_html(Scope::Element(node)).map_err(|e| e.at_index(i)))
.collect()
}
}
fn type_selector<T>(css: &str) -> Result<Selector, ExtractError> {
Selector::parse(css).map_err(|e| {
ExtractError::new(ExtractErrorKind::InvalidSelector(e))
.with_selector(css)
.in_type(short_type_name::<T>())
})
}
impl NodeRef<'_> {
pub fn extract<T: FromHtml>(&self) -> Result<T, ExtractError> {
T::from_html(Scope::Element(*self))
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct ExtractError(Box<Inner>);
#[derive(Clone, PartialEq, Eq)]
struct Inner {
kind: ExtractErrorKind,
root: Option<Cow<'static, str>>,
segments: Vec<Segment>,
selector: Option<String>,
source: Option<ValueSource>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum Segment {
Field(&'static str),
Index(usize),
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum ExtractErrorKind {
NoMatch,
MissingAttribute {
name: String,
element: Option<String>,
},
Parse {
value: String,
type_name: String,
message: String,
},
Convert {
value: String,
function: String,
message: String,
},
InvalidSelector(SelectorError),
MissingSelector,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum ValueSource {
Text,
Attr(String),
InnerHtml,
OuterHtml,
Markdown,
}
impl ExtractError {
pub fn new(kind: ExtractErrorKind) -> Self {
ExtractError(Box::new(Inner {
kind,
root: None,
segments: Vec::new(),
selector: None,
source: None,
}))
}
pub fn kind(&self) -> &ExtractErrorKind {
&self.0.kind
}
pub fn path(&self) -> String {
use core::fmt::Write;
let mut out = String::new();
if let Some(root) = &self.0.root {
out.push_str(root);
}
for segment in self.0.segments.iter().rev() {
match segment {
Segment::Field(name) => {
if !out.is_empty() {
out.push('.');
}
out.push_str(name);
}
Segment::Index(i) => {
let _ = write!(out, "[{i}]");
}
}
}
out
}
pub fn selector(&self) -> Option<&str> {
self.0.selector.as_deref()
}
pub fn value_source(&self) -> Option<&ValueSource> {
self.0.source.as_ref()
}
pub fn with_selector(mut self, selector: &str) -> Self {
if self.0.selector.is_none() {
self.0.selector = Some(selector.into());
}
self
}
pub fn with_source(mut self, source: ValueSource) -> Self {
if self.0.source.is_none() {
self.0.source = Some(source);
}
self
}
pub fn in_field(mut self, type_name: &'static str, field: &'static str) -> Self {
self.0.segments.push(Segment::Field(field));
self.0.root = Some(Cow::Borrowed(type_name));
self
}
pub fn at_index(mut self, index: usize) -> Self {
self.0.segments.push(Segment::Index(index));
self
}
fn in_type(mut self, type_name: String) -> Self {
if self.0.root.is_none() {
self.0.root = Some(Cow::Owned(type_name));
}
self
}
}
const MAX_VALUE_CHARS: usize = 60;
impl fmt::Display for ExtractError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let path = self.path();
f.write_str(&path)?;
let source = match &self.0.source {
None | Some(ValueSource::Text) => None,
Some(source) => Some(source),
};
let mut lead = !path.is_empty();
if self.0.selector.is_some() || source.is_some() {
f.write_str(if lead { " (" } else { "(" })?;
if let Some(selector) = &self.0.selector {
write!(f, "{selector:?}")?;
if source.is_some() {
f.write_str(" ")?;
}
}
match source {
Some(ValueSource::Attr(name)) => write!(f, "@{name}")?,
Some(ValueSource::InnerHtml) => f.write_str("html")?,
Some(ValueSource::OuterHtml) => f.write_str("outer_html")?,
Some(ValueSource::Markdown) => f.write_str("markdown")?,
Some(ValueSource::Text) | None => {}
}
f.write_str(")")?;
lead = true;
}
if lead {
f.write_str(": ")?;
}
match &self.0.kind {
ExtractErrorKind::NoMatch => f.write_str("no element matches"),
ExtractErrorKind::MissingAttribute {
name,
element: Some(element),
} => write!(f, "<{element}> has no {name:?} attribute"),
ExtractErrorKind::MissingAttribute { element: None, .. } => f.write_str(
"a document has no attributes; give the field a selector or extract from an \
element",
),
ExtractErrorKind::Parse {
value,
type_name,
message,
} => {
f.write_str("cannot parse ")?;
write_value(f, value)?;
write!(f, " as {type_name}")?;
write_message(f, message)
}
ExtractErrorKind::Convert {
value,
function,
message,
} => {
f.write_str("cannot convert ")?;
write_value(f, value)?;
write!(f, " with {function}")?;
write_message(f, message)
}
ExtractErrorKind::InvalidSelector(e) => write!(f, "invalid selector: {e}"),
ExtractErrorKind::MissingSelector => {
f.write_str(r#"extract_all needs a selector on the type: #[html(select = "…")]"#)
}
}
}
}
fn write_value(f: &mut fmt::Formatter<'_>, value: &str) -> fmt::Result {
match value.char_indices().nth(MAX_VALUE_CHARS) {
Some((cut, _)) => write!(f, "{:?}…", value.get(..cut).unwrap_or(value)),
None => write!(f, "{value:?}"),
}
}
fn write_message(f: &mut fmt::Formatter<'_>, message: &str) -> fmt::Result {
if message.is_empty() {
Ok(())
} else {
write!(f, ": {message}")
}
}
impl fmt::Debug for ExtractError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ExtractError")
.field("kind", &self.0.kind)
.field("path", &self.path())
.field("selector", &self.0.selector)
.field("source", &self.0.source)
.finish()
}
}
#[cfg(feature = "std")]
impl std::error::Error for ExtractError {}
#[doc(hidden)]
#[derive(Clone, Copy, Debug)]
pub enum Src {
Text,
Attr(&'static str),
InnerHtml,
OuterHtml,
Markdown,
}
impl Src {
fn value_source(self) -> ValueSource {
match self {
Src::Text => ValueSource::Text,
Src::Attr(name) => ValueSource::Attr(name.into()),
Src::InnerHtml => ValueSource::InnerHtml,
Src::OuterHtml => ValueSource::OuterHtml,
Src::Markdown => ValueSource::Markdown,
}
}
fn read<'a>(self, scope: Scope<'a>) -> Option<Cow<'a, str>> {
Some(match self {
Src::Text => Cow::Owned(scope.text()),
Src::Attr(name) => Cow::Borrowed(scope.attr(name)?),
Src::InnerHtml => Cow::Owned(scope.inner_html()),
Src::OuterHtml => Cow::Owned(scope.outer_html()),
#[cfg(feature = "markdown")]
Src::Markdown => Cow::Owned(scope.markdown()),
#[cfg(not(feature = "markdown"))]
Src::Markdown => Cow::Borrowed(""),
})
}
}
#[doc(hidden)]
#[derive(Clone, Copy, Debug)]
pub struct Ctx {
pub ty: &'static str,
pub field: &'static str,
pub select: Option<&'static str>,
pub source: Option<Src>,
}
impl Ctx {
fn wrap(&self, mut e: ExtractError) -> ExtractError {
if let Some(css) = self.select {
e = e.with_selector(css);
}
if let Some(src) = self.source {
e = e.with_source(src.value_source());
}
e.in_field(self.ty, self.field)
}
fn selector_error(&self, kind: ExtractErrorKind) -> ExtractError {
let e = ExtractError::new(kind);
let e = match self.select {
Some(css) => e.with_selector(css),
None => e,
};
e.in_field(self.ty, self.field)
}
fn missing_attribute(&self, target: Scope<'_>) -> ExtractError {
let name = match self.source {
Some(Src::Attr(name)) => name.into(),
_ => String::new(),
};
let element = target.node().map(|node| match node.kind() {
NodeKind::Element { tag } => tag.into(),
NodeKind::Text(_) => "#text".into(),
NodeKind::Comment(_) => "#comment".into(),
NodeKind::Doctype(_) => "#doctype".into(),
});
ExtractError::new(ExtractErrorKind::MissingAttribute { name, element })
}
fn parse_selector(&self, css: &str) -> Result<Selector, ExtractError> {
Selector::parse(css).map_err(|e| self.selector_error(ExtractErrorKind::InvalidSelector(e)))
}
fn target<'a>(&self, scope: Scope<'a>) -> Result<Option<Scope<'a>>, ExtractError> {
match self.select {
None => Ok(Some(scope)),
Some(css) => {
let sel = self.parse_selector(css)?;
Ok(scope.select_first(&sel).map(Scope::Element))
}
}
}
}
type Converted<T> = Result<Option<T>, ExtractError>;
#[doc(hidden)]
pub fn one<'a, T>(
scope: Scope<'a>,
ctx: &Ctx,
convert: impl FnOnce(Scope<'a>, &Ctx) -> Converted<T>,
) -> Result<T, ExtractError> {
let target = ctx
.target(scope)?
.ok_or_else(|| ctx.selector_error(ExtractErrorKind::NoMatch))?;
convert(target, ctx)
.map_err(|e| ctx.wrap(e))?
.ok_or_else(|| ctx.wrap(ctx.missing_attribute(target)))
}
#[doc(hidden)]
pub fn opt<'a, T>(
scope: Scope<'a>,
ctx: &Ctx,
convert: impl FnOnce(Scope<'a>, &Ctx) -> Converted<T>,
) -> Result<Option<T>, ExtractError> {
match ctx.target(scope)? {
None => Ok(None),
Some(target) => convert(target, ctx).map_err(|e| ctx.wrap(e)),
}
}
#[doc(hidden)]
pub fn all<'a, T>(
scope: Scope<'a>,
ctx: &Ctx,
mut convert: impl FnMut(Scope<'a>, &Ctx) -> Converted<T>,
) -> Result<Vec<T>, ExtractError> {
let Some(css) = ctx.select else {
return one(scope, ctx, convert).map(|v| alloc::vec![v]);
};
let sel = ctx.parse_selector(css)?;
let mut out = Vec::new();
for (i, node) in scope.select(&sel).enumerate() {
match convert(Scope::Element(node), ctx) {
Ok(Some(v)) => out.push(v),
Ok(None) => {
let e = ctx.missing_attribute(Scope::Element(node)).at_index(i);
return Err(ctx.wrap(e));
}
Err(e) => return Err(ctx.wrap(e.at_index(i))),
}
}
Ok(out)
}
#[doc(hidden)]
pub fn nested<T: FromHtml>(scope: Scope<'_>, _: &Ctx) -> Converted<T> {
T::from_html(scope).map(Some)
}
#[doc(hidden)]
pub fn value<T: FromHtmlValue>(scope: Scope<'_>, ctx: &Ctx) -> Converted<T> {
let src = ctx.source.unwrap_or(Src::Text);
match src.read(scope) {
None => Ok(None),
Some(value) => parse_value(value, src.value_source()).map(Some),
}
}
#[doc(hidden)]
pub fn with<T, E: fmt::Display>(
scope: Scope<'_>,
ctx: &Ctx,
function: &'static str,
f: impl FnOnce(&str) -> Result<T, E>,
) -> Converted<T> {
let src = ctx.source.unwrap_or(Src::Text);
let Some(value) = src.read(scope) else {
return Ok(None);
};
match f(&value) {
Ok(v) => Ok(Some(v)),
Err(e) => Err(ExtractError::new(ExtractErrorKind::Convert {
value: value.into_owned(),
function: function.into(),
message: e.to_string(),
})
.with_source(src.value_source())),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn short_type_names() {
assert_eq!(short_type_name::<String>(), "String");
assert_eq!(short_type_name::<f64>(), "f64");
assert_eq!(short_type_name::<Vec<String>>(), "Vec<String>");
assert_eq!(
short_type_name::<Option<(u8, Vec<String>)>>(),
"Option<(u8, Vec<String>)>"
);
assert_eq!(short_type_name::<Ctx>(), "Ctx");
}
#[test]
fn display_without_path_or_selector() {
let e = parse_value::<f64>(Cow::Borrowed("x"), ValueSource::Text).unwrap_err();
assert_eq!(
e.to_string(),
r#"cannot parse "x" as f64: invalid float literal"#
);
let e = ExtractError::new(ExtractErrorKind::MissingAttribute {
name: "href".into(),
element: Some("a".into()),
})
.with_source(ValueSource::Attr("href".into()));
assert_eq!(e.to_string(), r#"(@href): <a> has no "href" attribute"#);
}
#[test]
fn long_values_are_cut_short() {
let long = "x".repeat(100);
let e = parse_value::<u8>(Cow::Borrowed(&long), ValueSource::Text).unwrap_err();
let shown = alloc::format!("{:?}…", "x".repeat(MAX_VALUE_CHARS));
assert_eq!(
e.to_string(),
alloc::format!("cannot parse {shown} as u8: invalid digit found in string")
);
match e.kind() {
ExtractErrorKind::Parse { value, .. } => assert_eq!(value.len(), 100),
other => panic!("unexpected {other:?}"),
}
}
}