use crate::parser::PseudoClass;
use super::error::Error;
use super::xpath_expr::{Condition, XPathExpr, ascii_lower, xpath_literal};
use super::{Kind, Translator};
#[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 "))
}
fn actually_disabled() -> String {
format!(
"@disabled or \
(local-name() = 'option' and parent::*[local-name() = 'optgroup'][@disabled]) 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>) -> Condition {
let editable = editable();
match name {
Some("input") => or_group(&format!("({}) or {editable}", input_mutable())),
Some("textarea") => or_group(&format!("({}) or {editable}", textarea_mutable())),
Some(_) => plain(&editable),
None => or_group(&format!(
"(local-name() = 'input' and {}) or \
(local-name() = 'textarea' and {}) or \
{editable}",
input_mutable(),
textarea_mutable()
)),
}
}
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.clone();
let name = name.as_deref();
match (self.kind(), pc) {
(_, PseudoClass::Dir(_)) => {
xpath.add_condition("0");
}
(Kind::Generic, PseudoClass::Lang(args)) => {
self.lang_generic(xpath, args)?;
}
(Kind::Html, PseudoClass::Lang(args)) => {
self.lang_html(xpath, args)?;
}
(Kind::Html, PseudoClass::Checked) => {
xpath.push_condition(checked_condition(name));
}
(Kind::Html, PseudoClass::Link) | (Kind::Html, PseudoClass::AnyLink) => {
xpath.add_condition(&match name {
Some("a" | "area") => "@href".to_owned(),
Some(_) => "0".to_owned(),
None => "@href and (local-name() = 'a' or local-name() = 'area')".to_owned(),
});
}
(Kind::Html, PseudoClass::Required) => {
xpath.add_condition(&required_condition(name, "@required"));
}
(Kind::Html, PseudoClass::Optional) => {
xpath.add_condition(&required_condition(name, "not(@required)"));
}
(Kind::Html, PseudoClass::Disabled) => {
xpath.push_condition(disabled_condition(name, true));
}
(Kind::Html, PseudoClass::Enabled) => {
xpath.push_condition(disabled_condition(name, false));
}
(Kind::Html, PseudoClass::ReadWrite) => {
xpath.push_condition(read_write(name));
}
(Kind::Html, PseudoClass::ReadOnly) => {
xpath.add_condition(&format!("not({})", read_write(name).expr));
}
(Kind::Html, PseudoClass::Default) => {
xpath.push_condition(default_condition(name));
}
(Kind::Html, PseudoClass::PlaceholderShown) => {
xpath.push_condition(placeholder_shown_condition(name));
}
_ => {
xpath.add_condition("0");
}
}
Ok(())
}
fn lang_generic(&self, xpath: &mut XPathExpr, ranges: &[String]) -> Result<(), Error> {
let mut conditions: Vec<String> = Vec::new();
for value in ranges {
check_wildcard_position(value)?;
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_wildcard_position(value)?;
if value == "*" {
conditions.push(lang_known_condition(self.lang_source()));
} else {
let range = value.strip_suffix("-*").unwrap_or(value);
conditions.push(lang_ancestor_condition(self.lang_source(), range));
}
}
add_lang_conditions(xpath, &conditions);
Ok(())
}
}
fn checked_condition(name: Option<&str>) -> Condition {
let type_lc = type_lc();
match name {
Some("option") => plain("@selected"),
Some("input") => plain(&format!(
"@checked and ({type_lc} = 'checkbox' or {type_lc} = 'radio')"
)),
Some(_) => plain("0"),
None => or_group(&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 {
let type_lc = type_lc();
let default_button = is_default_button();
match name {
Some("option") => plain("@selected"),
Some("button") => plain(&format!(
"not({type_lc} = 'reset' or {type_lc} = 'button') and {default_button}"
)),
Some("input") => or_group(&format!(
"(@checked and ({type_lc} = 'checkbox' or {type_lc} = 'radio')) or \
(({type_lc} = 'submit' or {type_lc} = 'image') and {default_button})"
)),
Some(_) => plain("0"),
None => or_group(&format!(
"(@selected and local-name() = 'option') or \
(@checked and local-name() = 'input' \
and ({type_lc} = 'checkbox' or {type_lc} = 'radio')) or \
(({}) and {default_button})",
submit_button()
)),
}
}
fn placeholder_shown_condition(name: Option<&str>) -> Condition {
let input = format!(
"string-length(@placeholder) > 0 and not({}) and not(string-length(@value))",
type_is_one_of(PLACEHOLDER_INERT_TYPES)
);
let textarea = "string-length(@placeholder) > 0 and not(string-length())";
match name {
Some("input") => plain(&input),
Some("textarea") => plain(textarea),
Some(_) => plain("0"),
None => or_group(&format!(
"(local-name() = 'input' and {input}) or \
(local-name() = 'textarea' and {textarea})"
)),
}
}
fn required_condition(name: Option<&str>, attr: &str) -> String {
match name {
Some("select" | "textarea") => attr.to_owned(),
Some("input") => format!("{attr} and not({})", required_is_inert()),
Some(_) => "0".to_owned(),
None => format!("{attr} and {}", required_applies()),
}
}
fn disabled_condition(name: Option<&str>, want_disabled: bool) -> Condition {
let Some(name) = name else {
let (set, actually) = (disableable(), actually_disabled());
return plain(&if want_disabled {
format!("{set} and ({actually})")
} else {
format!("{set} and not({actually})")
});
};
if !DISABLEABLE.contains(&name) {
return plain("0");
}
let (actually, or_group) = match name {
"optgroup" => ("@disabled".to_owned(), false),
"option" => (
"@disabled or parent::*[local-name() = 'optgroup'][@disabled]".to_owned(),
true,
),
_ => (control_actually_disabled(), true),
};
if want_disabled {
Condition {
expr: actually,
or_group,
}
} else {
plain(&format!("not({actually})"))
}
}
fn plain(expr: &str) -> Condition {
Condition {
expr: expr.to_owned(),
or_group: false,
}
}
fn or_group(expr: &str) -> Condition {
Condition {
expr: expr.to_owned(),
or_group: true,
}
}
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 {range:?} \
(a wildcard outside the final subtag)"
)));
}
Ok(())
}
fn add_lang_conditions(xpath: &mut XPathExpr, conditions: &[String]) {
match conditions.len() {
0 => {}
1 => xpath.add_condition(&conditions[0]),
_ => xpath.add_or_condition(&conditions.join(" or ")),
}
}
fn lang_known_condition(source: LangSource) -> String {
format!(
"{}[string-length({}) > 0]",
source.nearest(),
source.string()
)
}
fn folded_lang(source: LangSource) -> String {
format!("concat({}, '-')", ascii_lower(source.string()))
}
fn lang_ancestor_condition(source: LangSource, range: &str) -> String {
let range = range.to_ascii_lowercase();
let mut subtags = range.split('-');
let lang = folded_lang(source);
let first = xpath_literal(&format!("{}-", subtags.next().expect("split is non-empty")));
let mut conditions = format!("starts-with({lang}, {first})");
let mut tail = format!("substring-after({lang}, {first})");
for subtag in subtags {
let needle = xpath_literal(&format!("-{subtag}-"));
let bounded = format!("concat('-', {tail})");
conditions.push_str(&format!(" and contains({bounded}, {needle})"));
tail = format!("substring-after({bounded}, {needle})");
}
format!("{}[{conditions}]", source.nearest())
}