#[derive(Debug, Clone)]
pub struct IncrementalHtmlPda {
buffer: String,
open_tags: Vec<String>,
scan_cursor: usize,
}
impl Default for IncrementalHtmlPda {
fn default() -> Self {
Self::new()
}
}
const VOID_ELEMENTS: &[&str] = &[
"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source",
"track", "wbr",
];
impl IncrementalHtmlPda {
pub fn new() -> Self {
Self {
buffer: String::with_capacity(512),
open_tags: Vec::with_capacity(16),
scan_cursor: 0,
}
}
pub fn reset(&mut self) {
self.buffer.clear();
self.open_tags.clear();
self.scan_cursor = 0;
}
pub fn push_chunk(&mut self, chunk: &str) {
self.buffer.push_str(chunk);
self.advance_tag_scanner();
}
fn advance_tag_scanner(&mut self) {
let bytes = self.buffer.as_bytes();
let mut i = self.scan_cursor;
while i < bytes.len() {
if bytes[i] != b'<' {
i += 1;
self.scan_cursor = i;
continue;
}
self.scan_cursor = i;
if self.buffer[i..].starts_with("<!--") {
if let Some(end_rel) = self.buffer[i + 4..].find("-->") {
i += 4 + end_rel + 3;
self.scan_cursor = i;
continue;
} else {
break;
}
}
let Some(gt_rel) = self.buffer[i..].find('>') else {
break;
};
let tag_slice = &self.buffer[i + 1..i + gt_rel].trim();
if tag_slice.starts_with('!') || tag_slice.starts_with('?') || tag_slice.is_empty() {
i += gt_rel + 1;
self.scan_cursor = i;
continue;
}
let is_closing = tag_slice.starts_with('/');
let self_closing = tag_slice.ends_with('/');
let name_src = if is_closing { &tag_slice[1..] } else { tag_slice };
let tag_name: String = name_src
.trim_start()
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
.collect::<String>()
.to_ascii_lowercase();
if tag_name.is_empty() {
i += gt_rel + 1;
self.scan_cursor = i;
continue;
}
if !is_closing && !self_closing && (tag_name == "script" || tag_name == "style") {
let close_pat = if tag_name == "script" {
"</script>"
} else {
"</style>"
};
let after_open = i + gt_rel + 1;
if let Some(close_rel) = self.buffer[after_open..]
.to_ascii_lowercase()
.find(close_pat)
{
i = after_open + close_rel + close_pat.len();
self.scan_cursor = i;
continue;
} else {
break;
}
}
if is_closing {
if let Some(pos) = self.open_tags.iter().rposition(|t| t == &tag_name) {
self.open_tags.truncate(pos);
}
} else if !self_closing && !VOID_ELEMENTS.contains(&tag_name.as_str()) {
self.open_tags.push(tag_name);
}
i += gt_rel + 1;
self.scan_cursor = i;
}
}
pub fn snapshot_healed(&self) -> String {
if self.buffer.trim().is_empty() {
return String::new();
}
let mut out = self.buffer.clone();
let tail_start = self.scan_cursor.min(out.len());
let tail = &out[tail_start..];
if tail.starts_with("<!--") && !tail.contains("-->") {
out.push_str(" -->");
} else if tail.to_ascii_lowercase().starts_with("<script") {
if let Some(gt_rel) = tail.find('>') {
let script_body = &tail[gt_rel + 1..];
let closer = close_js_fragment(script_body);
out.push_str(&closer);
out.push_str("\n</script>");
} else {
out.truncate(tail_start);
}
} else if tail.to_ascii_lowercase().starts_with("<style") {
if let Some(gt_rel) = tail.find('>') {
let style_body = &tail[gt_rel + 1..];
let closer = close_css_fragment(style_body);
out.push_str(&closer);
out.push_str("\n</style>");
} else {
out.truncate(tail_start);
}
} else if tail.starts_with('<') && !tail.contains('>') {
out.truncate(tail_start);
}
for tag in self.open_tags.iter().rev() {
out.push_str("</");
out.push_str(tag);
out.push('>');
}
out.trim().to_string()
}
}
fn close_js_fragment(script_body: &str) -> String {
let mut in_single = false;
let mut in_double = false;
let mut in_template = false;
let mut in_block_comment = false;
let mut in_line_comment = false;
let mut escape = false;
let mut delim_stack: Vec<char> = Vec::new();
let bytes = script_body.as_bytes();
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if in_line_comment {
if b == b'\n' {
in_line_comment = false;
}
i += 1;
continue;
}
if in_block_comment {
if b == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
in_block_comment = false;
i += 2;
continue;
}
i += 1;
continue;
}
if escape {
escape = false;
i += 1;
continue;
}
if b == b'\\' && (in_single || in_double || in_template) {
escape = true;
i += 1;
continue;
}
if !in_single && !in_double && !in_template {
if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
in_line_comment = true;
i += 2;
continue;
}
if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
in_block_comment = true;
i += 2;
continue;
}
}
if b == b'\'' && !in_double && !in_template {
in_single = !in_single;
} else if b == b'"' && !in_single && !in_template {
in_double = !in_double;
} else if b == b'`' && !in_single && !in_double {
in_template = !in_template;
} else if !in_single && !in_double && !in_template {
match b {
b'{' => delim_stack.push('}'),
b'(' => delim_stack.push(')'),
b'[' => delim_stack.push(']'),
b'}' | b')' | b']' => {
if delim_stack.last() == Some(&(b as char)) {
delim_stack.pop();
}
}
_ => {}
}
}
i += 1;
}
let mut suffix = String::new();
if in_block_comment {
suffix.push_str(" */");
}
if in_single {
suffix.push('\'');
}
if in_double {
suffix.push('"');
}
if in_template {
suffix.push('`');
}
for closer in delim_stack.into_iter().rev() {
suffix.push(closer);
}
suffix
}
fn close_css_fragment(style_body: &str) -> String {
let mut in_comment = false;
let mut open_braces: usize = 0;
let bytes = style_body.as_bytes();
let mut i = 0;
while i < bytes.len() {
if in_comment {
if bytes[i] == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
in_comment = false;
i += 2;
continue;
}
i += 1;
continue;
}
if bytes[i] == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
in_comment = true;
i += 2;
continue;
}
if bytes[i] == b'{' {
open_braces += 1;
} else if bytes[i] == b'}' {
open_braces = open_braces.saturating_sub(1);
}
i += 1;
}
let mut suffix = String::new();
if in_comment {
suffix.push_str(" */");
}
for _ in 0..open_braces {
suffix.push('}');
}
suffix
}
pub fn heal_html(raw: &str) -> String {
let mut pda = IncrementalHtmlPda::new();
pda.push_chunk(raw);
pda.snapshot_healed()
}