use ratatui_core::style::Modifier;
use crate::style::Role;
pub(super) const MAX_NESTING: usize = 32;
pub(super) enum Effect {
Open(Scope),
Close(&'static str),
Break,
Image { src: String, alt: String },
None,
}
pub(super) struct Scope {
pub(super) name: &'static str,
pub(super) role: Option<Role>,
pub(super) modifier: Modifier,
pub(super) href: Option<String>,
pub(super) script: Option<Script>,
}
impl Scope {
fn new(name: &'static str) -> Self {
Self {
name,
role: None,
modifier: Modifier::empty(),
href: None,
script: None,
}
}
fn role(name: &'static str, role: Role) -> Self {
Self {
role: Some(role),
..Self::new(name)
}
}
fn modifier(name: &'static str, modifier: Modifier) -> Self {
Self {
modifier,
..Self::new(name)
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum Script {
Sub,
Sup,
}
impl Script {
fn map(self, ch: char) -> Option<char> {
let table = match self {
Script::Sub => "₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎",
Script::Sup => "⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾",
};
let idx = "0123456789+-=()".find(ch)?;
table.chars().nth(idx)
}
}
pub(super) fn transliterate(text: &str, script: Script) -> Option<String> {
if text.is_empty() {
return None;
}
text.chars().map(|c| script.map(c)).collect()
}
pub(super) fn classify(raw: &str) -> Effect {
let inner = raw
.strip_prefix('<')
.and_then(|s| s.strip_suffix('>'))
.unwrap_or(raw)
.trim();
if inner.starts_with('!') || inner.starts_with('?') {
return Effect::None;
}
let (closing, inner) = match inner.strip_prefix('/') {
Some(rest) => (true, rest.trim_start()),
None => (false, inner),
};
let name_len = inner
.find(|c: char| c.is_whitespace() || c == '/')
.unwrap_or(inner.len());
let name = inner[..name_len].to_ascii_lowercase();
let attrs = &inner[name_len..];
if closing {
return match canonical(&name) {
Some(name) => Effect::Close(name),
None => Effect::None,
};
}
match name.as_str() {
"br" => Effect::Break,
"img" => Effect::Image {
src: attr(attrs, "src").unwrap_or_default(),
alt: attr(attrs, "alt").unwrap_or_default(),
},
"a" => Effect::Open(Scope {
role: Some(Role::Link),
href: attr(attrs, "href"),
..Scope::new("a")
}),
"b" | "strong" => Effect::Open(Scope::role("strong", Role::Strong)),
"i" | "em" | "var" | "cite" | "dfn" => Effect::Open(Scope::role("em", Role::Emphasis)),
"code" | "kbd" | "samp" | "tt" => Effect::Open(Scope::role("code", Role::InlineCode)),
"s" | "del" | "strike" => Effect::Open(Scope::role("del", Role::Strikethrough)),
"u" | "ins" => Effect::Open(Scope::modifier("u", Modifier::UNDERLINED)),
"mark" => Effect::Open(Scope::modifier("mark", Modifier::REVERSED)),
"sub" => Effect::Open(Scope {
script: Some(Script::Sub),
..Scope::new("sub")
}),
"sup" => Effect::Open(Scope {
script: Some(Script::Sup),
..Scope::new("sup")
}),
_ => Effect::None,
}
}
fn canonical(name: &str) -> Option<&'static str> {
Some(match name {
"b" | "strong" => "strong",
"i" | "em" | "var" | "cite" | "dfn" => "em",
"code" | "kbd" | "samp" | "tt" => "code",
"s" | "del" | "strike" => "del",
"u" | "ins" => "u",
"mark" => "mark",
"sub" => "sub",
"sup" => "sup",
"a" => "a",
_ => return None,
})
}
fn attr(attrs: &str, name: &str) -> Option<String> {
let mut rest = attrs;
while let Some(at) = rest.to_ascii_lowercase().find(name) {
let before_ok = at == 0
|| rest[..at]
.chars()
.next_back()
.is_some_and(|c| c.is_whitespace() || c == '"' || c == '\'');
let after = rest[at + name.len()..].trim_start();
if before_ok && let Some(value) = after.strip_prefix('=') {
let value = value.trim_start();
let raw = match value.chars().next() {
Some(q @ ('"' | '\'')) => value[1..].split(q).next().unwrap_or(""),
_ => {
let end = value
.find(|c: char| c.is_whitespace() || c == '>')
.unwrap_or(value.len());
&value[..end]
}
};
return Some(unescape(raw));
}
rest = &rest[at + name.len()..];
}
None
}
fn unescape(value: &str) -> String {
if !value.contains('&') {
return value.to_string();
}
let mut out = String::with_capacity(value.len());
let mut rest = value;
while let Some(amp) = rest.find('&') {
out.push_str(&rest[..amp]);
let tail = &rest[amp..];
let end = tail[1..].find(';').map(|i| i + 2).filter(|&e| e <= 12);
let decoded = end.and_then(|end| match &tail[1..end - 1] {
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" | "#39" => Some('\''),
"nbsp" => Some('\u{a0}'),
num => {
let digits = num.strip_prefix('#')?;
let code = match digits.strip_prefix(['x', 'X']) {
Some(hex) => u32::from_str_radix(hex, 16).ok()?,
None => digits.parse().ok()?,
};
char::from_u32(code)
}
});
match (decoded, end) {
(Some(c), Some(end)) => {
out.push(c);
rest = &tail[end..];
}
_ => {
out.push('&');
rest = &tail[1..];
}
}
}
out.push_str(rest);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn scope(raw: &str) -> Scope {
match classify(raw) {
Effect::Open(scope) => scope,
_ => panic!("expected an opening scope for {raw:?}"),
}
}
#[test]
fn aliases_share_one_closing_name() {
assert_eq!(scope("<i>").name, "em");
assert_eq!(scope("<em>").name, "em");
assert!(matches!(classify("</i>"), Effect::Close("em")));
assert!(matches!(classify("</em>"), Effect::Close("em")));
}
#[test]
fn tag_names_are_case_insensitive_and_tolerate_whitespace() {
assert_eq!(scope("<STRONG>").name, "strong");
assert!(matches!(classify("</ B >"), Effect::Close("strong")));
assert!(matches!(classify("<br />"), Effect::Break));
}
#[test]
fn unknown_and_non_element_tags_are_inert() {
for raw in ["<script>", "<div>", "<!-- note -->", "<!DOCTYPE html>"] {
assert!(matches!(classify(raw), Effect::None), "{raw}");
}
}
#[test]
fn anchor_reads_href_with_entities_decoded() {
let scope = scope("<a class='x' href=\"/q?a=1&b=2\">");
assert_eq!(scope.href.as_deref(), Some("/q?a=1&b=2"));
assert_eq!(scope.role, Some(Role::Link));
}
#[test]
fn anchor_without_href_still_opens_a_scope() {
assert_eq!(scope("<a name=top>").href, None);
}
#[test]
fn image_reads_src_and_alt() {
match classify("<img src=pic.png alt='a cat' width=10>") {
Effect::Image { src, alt } => {
assert_eq!(src, "pic.png");
assert_eq!(alt, "a cat");
}
_ => panic!("expected an image"),
}
}
#[test]
fn attribute_lookup_does_not_match_a_suffix() {
match classify("<img data-src=no srcset=also-no src=yes>") {
Effect::Image { src, .. } => assert_eq!(src, "yes"),
_ => panic!("expected an image"),
}
}
#[test]
fn transliteration_is_all_or_nothing() {
assert_eq!(transliterate("2", Script::Sub).as_deref(), Some("₂"));
assert_eq!(transliterate("-9", Script::Sup).as_deref(), Some("⁻⁹"));
assert_eq!(transliterate("th", Script::Sup), None);
assert_eq!(transliterate("", Script::Sub), None);
}
#[test]
fn unescape_leaves_lone_ampersands_alone() {
assert_eq!(unescape("a & b"), "a & b");
assert_eq!(unescape("AB&nope;"), "AB&nope;");
}
}