html_filter/types/
html_builder.rs

1//! Module that defines a builder for the [`Html`] tree.
2
3use core::fmt;
4use core::mem::take;
5
6use super::html::Html;
7use super::tag::{Tag, TagType};
8use crate::errors::{safe_expect, safe_unreachable};
9
10/// Wrapper for bool to manage visibility
11#[derive(Debug)]
12pub struct CommentFull(bool);
13
14/// Dom tree structure to represent the parsed html.
15///
16/// This is a builder for [`Html`]. Refer to its documentation for more
17/// information.
18#[non_exhaustive]
19#[derive(Debug, Default)]
20pub enum HtmlBuilder {
21    /// Comment block
22    ///
23    /// # Example
24    ///
25    /// `<!-- some comment -->`
26    #[non_exhaustive]
27    Comment {
28        /// Content of the comment
29        ///
30        /// # Examples
31        ///
32        /// In the previous example, the content is `some content`.
33        content: String,
34        /// Fullness of the comment
35        ///
36        /// `full` is `true` iff the closing `-->` was found for this comment.
37        ///
38        /// # Examples
39        ///
40        /// In the previous example, the content is `some content`.
41        full: CommentFull,
42    },
43    /// Document tag.
44    ///
45    /// These are tags with exclamation marks
46    ///
47    /// # Examples
48    ///
49    /// `<!doctype HtmlBuilder>`
50    #[non_exhaustive]
51    Doctype {
52        /// Name of the tag
53        ///
54        /// # Examples
55        ///
56        /// In the previous example, the name is `doctype`.
57        name: String,
58        /// Attribute of the tag
59        ///
60        /// # Examples
61        ///
62        /// In the previous example, the attribute is `HtmlBuilder`.
63        attr: Option<String>,
64    },
65    /// Empty html tree
66    ///
67    /// Corresponds to an empty string
68    #[default]
69    Empty,
70    /// Tag
71    ///
72    /// # Examples
73    ///
74    /// - `<div id="blob">content</div>`
75    /// - `<div attr />`
76    /// - `</>`
77    #[non_exhaustive]
78    Tag {
79        /// Opening tag
80        ///
81        /// Contains the name of the tag and its attributes.
82        tag: Tag,
83        /// Type of the tag
84        ///
85        /// The type is the information on the closing style: self-closing
86        /// (`<div/>`), opened (`<div>`) or closed (`<div></div>`).
87        full: TagType,
88        /// Child of the tag
89        ///
90        /// Everything between the opening and the closing tag.
91        ///
92        /// # Note
93        ///
94        /// This is always empty if the tag is self-closing.
95        child: Box<HtmlBuilder>,
96    },
97    /// Raw text
98    ///
99    /// Text outside of a tag.
100    ///
101    /// # Examples
102    ///
103    /// In `a<strong>b`, `a` and `b` are [`HtmlBuilder::Text`] elements
104    Text(String),
105    /// List of nodes
106    ///
107    /// # Examples
108    ///
109    /// In `a<strong>b`, the node is a vector, with [`HtmlBuilder::Text`] `a`,
110    /// [`HtmlBuilder::Tag`] `strong` [`HtmlBuilder::Text`] `b`.
111    Vec(Vec<HtmlBuilder>),
112}
113
114impl HtmlBuilder {
115    /// Pushes a block comment into the [`HtmlBuilder`] tree
116    pub fn close_comment(&mut self) -> bool {
117        match self {
118            Self::Comment { full, .. } =>
119                if full.0 {
120                    false
121                } else {
122                    full.0 = true;
123                    true
124                },
125            Self::Text(_) | Self::Empty | Self::Doctype { .. } => false,
126            Self::Tag { full, child, .. } => full.is_open() && child.close_comment(),
127            Self::Vec(vec) =>
128                safe_expect!(vec.last_mut(), "Html vec built with one.").close_comment(),
129        }
130    }
131
132    /// Method to find to close that last opened tag.
133    ///
134    /// This method finds the opened tag the closest to the leaves.
135    pub fn close_tag(&mut self, name: &str) -> Result<(), String> {
136        if self.close_tag_aux(name) {
137            Ok(())
138        } else {
139            Err(format!(
140                "Invalid closing tag: Found closing tag for '{name}' but it isn't open."
141            ))
142        }
143    }
144
145    /// Wrapper for [`Self::close_tag`].
146    ///
147    /// # Returns
148    ///
149    /// `true` iff the tag was successfully closed.
150    pub fn close_tag_aux(&mut self, name: &str) -> bool {
151        if let Self::Tag { tag, full: full @ TagType::Opened, child } = self {
152            child.close_tag_aux(name)
153                || (tag.as_name() == name && {
154                    *full = TagType::Closed;
155                    true
156                })
157        } else if let Self::Vec(vec) = self {
158            vec.last_mut()
159                .is_some_and(|child| child.close_tag_aux(name))
160        } else {
161            false
162        }
163    }
164
165    /// Boxes an empty tree.
166    pub fn empty_box() -> Box<Self> {
167        Box::new(Self::default())
168    }
169
170    /// Creates a tree for a character.
171    pub fn from_char(ch: char) -> Self {
172        Self::Text(ch.to_string())
173    }
174
175    /// Exports an [`HtmlBuilder`] into an [`Html`]
176    pub fn into_html(self) -> Html {
177        match self {
178            Self::Comment { content, .. } => Html::Comment(content),
179            Self::Doctype { name, attr } => Html::Doctype { name, attr },
180            Self::Empty => Html::Empty,
181            Self::Tag { tag, child, .. } => Html::Tag { tag, child: Box::new(child.into_html()) },
182            Self::Text(text) => Html::Text(text),
183            Self::Vec(vec) => Html::Vec(vec.into_iter().map(Self::into_html).collect()),
184        }
185    }
186
187    /// Checks if an [`HtmlBuilder`] tree is pushable.
188    ///
189    /// This is to check if a new node needs to be created for the next data.
190    ///
191    /// This method is different if the input is a char or not.
192    #[coverage(off)]
193    pub fn is_pushable(&self, is_char: bool) -> bool {
194        match self {
195            Self::Empty | Self::Vec(_) => safe_unreachable("Vec or Empty can't be in vec"),
196            Self::Tag { full, .. } => full.is_open(),
197            Self::Doctype { .. } => false,
198            Self::Text(_) => is_char,
199            Self::Comment { full, .. } => !full.0,
200        }
201    }
202
203    /// Pushes one character into an [`HtmlBuilder`] tree.
204    pub fn push_char(&mut self, ch: char) {
205        match self {
206            Self::Empty => *self = Self::from_char(ch),
207            Self::Tag { child, full: TagType::Opened, .. } => child.push_char(ch),
208            Self::Doctype { .. }
209            | Self::Tag { full: TagType::Closed | TagType::SelfClosing, .. } =>
210                *self = Self::Vec(vec![take(self), Self::from_char(ch)]),
211            Self::Text(text) => text.push(ch),
212            Self::Vec(vec) => {
213                let last = safe_expect!(vec.last_mut(), "Initialised with one element.");
214                if last.is_pushable(true) {
215                    return last.push_char(ch);
216                }
217                vec.push(Self::from_char(ch));
218            }
219            Self::Comment { content, full } => {
220                if full.0 {
221                    // This means the comment is at the root
222                    *self = Self::Vec(vec![take(self), Self::from_char(ch)]);
223                } else {
224                    content.push(ch);
225                }
226            }
227        }
228    }
229
230    /// Pushes a block comment into the [`HtmlBuilder`] tree
231    pub fn push_comment(&mut self) {
232        self.push_node(Self::Comment { content: String::new(), full: CommentFull(false) });
233    }
234
235    /// Pushes an [`HtmlBuilder`] tree into another one.
236    ///
237    /// This is useful to add comments or push tags for instance.
238    #[coverage(off)]
239    pub fn push_node(&mut self, node: Self) {
240        match self {
241            Self::Empty => *self = node,
242            Self::Tag { child, full: TagType::Opened, .. } => child.push_node(node),
243            Self::Text(_)
244            | Self::Doctype { .. }
245            | Self::Tag { full: TagType::Closed | TagType::SelfClosing, .. } =>
246                *self = Self::Vec(vec![take(self), node]),
247            Self::Vec(vec) => {
248                let last = safe_expect!(vec.last_mut(), "Initialised with one element.");
249                if last.is_pushable(false) {
250                    return last.push_node(node);
251                }
252                vec.push(node);
253            }
254            Self::Comment { .. } => safe_unreachable("Pushed parsed not into an unclosed comment."),
255        }
256    }
257
258    /// Pushes a tag into an [`HtmlBuilder`] tree.
259    pub fn push_tag(&mut self, tag: Tag, inline: bool) {
260        self.push_node(Self::Tag {
261            tag,
262            full: if inline {
263                TagType::SelfClosing
264            } else {
265                TagType::Opened
266            },
267            child: Self::empty_box(),
268        });
269    }
270}
271
272#[coverage(off)]
273#[expect(clippy::min_ident_chars, reason = "keep trait naming")]
274impl fmt::Display for HtmlBuilder {
275    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
276        match self {
277            Self::Empty => "".fmt(f),
278            Self::Tag { tag, full, child } => match full {
279                TagType::Closed => write!(f, "<{tag}>{child}</{}>", tag.as_name()),
280                TagType::Opened => write!(f, "<{tag}>{child}"),
281                TagType::SelfClosing => write!(f, "<{tag} />"),
282            },
283            Self::Doctype { name, attr } => match (name, attr) {
284                (name_str, Some(attr_str)) => write!(f, "<!{name_str} {attr_str}>"),
285                (name_str, None) if name_str.is_empty() => write!(f, "<!>"),
286                (name_str, None) => write!(f, "<!{name_str} >"),
287            },
288            Self::Text(text) => text.fmt(f),
289            Self::Vec(vec) => vec.iter().try_for_each(|html| html.fmt(f)),
290            Self::Comment { content, full } => f
291                .write_str("<!--")
292                .and_then(|()| f.write_str(content))
293                .and_then(|()| if full.0 { f.write_str("-->") } else { Ok(()) }),
294        }
295    }
296}