use std::sync::LazyLock;
use crate::parser::PseudoClass;
use super::error::{Error, echoed};
use super::xpath_expr::{Condition, Literal, XPathExpr, ascii_lower, xpath_literal};
use super::{Kind, Translator};
macro_rules! cached {
($build:expr) => {{
static FRAGMENT: LazyLock<String> = LazyLock::new(|| $build);
FRAGMENT.as_str()
}};
($build:expr, negated if $negate:expr) => {{
static FRAGMENTS: LazyLock<[String; 2]> = LazyLock::new(|| {
let expr = $build;
let negated = format!("not({expr})");
[expr, negated]
});
FRAGMENTS[usize::from($negate)].as_str()
}};
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum LangSource {
XmlLang,
Lang,
Both,
}
impl LangSource {
fn nearest(self) -> &'static str {
match self {
LangSource::XmlLang => "ancestor-or-self::*[@xml:lang][1]",
LangSource::Lang => "ancestor-or-self::*[@lang][1]",
LangSource::Both => "ancestor-or-self::*[@xml:lang or @lang][1]",
}
}
fn string(self) -> &'static str {
match self {
LangSource::XmlLang => "@xml:lang",
LangSource::Lang => "@lang",
LangSource::Both => {
"concat(@xml:lang, \
substring(@lang, 1, string-length(@lang) * not(@xml:lang)))"
}
}
}
}
fn type_lc() -> String {
ascii_lower("@type")
}
const FIELDSET_DISABLED: &str = "count(ancestor::*[local-name() = 'fieldset'][@disabled]) > \
count(ancestor::*[local-name() = 'legend']\
[not(preceding-sibling::*[local-name() = 'legend'])]\
[parent::*[local-name() = 'fieldset'][@disabled]])";
const DISABLEABLE: [&str; 7] = [
"button", "input", "select", "textarea", "optgroup", "option", "fieldset",
];
fn disableable() -> String {
let names: Vec<String> = DISABLEABLE
.iter()
.map(|name| format!("local-name() = '{name}'"))
.collect();
format!("({})", names.join(" or "))
}
const SELECT_WALK_STOPS: &str = "local-name() = 'select' or local-name() = 'datalist' or \
local-name() = 'hr' or local-name() = 'option'";
fn nearest_select_disabled() -> String {
format!("ancestor::*[{SELECT_WALK_STOPS}][1][local-name() = 'select'][@disabled]")
}
fn option_disabled_by_optgroup() -> String {
format!(
"ancestor::*[local-name() = 'optgroup' or {SELECT_WALK_STOPS}][1]\
[local-name() = 'optgroup'][@disabled]"
)
}
fn actually_disabled() -> String {
let by_optgroup = option_disabled_by_optgroup();
let by_select = nearest_select_disabled();
format!(
"@disabled or \
(local-name() = 'option' and {by_optgroup}) or \
((local-name() = 'option' or local-name() = 'optgroup') and {by_select}) or \
(not(local-name() = 'optgroup' or local-name() = 'option') \
and {FIELDSET_DISABLED})"
)
}
const REQUIRED_INERT_TYPES: &str = "|hidden|range|color|submit|image|reset|button|";
const READONLY_INERT_TYPES: &str =
"|hidden|color|checkbox|radio|file|submit|image|reset|button|range|";
const PLACEHOLDER_INERT_TYPES: &str = concat!(
"|hidden|checkbox|radio|file|submit|image|reset|button|color|range|",
"date|month|week|time|datetime-local|"
);
fn type_is_one_of(keywords: &str) -> String {
let type_lc = type_lc();
format!(
"contains('{keywords}', concat('|', {type_lc}, '|')) \
and not(contains({type_lc}, '|'))"
)
}
fn required_is_inert() -> String {
type_is_one_of(REQUIRED_INERT_TYPES)
}
fn required_applies() -> String {
format!(
"((local-name() = 'input' and not({})) or \
local-name() = 'select' or \
local-name() = 'textarea')",
required_is_inert()
)
}
fn control_actually_disabled() -> String {
format!("@disabled or {FIELDSET_DISABLED}")
}
const CONTENTEDITABLE_STATES: &str = "||true|plaintext-only|false|";
fn editable() -> String {
let ce_lc = ascii_lower("@contenteditable");
format!(
"ancestor-or-self::*[@contenteditable and \
contains('{CONTENTEDITABLE_STATES}', concat('|', {ce_lc}, '|')) \
and not(contains(@contenteditable, '|'))][1]\
[not({ce_lc} = 'false')]"
)
}
fn input_mutable() -> String {
format!(
"not({}) and not(@readonly) and not({})",
type_is_one_of(READONLY_INERT_TYPES),
control_actually_disabled()
)
}
fn textarea_mutable() -> String {
format!("not(@readonly) and not({})", control_actually_disabled())
}
fn read_write(name: Option<&str>, read_only: bool) -> Condition {
let (expr, or_group) = match name {
Some("input") => (
cached!(
format!("({}) or {}", input_mutable(), editable()),
negated if read_only
),
true,
),
Some("textarea") => (
cached!(
format!("({}) or {}", textarea_mutable(), editable()),
negated if read_only
),
true,
),
Some(_) => (cached!(editable(), negated if read_only), false),
None => (
cached!(
format!(
"(local-name() = 'input' and {}) or \
(local-name() = 'textarea' and {}) or \
{}",
input_mutable(),
textarea_mutable(),
editable()
),
negated if read_only
),
true,
),
};
Condition {
expr: expr.to_owned(),
or_group: or_group && !read_only,
}
}
fn submit_button() -> String {
let type_lc = type_lc();
format!(
"(local-name() = 'button' and not({type_lc} = 'reset' or {type_lc} = 'button')) or \
(local-name() = 'input' and ({type_lc} = 'submit' or {type_lc} = 'image'))"
)
}
fn is_default_button() -> String {
format!(
"ancestor::*[local-name() = 'form'] and \
count(. | ancestor::*[local-name() = 'form'][1]/descendant::*[{}][1]) = 1",
submit_button()
)
}
impl Translator {
pub(crate) fn apply_pseudo_class(
&self,
xpath: &mut XPathExpr,
pc: &PseudoClass,
) -> Result<(), Error> {
let name = xpath.local_name();
let condition = match (self.kind(), pc) {
(_, PseudoClass::Dir(_)) => {
Condition::plain("0")
}
(Kind::Generic, PseudoClass::Lang(args)) => {
return self.lang_generic(xpath, args);
}
(Kind::Html, PseudoClass::Lang(args)) => {
return self.lang_html(xpath, args);
}
(Kind::Html, PseudoClass::Checked) => checked_condition(name),
(Kind::Html, PseudoClass::Link) | (Kind::Html, PseudoClass::AnyLink) => {
Condition::plain(match name {
Some("a" | "area") => "@href",
Some(_) => "0",
None => "@href and (local-name() = 'a' or local-name() = 'area')",
})
}
(Kind::Html, PseudoClass::Required) => {
Condition::plain(required_condition(name, false))
}
(Kind::Html, PseudoClass::Optional) => {
Condition::plain(required_condition(name, true))
}
(Kind::Html, PseudoClass::Disabled) => {
disabled_condition(name, true)
}
(Kind::Html, PseudoClass::Enabled) => {
disabled_condition(name, false)
}
(Kind::Html, PseudoClass::ReadWrite) => read_write(name, false),
(Kind::Html, PseudoClass::ReadOnly) => read_write(name, true),
(Kind::Html, PseudoClass::Default) => default_condition(name),
(Kind::Html, PseudoClass::PlaceholderShown) => placeholder_shown_condition(name),
_ => Condition::plain("0"),
};
xpath.push_condition(condition);
Ok(())
}
fn lang_generic(&self, xpath: &mut XPathExpr, ranges: &[String]) -> Result<(), Error> {
let mut conditions: Vec<String> = Vec::new();
for value in ranges {
check_lang_range(value)?;
check_wildcard_position(value)?;
if value.is_empty() {
conditions.push(lang_unknown_condition(self.lang_source()));
} else if value == "*" {
conditions.push(lang_known_condition(self.lang_source()));
} else if let Some(prefix) = value.strip_suffix("-*") {
conditions.push(format!("lang({})", xpath_literal(prefix)));
} else {
conditions.push(format!("lang({})", xpath_literal(value)));
}
}
add_lang_conditions(xpath, conditions);
Ok(())
}
fn lang_html(&self, xpath: &mut XPathExpr, ranges: &[String]) -> Result<(), Error> {
let mut conditions: Vec<String> = Vec::new();
for value in ranges {
check_lang_range(value)?;
if value.is_empty() {
conditions.push(lang_unknown_condition(self.lang_source()));
continue;
}
let range = LangRange::parse(value);
if range.subtags.is_empty() {
conditions.push(lang_known_condition(self.lang_source()));
} else {
conditions.push(lang_ancestor_condition(self.lang_source(), &range));
}
}
add_lang_conditions(xpath, conditions);
Ok(())
}
}
fn checked_condition(name: Option<&str>) -> Condition {
match name {
Some("option") => Condition::plain("@selected"),
Some("input") => Condition::plain(cached!({
let type_lc = type_lc();
format!("@checked and ({type_lc} = 'checkbox' or {type_lc} = 'radio')")
})),
Some(_) => Condition::plain("0"),
None => Condition::or_group(cached!({
let type_lc = type_lc();
format!(
"(@selected and local-name() = 'option') or \
(@checked and local-name() = 'input' \
and ({type_lc} = 'checkbox' or {type_lc} = 'radio'))"
)
})),
}
}
fn default_condition(name: Option<&str>) -> Condition {
match name {
Some("option") => Condition::plain("@selected"),
Some("button") => Condition::plain(cached!({
let type_lc = type_lc();
format!(
"not({type_lc} = 'reset' or {type_lc} = 'button') and {}",
is_default_button()
)
})),
Some("input") => Condition::or_group(cached!({
let type_lc = type_lc();
format!(
"(@checked and ({type_lc} = 'checkbox' or {type_lc} = 'radio')) or \
(({type_lc} = 'submit' or {type_lc} = 'image') and {})",
is_default_button()
)
})),
Some(_) => Condition::plain("0"),
None => Condition::or_group(cached!({
let type_lc = type_lc();
format!(
"(@selected and local-name() = 'option') or \
(@checked and local-name() = 'input' \
and ({type_lc} = 'checkbox' or {type_lc} = 'radio')) or \
(({}) and {})",
submit_button(),
is_default_button()
)
})),
}
}
fn placeholder_shown_condition(name: Option<&str>) -> Condition {
match name {
Some("input") => Condition::plain(cached!(placeholder_shown_input())),
Some("textarea") => Condition::plain(PLACEHOLDER_SHOWN_TEXTAREA),
Some(_) => Condition::plain("0"),
None => Condition::or_group(cached!(format!(
"(local-name() = 'input' and {}) or \
(local-name() = 'textarea' and {PLACEHOLDER_SHOWN_TEXTAREA})",
placeholder_shown_input()
))),
}
}
fn placeholder_shown_input() -> String {
format!(
"string-length(@placeholder) > 0 and not({}) and not(string-length(@value))",
type_is_one_of(PLACEHOLDER_INERT_TYPES)
)
}
const PLACEHOLDER_SHOWN_TEXTAREA: &str = "string-length(@placeholder) > 0 and not(string-length())";
fn required_condition(name: Option<&str>, optional: bool) -> &'static str {
match (name, optional) {
(Some("select" | "textarea"), false) => "@required",
(Some("select" | "textarea"), true) => "not(@required)",
(Some("input"), false) => cached!(format!("@required and not({})", required_is_inert())),
(Some("input"), true) => {
cached!(format!("not(@required) and not({})", required_is_inert()))
}
(Some(_), _) => "0",
(None, false) => cached!(format!("@required and {}", required_applies())),
(None, true) => cached!(format!("not(@required) and {}", required_applies())),
}
}
fn disabled_condition(name: Option<&str>, want_disabled: bool) -> Condition {
let negate = !want_disabled;
let expr = match name {
None if want_disabled => {
cached!(format!("{} and ({})", disableable(), actually_disabled()))
}
None => cached!(format!(
"{} and not({})",
disableable(),
actually_disabled()
)),
Some("optgroup") => cached!(
format!("@disabled or {}", nearest_select_disabled()),
negated if negate
),
Some("option") => cached!(
format!(
"@disabled or {} or {}",
option_disabled_by_optgroup(),
nearest_select_disabled()
),
negated if negate
),
Some(name) if DISABLEABLE.contains(&name) => {
cached!(control_actually_disabled(), negated if negate)
}
Some(_) => "0",
};
Condition {
expr: expr.to_owned(),
or_group: want_disabled && name.is_some_and(|name| DISABLEABLE.contains(&name)),
}
}
fn check_lang_range(range: &str) -> Result<(), Error> {
if range.is_empty() {
return Ok(()); }
for subtag in range.split('-') {
let fault = if subtag.is_empty() {
"an empty subtag, which a language range cannot have"
} else if subtag != "*" && subtag.contains('*') {
"a wildcard glued to a subtag, \
where a language range takes one only as a whole subtag"
} else {
continue;
};
return Err(Error::unsupported(format!(
"the :lang() language range {:?} ({fault})",
echoed(range)
)));
}
Ok(())
}
fn check_wildcard_position(range: &str) -> Result<(), Error> {
if let Some(pos) = range.find('*')
&& pos != range.len() - 1
{
return Err(Error::unsupported(format!(
"the :lang() language range {:?} \
(a wildcard outside the final subtag, \
which XPath's lang() cannot express)",
echoed(range)
)));
}
Ok(())
}
fn add_lang_conditions(xpath: &mut XPathExpr, mut conditions: Vec<String>) {
match conditions.len() {
0 => {}
1 => xpath.add_condition(conditions.pop().expect("checked")),
_ => xpath.add_or_condition(conditions.join(" or ")),
}
}
fn lang_known_condition(source: LangSource) -> String {
format!(
"{}[string-length({}) > 0]",
source.nearest(),
source.string()
)
}
fn lang_unknown_condition(source: LangSource) -> String {
format!("not({})", lang_known_condition(source))
}
fn folded_lang(source: LangSource) -> String {
format!("concat({}, '-')", ascii_lower(source.string()))
}
struct LangRange {
wildcard_head: bool,
subtags: Vec<String>,
}
impl LangRange {
fn parse(range: &str) -> Self {
let mut subtags = range.split('-').map(str::to_ascii_lowercase);
let head = subtags.next().expect("split is non-empty");
let wildcard_head = head == "*";
let mut literals: Vec<String> = if wildcard_head {
Vec::new()
} else {
vec![head]
};
literals.extend(subtags.filter(|subtag| subtag != "*"));
Self {
wildcard_head,
subtags: literals,
}
}
}
fn lang_ancestor_condition(source: LangSource, range: &LangRange) -> String {
let lang = folded_lang(source);
let mut subtags = range.subtags.iter();
let mut conditions: Vec<String> = Vec::new();
let mut tail = if range.wildcard_head {
format!("substring-after({lang}, '-')")
} else {
let first = Literal::affixed(
"",
subtags
.next()
.expect("no literals is the known-language test"),
"-",
);
conditions.push(format!("starts-with({lang}, {first})"));
format!("substring-after({lang}, {first})")
};
for subtag in subtags {
let needle = Literal::affixed("-", subtag, "-");
let bounded = format!("concat('-', {tail})");
conditions.push(format!("contains({bounded}, {needle})"));
tail = format!("substring-after({bounded}, {needle})");
}
format!("{}[{}]", source.nearest(), conditions.join(" and "))
}