pub const MAX_VALUE: usize = 16 * 1024;
pub fn normalize(input: &str) -> String {
let mut s: String = input.chars().take(MAX_VALUE).collect();
for _ in 0..2 {
let next = percent_decode(&s);
if next == s {
break;
}
s = next;
}
s = html_entities(&s);
s = js_escapes(&s);
s = strip_sql_comments(&s);
let mut out = String::with_capacity(s.len());
let mut space = false;
for c in s.chars() {
let c = if c.is_whitespace() || c == '\u{0b}' || c == '\u{0c}' { ' ' } else { c };
if c.is_control() {
continue;
}
if c == ' ' {
if !space {
out.push(' ');
}
space = true;
} else {
out.extend(c.to_lowercase());
space = false;
}
}
out
}
fn hex(b: u8) -> Option<u32> {
(b as char).to_digit(16)
}
pub fn percent_decode(s: &str) -> String {
let b = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'%' {
if matches!(b.get(i + 1), Some(b'u' | b'U')) {
if let (Some(a), Some(c), Some(d), Some(e)) = (b.get(i + 2).and_then(|x| hex(*x)), b.get(i + 3).and_then(|x| hex(*x)), b.get(i + 4).and_then(|x| hex(*x)), b.get(i + 5).and_then(|x| hex(*x))) {
if let Some(ch) = char::from_u32(a << 12 | c << 8 | d << 4 | e) {
let mut buf = [0u8; 4];
out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
i += 6;
continue;
}
}
}
if let (Some(h), Some(l)) = (b.get(i + 1).and_then(|x| hex(*x)), b.get(i + 2).and_then(|x| hex(*x))) {
out.push((h << 4 | l) as u8);
i += 3;
continue;
}
}
out.push(b[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
pub fn html_entities(s: &str) -> String {
if !s.contains('&') {
return s.to_string();
}
const NAMED: [(&str, char); 14] = [
("lt", '<'), ("gt", '>'), ("quot", '"'), ("apos", '\''), ("amp", '&'), ("colon", ':'), ("lpar", '('), ("rpar", ')'),
("tab", '\t'), ("newline", '\n'), ("sol", '/'), ("bsol", '\\'), ("equals", '='), ("nbsp", ' '),
];
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(at) = rest.find('&') {
out.push_str(&rest[..at]);
let after = &rest[at + 1..];
let mut consumed = None;
if let Some(num) = after.strip_prefix('#') {
let (digits, radix, skip) = match num.strip_prefix(['x', 'X']) {
Some(h) => (h.chars().take_while(|c| c.is_ascii_hexdigit()).take(8).collect::<String>(), 16, 2),
None => (num.chars().take_while(|c| c.is_ascii_digit()).take(8).collect::<String>(), 10, 1),
};
if !digits.is_empty() {
if let Some(ch) = u32::from_str_radix(&digits, radix).ok().and_then(char::from_u32) {
out.push(ch);
let mut len = skip + digits.len();
if after[len..].starts_with(';') {
len += 1;
}
consumed = Some(len);
}
}
} else {
let name: String = after.chars().take_while(|c| c.is_ascii_alphabetic()).take(10).collect();
if let Some((_, ch)) = NAMED.iter().find(|(n, _)| n.eq_ignore_ascii_case(&name)) {
out.push(*ch);
let mut len = name.len();
if after[len..].starts_with(';') {
len += 1;
}
consumed = Some(len);
}
}
match consumed {
Some(len) => rest = &after[len..],
None => {
out.push('&');
rest = after;
}
}
}
out.push_str(rest);
out
}
pub fn js_escapes(s: &str) -> String {
if !s.contains('\\') {
return s.to_string();
}
let chars: Vec<char> = s.chars().collect();
let mut out = String::with_capacity(s.len());
let mut i = 0;
while i < chars.len() {
if chars[i] == '\\' && i + 1 < chars.len() {
let take = match chars[i + 1] {
'x' | 'X' => 2,
'u' | 'U' => 4,
_ => 0,
};
if take > 0 && i + 2 + take <= chars.len() {
let digits: String = chars[i + 2..i + 2 + take].iter().collect();
if let Some(ch) = u32::from_str_radix(&digits, 16).ok().and_then(char::from_u32) {
out.push(ch);
i += 2 + take;
continue;
}
}
}
out.push(chars[i]);
i += 1;
}
out
}
pub fn strip_sql_comments(s: &str) -> String {
if !s.contains("/*") {
return s.to_string();
}
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(start) = rest.find("/*") {
out.push_str(&rest[..start]);
out.push(' ');
match rest[start + 2..].find("*/") {
Some(end) => rest = &rest[start + 2 + end + 2..],
None => {
rest = "";
}
}
}
out.push_str(rest);
out
}