use crate::error::{CssError, Result};
use crate::media::MediaContext;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SupportsQuery {
pub alternatives: Vec<SupportsAlternative>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SupportsAlternative {
pub terms: Vec<SupportsTerm>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SupportsTerm {
pub negated: bool,
pub cond: SupportsCondition,
}
impl SupportsTerm {
pub fn matches(&self, ctx: &MediaContext) -> bool {
self.cond.matches(ctx) != self.negated
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SupportsCondition {
Truecolor,
Color,
Monochrome,
NoColor,
Property(String),
PropertyValue(String, String),
}
impl SupportsQuery {
pub fn matches(&self, ctx: &MediaContext) -> bool {
if self.alternatives.is_empty() {
return true;
}
self.alternatives.iter().any(|a| a.matches(ctx))
}
pub fn parse(s: &str) -> Result<SupportsQuery> {
let lower = s.to_ascii_lowercase();
if lower.trim().is_empty() {
return Ok(SupportsQuery { alternatives: Vec::new() });
}
let mut alternatives = Vec::new();
for part in split_top_level_commas(&lower) {
let trimmed = part.trim();
if trimmed.is_empty() {
return Err(CssError::invalid_selector(
"supports query: empty alternative (stray comma?)",
));
}
alternatives.push(parse_alternative(trimmed)?);
}
Ok(SupportsQuery { alternatives })
}
}
impl SupportsAlternative {
pub fn matches(&self, ctx: &MediaContext) -> bool {
self.terms.iter().all(|t| t.matches(ctx))
}
}
fn parse_alternative(part: &str) -> Result<SupportsAlternative> {
let bytes = part.as_bytes();
let mut i = 0usize;
let mut terms = Vec::new();
loop {
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() {
break;
}
let mut negated = false;
if let Some(consumed) = consume_keyword(bytes, i, "not") {
negated = true;
i = consumed;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
}
if i >= bytes.len() {
return Err(CssError::invalid_selector(
"supports query: `not` at end of alternative (nothing to negate)",
));
}
if bytes[i] != b'(' {
return Err(CssError::invalid_selector(format!(
"supports query: expected `(` near `{}`",
&part[i.min(part.len())..]
)));
}
let close = match part[i..].find(')') {
Some(rel) => i + rel,
None => {
return Err(CssError::invalid_selector(
"supports query: unbalanced parens (missing `)`)",
));
}
};
let inner = &part[i + 1..close];
let cond = parse_condition(inner)?;
terms.push(SupportsTerm { negated, cond });
i = close + 1;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() {
break;
}
if let Some(consumed) = consume_keyword(bytes, i, "and") {
i = consumed;
continue;
}
return Err(CssError::invalid_selector(format!(
"supports query: expected `and` between terms near `{}`",
&part[i..]
)));
}
Ok(SupportsAlternative { terms })
}
fn consume_keyword(bytes: &[u8], i: usize, kw: &str) -> Option<usize> {
let kw_bytes = kw.as_bytes();
if i + kw_bytes.len() > bytes.len() {
return None;
}
if &bytes[i..i + kw_bytes.len()] != kw_bytes {
return None;
}
let after = i + kw_bytes.len();
if after < bytes.len()
&& !bytes[after].is_ascii_whitespace()
&& bytes[after] != b'('
&& bytes[after] != b','
{
return None;
}
Some(after)
}
fn split_top_level_commas(s: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut depth = 0i32;
let mut start = 0usize;
for (idx, ch) in s.char_indices() {
match ch {
'(' => depth += 1,
')' => {
if depth > 0 {
depth -= 1;
}
}
',' if depth == 0 => {
parts.push(&s[start..idx]);
start = idx + 1;
}
_ => {}
}
}
parts.push(&s[start..]);
parts
}
fn parse_condition(inner: &str) -> Result<SupportsCondition> {
let trimmed = inner.trim();
if trimmed.is_empty() {
return Err(CssError::invalid_selector(
"supports query: empty condition `()`",
));
}
if let Some(colon) = trimmed.find(':') {
let name = trimmed[..colon].trim();
let value = trimmed[colon + 1..].trim().to_string();
if name.is_empty() {
return Err(CssError::invalid_selector(
"supports query: `(: value)` has no property name",
));
}
Ok(SupportsCondition::PropertyValue(name.to_string(), value))
} else {
match trimmed {
"truecolor" => Ok(SupportsCondition::Truecolor),
"color" => Ok(SupportsCondition::Color),
"monochrome" => Ok(SupportsCondition::Monochrome),
"no-color" | "nocolor" => Ok(SupportsCondition::NoColor),
other => Ok(SupportsCondition::Property(other.to_string())),
}
}
}
impl SupportsCondition {
pub fn matches(&self, ctx: &MediaContext) -> bool {
match self {
SupportsCondition::Truecolor => ctx.truecolor,
SupportsCondition::Color => !ctx.no_color,
SupportsCondition::Monochrome | SupportsCondition::NoColor => ctx.no_color,
SupportsCondition::Property(p) | SupportsCondition::PropertyValue(p, _) => {
crate::stylesheet::is_known_property(p)
}
}
}
}
impl std::fmt::Display for SupportsQuery {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for (i, a) in self.alternatives.iter().enumerate() {
if i > 0 {
write!(f, ", ")?;
}
std::fmt::Display::fmt(a, f)?;
}
Ok(())
}
}
impl std::fmt::Display for SupportsAlternative {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for (i, t) in self.terms.iter().enumerate() {
if i > 0 {
write!(f, " and ")?;
}
std::fmt::Display::fmt(t, f)?;
}
Ok(())
}
}
impl std::fmt::Display for SupportsTerm {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.negated {
write!(f, "not ")?;
}
std::fmt::Display::fmt(&self.cond, f)
}
}
impl std::fmt::Display for SupportsCondition {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SupportsCondition::Truecolor => write!(f, "(truecolor)"),
SupportsCondition::Color => write!(f, "(color)"),
SupportsCondition::Monochrome => write!(f, "(monochrome)"),
SupportsCondition::NoColor => write!(f, "(no-color)"),
SupportsCondition::Property(p) => write!(f, "({p})"),
SupportsCondition::PropertyValue(p, v) => write!(f, "({p}: {v})"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx_truecolor() -> MediaContext {
MediaContext { truecolor: true, ..Default::default() }
}
fn ctx_no_truecolor() -> MediaContext {
MediaContext { truecolor: false, ..Default::default() }
}
fn ctx_no_color() -> MediaContext {
MediaContext { no_color: true, ..Default::default() }
}
fn ctx_color() -> MediaContext {
MediaContext { no_color: false, ..Default::default() }
}
fn alt<I: IntoIterator<Item = SupportsCondition>>(conds: I) -> SupportsAlternative {
SupportsAlternative {
terms: conds
.into_iter()
.map(|c| SupportsTerm { negated: false, cond: c })
.collect(),
}
}
fn term(c: SupportsCondition) -> SupportsTerm {
SupportsTerm { negated: false, cond: c }
}
fn not_term(c: SupportsCondition) -> SupportsTerm {
SupportsTerm { negated: true, cond: c }
}
fn alt_terms<I: IntoIterator<Item = SupportsTerm>>(terms: I) -> SupportsAlternative {
SupportsAlternative { terms: terms.into_iter().collect() }
}
fn alt_neg(c: SupportsCondition) -> SupportsAlternative {
SupportsAlternative { terms: vec![not_term(c)] }
}
#[test]
fn parse_truecolor() {
let q = SupportsQuery::parse("(truecolor)").unwrap();
assert_eq!(q.alternatives, vec![alt([SupportsCondition::Truecolor])]);
}
#[test]
fn parse_color_and_property() {
let q = SupportsQuery::parse("(color) and (border-style)").unwrap();
assert_eq!(
q.alternatives,
vec![alt([
SupportsCondition::Color,
SupportsCondition::Property("border-style".into()),
])]
);
}
#[test]
fn parse_not_truecolor() {
let q = SupportsQuery::parse("not (truecolor)").unwrap();
assert_eq!(q.alternatives, vec![alt_neg(SupportsCondition::Truecolor)]);
}
#[test]
fn parse_property_value() {
let q = SupportsQuery::parse("(border-style: rounded)").unwrap();
assert_eq!(
q.alternatives,
vec![alt([SupportsCondition::PropertyValue(
"border-style".into(),
"rounded".into()
)])]
);
}
#[test]
fn parse_comma_or() {
let q = SupportsQuery::parse("(truecolor), (color)").unwrap();
assert_eq!(
q.alternatives,
vec![
alt([SupportsCondition::Truecolor]),
alt([SupportsCondition::Color]),
]
);
}
#[test]
fn parse_per_term_not() {
let q = SupportsQuery::parse("not (truecolor) and (color)").unwrap();
assert_eq!(
q.alternatives,
vec![alt_terms([
not_term(SupportsCondition::Truecolor),
term(SupportsCondition::Color),
])]
);
}
#[test]
fn parse_monochrome_and_no_color_aliases() {
assert_eq!(
SupportsQuery::parse("(monochrome)").unwrap().alternatives,
vec![alt([SupportsCondition::Monochrome])]
);
assert_eq!(
SupportsQuery::parse("(no-color)").unwrap().alternatives,
vec![alt([SupportsCondition::NoColor])]
);
assert_eq!(
SupportsQuery::parse("(nocolor)").unwrap().alternatives,
vec![alt([SupportsCondition::NoColor])]
);
}
#[test]
fn parse_empty_query_matches_all() {
let q = SupportsQuery::parse("").unwrap();
assert!(q.alternatives.is_empty());
assert!(q.matches(&MediaContext::default()));
}
#[test]
fn parse_not_is_whole_word() {
assert!(SupportsQuery::parse("notable").is_err());
}
#[test]
fn parse_empty_alternative_errors() {
assert!(SupportsQuery::parse("(truecolor),").is_err());
assert!(SupportsQuery::parse(", (truecolor)").is_err());
}
#[test]
fn parse_unbalanced_parens_error() {
assert!(SupportsQuery::parse("(truecolor").is_err());
}
#[test]
fn parse_not_at_end_errors() {
assert!(SupportsQuery::parse("(truecolor) and not").is_err());
}
#[test]
fn truecolor_matches_only_when_flag_set() {
let q = SupportsQuery::parse("(truecolor)").unwrap();
assert!(q.matches(&ctx_truecolor()));
assert!(!q.matches(&ctx_no_truecolor()));
}
#[test]
fn color_matches_only_when_color_capable() {
let q = SupportsQuery::parse("(color)").unwrap();
assert!(q.matches(&ctx_color()));
assert!(!q.matches(&ctx_no_color()));
}
#[test]
fn monochrome_matches_only_when_no_color() {
let q = SupportsQuery::parse("(monochrome)").unwrap();
assert!(q.matches(&ctx_no_color()));
assert!(!q.matches(&ctx_color()));
}
#[test]
fn no_color_alias_matches_monochrome() {
let q = SupportsQuery::parse("(no-color)").unwrap();
assert!(q.matches(&ctx_no_color()));
assert!(!q.matches(&ctx_color()));
}
#[test]
fn property_matches_known_regardless_of_ctx() {
let q = SupportsQuery::parse("(border-style)").unwrap();
assert!(q.matches(&ctx_truecolor()));
assert!(q.matches(&ctx_no_truecolor()));
assert!(q.matches(&MediaContext::default()));
}
#[test]
fn unknown_property_does_not_match() {
let q = SupportsQuery::parse("(future-thing)").unwrap();
assert!(q.alternatives.len() == 1);
assert!(!q.matches(&MediaContext::default()));
assert!(!q.matches(&ctx_truecolor()));
}
#[test]
fn property_value_matches_known_property() {
let q = SupportsQuery::parse("(border-style: rounded)").unwrap();
assert!(q.matches(&MediaContext::default()));
}
#[test]
fn not_inverts_condition() {
let q = SupportsQuery::parse("not (truecolor)").unwrap();
assert!(q.matches(&ctx_no_truecolor()), "¬truecolor matches when truecolor is off");
assert!(!q.matches(&ctx_truecolor()), "¬truecolor does NOT match when truecolor is on");
}
#[test]
fn and_matches_only_when_all_hold() {
let q = SupportsQuery::parse("(truecolor) and (border-style)").unwrap();
assert!(q.matches(&ctx_truecolor()));
assert!(!q.matches(&ctx_no_truecolor()));
}
#[test]
fn comma_or_matches_either() {
let q = SupportsQuery::parse("(truecolor), (monochrome)").unwrap();
assert!(q.matches(&ctx_truecolor()));
assert!(q.matches(&ctx_no_color()));
let plain = MediaContext { truecolor: false, no_color: false, ..Default::default() };
assert!(!q.matches(&plain));
}
#[test]
fn per_term_negation_matches() {
let q = SupportsQuery::parse("not (truecolor) and (color)").unwrap();
let color_no_tc = MediaContext { truecolor: false, no_color: false, ..Default::default() };
assert!(q.matches(&color_no_tc));
let color_tc = MediaContext { truecolor: true, no_color: false, ..Default::default() };
assert!(!q.matches(&color_tc));
assert!(!q.matches(&ctx_no_color()));
}
#[test]
fn display_roundtrip_simple() {
let q = SupportsQuery::parse("(truecolor) and (color)").unwrap();
assert_eq!(q.to_string(), "(truecolor) and (color)");
}
#[test]
fn display_roundtrip_comma_and_not() {
let q = SupportsQuery::parse("(truecolor), not (color)").unwrap();
assert_eq!(q.to_string(), "(truecolor), not (color)");
}
#[test]
fn display_roundtrip_property_value() {
let q = SupportsQuery::parse("(border-style: rounded)").unwrap();
assert_eq!(q.to_string(), "(border-style: rounded)");
}
#[test]
fn display_roundtrip_negated_term() {
let q = SupportsQuery::parse("not (truecolor) and (color)").unwrap();
assert_eq!(q.to_string(), "not (truecolor) and (color)");
let q2 = SupportsQuery::parse(&q.to_string()).unwrap();
assert_eq!(q, q2);
}
}