use crate::error::{CssError, Result};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct MediaContext {
pub cols: u16,
pub rows: u16,
pub truecolor: bool,
pub no_color: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MediaQuery {
pub alternatives: Vec<MediaAlternative>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MediaTerm {
pub negated: bool,
pub cond: MediaCondition,
}
impl MediaTerm {
pub fn matches(&self, ctx: &MediaContext) -> bool {
self.cond.matches(ctx) != self.negated
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MediaAlternative {
pub terms: Vec<MediaTerm>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MediaCondition {
MinWidth(u16),
MaxWidth(u16),
Width(u16),
MinHeight(u16),
MaxHeight(u16),
Height(u16),
Color,
Monochrome,
Truecolor,
}
impl MediaQuery {
pub fn matches(&self, ctx: &MediaContext) -> bool {
if self.alternatives.is_empty() {
return true;
}
self.alternatives.iter().any(|a| a.matches(ctx))
}
pub fn and(&self, other: &MediaQuery) -> MediaQuery {
if self.alternatives.is_empty() {
return other.clone();
}
if other.alternatives.is_empty() {
return self.clone();
}
let mut combined = Vec::with_capacity(self.alternatives.len() * other.alternatives.len());
for a1 in &self.alternatives {
for a2 in &other.alternatives {
let mut terms = Vec::with_capacity(a1.terms.len() + a2.terms.len());
terms.extend(a1.terms.iter().cloned());
terms.extend(a2.terms.iter().cloned());
combined.push(MediaAlternative { terms });
}
}
MediaQuery { alternatives: combined }
}
pub(crate) fn matching_specificity(&self, media: &MediaContext) -> Option<usize> {
if self.alternatives.is_empty() {
return Some(0);
}
let mut best: Option<usize> = None;
for a in &self.alternatives {
if a.matches(media) {
let n = a.terms.len();
best = Some(match best {
Some(b) if b >= n => b,
_ => n,
});
}
}
best
}
pub fn parse(s: &str) -> Result<MediaQuery> {
let lower = s.to_ascii_lowercase();
if lower.trim().is_empty() {
return Ok(MediaQuery { 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(
"media query: empty alternative (stray comma?)",
));
}
alternatives.push(parse_alternative(trimmed)?);
}
Ok(MediaQuery { alternatives })
}
}
impl MediaAlternative {
pub fn matches(&self, ctx: &MediaContext) -> bool {
self.terms.iter().all(|t| t.matches(ctx))
}
}
fn parse_alternative(part: &str) -> Result<MediaAlternative> {
let bytes = part.as_bytes();
let mut i = 0usize;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if let Some(consumed) = consume_keyword(bytes, i, "not") {
let mut j = consumed;
while j < bytes.len() && bytes[j].is_ascii_whitespace() {
j += 1;
}
let mut is_type = false;
for kw in ["only", "screen", "all", "print"] {
if consume_keyword(bytes, j, kw).is_some() {
is_type = true;
break;
}
}
if is_type {
i = consumed;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
}
}
loop {
let prev_i = i;
for kw in ["only", "screen", "all", "print"] {
if let Some(consumed) = consume_keyword(bytes, i, kw) {
i = consumed;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
break;
}
}
if i == prev_i {
break;
}
}
if let Some(consumed) = consume_keyword(bytes, i, "and") {
i = consumed;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
}
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(
"media query: `not` at end of alternative (nothing to negate)",
));
}
if bytes[i] != b'(' {
return Err(CssError::invalid_selector(format!(
"media query: expected `(` near `{}`",
&part[i.min(part.len())..]
)));
}
let close = match part[i..].find(')') {
Some(rel) => i + rel,
None => {
return Err(CssError::invalid_selector(
"media query: unbalanced parens (missing `)`)",
));
}
};
let inner = &part[i + 1..close];
let cond = parse_condition(inner)?;
terms.push(MediaTerm { 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!(
"media query: expected `and` between terms near `{}`",
&part[i..]
)));
}
Ok(MediaAlternative { 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<MediaCondition> {
let trimmed = inner.trim();
if trimmed.is_empty() {
return Err(CssError::invalid_selector(
"media query: empty condition `()`",
));
}
if let Some(colon) = trimmed.find(':') {
let feature = trimmed[..colon].trim();
let value = trimmed[colon + 1..].trim();
parse_feature_value(feature, value)
} else {
parse_feature_bare(trimmed)
}
}
fn parse_feature_bare(feature: &str) -> Result<MediaCondition> {
match feature {
"min-width" | "max-width" | "width" | "min-height" | "max-height" | "height" => {
Err(CssError::invalid_selector(format!(
"media query: `({feature})` requires a value, e.g. `({feature}: 80)`"
)))
}
"color" => Ok(MediaCondition::Color),
"monochrome" => Ok(MediaCondition::Monochrome),
"truecolor" => Ok(MediaCondition::Truecolor),
other => Err(CssError::invalid_selector(format!(
"media query: unknown feature `{other}`"
))),
}
}
fn parse_feature_value(feature: &str, value: &str) -> Result<MediaCondition> {
match feature {
"min-width" => Ok(MediaCondition::MinWidth(parse_u16(value, "min-width")?)),
"max-width" => Ok(MediaCondition::MaxWidth(parse_u16(value, "max-width")?)),
"width" => Ok(MediaCondition::Width(parse_u16(value, "width")?)),
"min-height" => Ok(MediaCondition::MinHeight(parse_u16(value, "min-height")?)),
"max-height" => Ok(MediaCondition::MaxHeight(parse_u16(value, "max-height")?)),
"height" => Ok(MediaCondition::Height(parse_u16(value, "height")?)),
"color" => {
match value {
"0" => Ok(MediaCondition::Monochrome),
_ => {
let n = parse_u16(value, "color")?;
if n == 0 {
Ok(MediaCondition::Monochrome)
} else {
Ok(MediaCondition::Color)
}
}
}
}
"monochrome" => {
match value {
"0" => Ok(MediaCondition::Color),
_ => {
let n = parse_u16(value, "monochrome")?;
if n == 0 {
Ok(MediaCondition::Color)
} else {
Ok(MediaCondition::Monochrome)
}
}
}
}
"truecolor" => {
let n = parse_u16(value, "truecolor")?;
if n >= 1 {
Ok(MediaCondition::Truecolor)
} else {
Err(CssError::invalid_selector(
"media query: `(truecolor: 0)` is not supported — use a separate context",
))
}
}
other => Err(CssError::invalid_selector(format!(
"media query: unknown feature `{other}`"
))),
}
}
fn parse_u16(value: &str, feature: &str) -> Result<u16> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(CssError::invalid_selector(format!(
"media query: `({feature}:)` has no value"
)));
}
if trimmed.starts_with('-') {
return Err(CssError::invalid_selector(format!(
"media query: `({feature}: {trimmed})` value must be non-negative"
)));
}
trimmed.parse::<u16>().map_err(|_| {
CssError::invalid_selector(format!(
"media query: `({feature}: {trimmed})` value is not a number"
))
})
}
impl MediaCondition {
pub fn matches(&self, ctx: &MediaContext) -> bool {
match *self {
MediaCondition::MinWidth(n) => ctx.cols >= n,
MediaCondition::MaxWidth(n) => ctx.cols <= n,
MediaCondition::Width(n) => ctx.cols == n,
MediaCondition::MinHeight(n) => ctx.rows >= n,
MediaCondition::MaxHeight(n) => ctx.rows <= n,
MediaCondition::Height(n) => ctx.rows == n,
MediaCondition::Color => !ctx.no_color,
MediaCondition::Monochrome => ctx.no_color,
MediaCondition::Truecolor => ctx.truecolor,
}
}
}
impl std::fmt::Display for MediaQuery {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.alternatives.is_empty() {
return write!(f, "all");
}
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 MediaAlternative {
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 MediaTerm {
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 MediaCondition {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match *self {
MediaCondition::MinWidth(n) => write!(f, "(min-width: {n})"),
MediaCondition::MaxWidth(n) => write!(f, "(max-width: {n})"),
MediaCondition::Width(n) => write!(f, "(width: {n})"),
MediaCondition::MinHeight(n) => write!(f, "(min-height: {n})"),
MediaCondition::MaxHeight(n) => write!(f, "(max-height: {n})"),
MediaCondition::Height(n) => write!(f, "(height: {n})"),
MediaCondition::Color => write!(f, "(color)"),
MediaCondition::Monochrome => write!(f, "(monochrome)"),
MediaCondition::Truecolor => write!(f, "(truecolor)"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx(cols: u16, rows: u16) -> MediaContext {
MediaContext {
cols,
rows,
truecolor: false,
no_color: false,
}
}
fn alt<I: IntoIterator<Item = MediaCondition>>(conds: I) -> MediaAlternative {
MediaAlternative {
terms: conds
.into_iter()
.map(|c| MediaTerm { negated: false, cond: c })
.collect(),
}
}
fn not_term(c: MediaCondition) -> MediaTerm {
MediaTerm { negated: true, cond: c }
}
fn term(c: MediaCondition) -> MediaTerm {
MediaTerm { negated: false, cond: c }
}
fn alt_neg(c: MediaCondition) -> MediaAlternative {
MediaAlternative { terms: vec![not_term(c)] }
}
fn alt_terms<I: IntoIterator<Item = MediaTerm>>(terms: I) -> MediaAlternative {
MediaAlternative { terms: terms.into_iter().collect() }
}
fn no_color_ctx() -> MediaContext {
MediaContext { no_color: true, ..Default::default() }
}
#[test]
fn parse_min_width() {
let q = MediaQuery::parse("(min-width: 80)").unwrap();
assert_eq!(q.alternatives, vec![alt([MediaCondition::MinWidth(80)])]);
}
#[test]
fn parse_max_width_and_min_height() {
let q = MediaQuery::parse("(max-width: 120) and (min-height: 24)").unwrap();
assert_eq!(
q.alternatives,
vec![alt([MediaCondition::MaxWidth(120), MediaCondition::MinHeight(24)])]
);
}
#[test]
fn parse_width_exact() {
let q = MediaQuery::parse("(width: 80)").unwrap();
assert_eq!(q.alternatives, vec![alt([MediaCondition::Width(80)])]);
}
#[test]
fn parse_color_bare() {
let q = MediaQuery::parse("(color)").unwrap();
assert_eq!(q.alternatives, vec![alt([MediaCondition::Color])]);
}
#[test]
fn parse_monochrome_bare() {
let q = MediaQuery::parse("(monochrome)").unwrap();
assert_eq!(q.alternatives, vec![alt([MediaCondition::Monochrome])]);
}
#[test]
fn parse_truecolor_bare() {
let q = MediaQuery::parse("(truecolor)").unwrap();
assert_eq!(q.alternatives, vec![alt([MediaCondition::Truecolor])]);
}
#[test]
fn parse_leading_media_type_ignored() {
let q = MediaQuery::parse("screen and (min-width: 80)").unwrap();
assert_eq!(q.alternatives, vec![alt([MediaCondition::MinWidth(80)])]);
let q2 = MediaQuery::parse("all and (max-height: 40)").unwrap();
assert_eq!(q2.alternatives, vec![alt([MediaCondition::MaxHeight(40)])]);
}
#[test]
fn parse_empty_query_matches_all() {
let q = MediaQuery::parse("").unwrap();
assert!(q.alternatives.is_empty());
assert!(q.matches(&MediaContext::default()));
}
#[test]
fn parse_uppercase_features() {
let q = MediaQuery::parse("(MIN-WIDTH: 80)").unwrap();
assert_eq!(q.alternatives, vec![alt([MediaCondition::MinWidth(80)])]);
}
#[test]
fn parse_comma_or_two_alternatives() {
let q = MediaQuery::parse("(min-width: 80), (max-width: 120)").unwrap();
assert_eq!(
q.alternatives,
vec![
alt([MediaCondition::MinWidth(80)]),
alt([MediaCondition::MaxWidth(120)]),
]
);
}
#[test]
fn parse_not_prefix_single() {
let q = MediaQuery::parse("not (min-width: 80)").unwrap();
assert_eq!(q.alternatives, vec![alt_neg(MediaCondition::MinWidth(80))]);
}
#[test]
fn parse_comma_three_alternatives() {
let q = MediaQuery::parse("(min-width: 80), (max-width: 120), (color)").unwrap();
assert_eq!(
q.alternatives,
vec![
alt([MediaCondition::MinWidth(80)]),
alt([MediaCondition::MaxWidth(120)]),
alt([MediaCondition::Color]),
]
);
}
#[test]
fn parse_not_screen_media_type_ignored() {
let q = MediaQuery::parse("not screen and (min-width: 80)").unwrap();
assert_eq!(q.alternatives, vec![alt([MediaCondition::MinWidth(80)])]);
}
#[test]
fn parse_comma_with_not_second_alt() {
let q = MediaQuery::parse("(min-width: 80), not (color)").unwrap();
assert_eq!(
q.alternatives,
vec![
alt([MediaCondition::MinWidth(80)]),
alt_neg(MediaCondition::Color),
]
);
}
#[test]
fn parse_and_chain_one_alternative_regression() {
let q = MediaQuery::parse("(min-width: 80) and (max-height: 40)").unwrap();
assert_eq!(
q.alternatives,
vec![alt([MediaCondition::MinWidth(80), MediaCondition::MaxHeight(40)])]
);
}
#[test]
fn parse_not_is_whole_word() {
assert!(MediaQuery::parse("notable").is_err());
}
#[test]
fn parse_bare_media_type_matches_all() {
let q = MediaQuery::parse("screen").unwrap();
assert_eq!(q.alternatives, vec![MediaAlternative { terms: vec![] }]);
assert!(q.matches(&MediaContext::default()));
}
#[test]
fn parse_empty_alternative_errors() {
assert!(MediaQuery::parse("(min-width: 80),").is_err());
assert!(MediaQuery::parse(", (min-width: 80)").is_err());
}
#[test]
fn parse_unknown_feature_errors() {
assert!(MediaQuery::parse("(foo: 1)").is_err());
}
#[test]
fn parse_non_numeric_width_errors() {
assert!(MediaQuery::parse("(min-width: wide)").is_err());
}
#[test]
fn parse_unbalanced_parens_error() {
assert!(MediaQuery::parse("(min-width: 80").is_err());
}
#[test]
fn parse_missing_value_errors() {
assert!(MediaQuery::parse("(min-width)").is_err());
}
#[test]
fn parse_negative_value_errors() {
assert!(MediaQuery::parse("(min-width: -5)").is_err());
}
#[test]
fn min_width_matches() {
let q = MediaQuery::parse("(min-width: 80)").unwrap();
assert!(q.matches(&ctx(100, 24)));
assert!(q.matches(&ctx(80, 24)));
assert!(!q.matches(&ctx(60, 24)));
}
#[test]
fn max_width_and_min_height_matches() {
let q = MediaQuery::parse("(max-width: 120) and (min-height: 24)").unwrap();
assert!(q.matches(&ctx(100, 24)));
assert!(q.matches(&ctx(120, 30)));
assert!(!q.matches(&ctx(200, 24))); assert!(!q.matches(&ctx(100, 10))); assert!(!q.matches(&ctx(200, 10))); }
#[test]
fn truecolor_only_when_flag_set() {
let q = MediaQuery::parse("(truecolor)").unwrap();
assert!(!q.matches(&MediaContext { truecolor: false, ..Default::default() }));
assert!(q.matches(&MediaContext { truecolor: true, ..Default::default() }));
}
#[test]
fn monochrome_only_when_no_color() {
let q = MediaQuery::parse("(monochrome)").unwrap();
assert!(!q.matches(&MediaContext { no_color: false, ..Default::default() }));
assert!(q.matches(&MediaContext { no_color: true, ..Default::default() }));
}
#[test]
fn color_inverts_monochrome() {
let color_q = MediaQuery::parse("(color)").unwrap();
assert!(color_q.matches(&MediaContext { no_color: false, ..Default::default() }));
assert!(!color_q.matches(&MediaContext { no_color: true, ..Default::default() }));
}
#[test]
fn default_context_does_not_match_gated_query() {
let q = MediaQuery::parse("(min-width: 80)").unwrap();
assert!(!q.matches(&MediaContext::default()));
}
#[test]
fn comma_or_matches_either_alternative() {
let q = MediaQuery::parse("(min-width: 100), (max-width: 50)").unwrap();
assert!(q.matches(&ctx(100, 24)));
assert!(q.matches(&ctx(150, 24)));
assert!(q.matches(&ctx(40, 24)));
assert!(q.matches(&ctx(50, 24)));
assert!(!q.matches(&ctx(70, 24)));
}
#[test]
fn not_prefix_inverts_single_condition() {
let q = MediaQuery::parse("not (min-width: 80)").unwrap();
assert!(q.matches(&ctx(60, 24))); assert!(q.matches(&ctx(79, 24)));
assert!(!q.matches(&ctx(100, 24))); assert!(!q.matches(&ctx(80, 24)));
}
#[test]
fn and_chain_matches_only_when_all_hold() {
let q = MediaQuery::parse("(min-width: 80) and (max-width: 120)").unwrap();
assert!(q.matches(&ctx(100, 24)));
assert!(q.matches(&ctx(80, 24)));
assert!(q.matches(&ctx(120, 24)));
assert!(!q.matches(&ctx(60, 24))); assert!(!q.matches(&ctx(200, 24))); }
#[test]
fn comma_with_not_second_alt() {
let q = MediaQuery::parse("(min-width: 200), not (color)").unwrap();
assert!(q.matches(&no_color_ctx()));
assert!(!q.matches(&ctx(100, 24)));
assert!(q.matches(&ctx(200, 24)));
}
#[test]
fn not_all_conditions_in_one_alternative() {
let q = MediaQuery::parse("not (min-width: 80) and (color)").unwrap();
assert_eq!(
q.alternatives,
vec![alt_terms([
not_term(MediaCondition::MinWidth(80)),
term(MediaCondition::Color),
])]
);
assert!(q.matches(&ctx(60, 24)));
assert!(!q.matches(&ctx(100, 24)));
let small_mono = MediaContext { cols: 60, no_color: true, ..Default::default() };
assert!(!q.matches(&small_mono));
let large_mono = MediaContext { cols: 100, no_color: true, ..Default::default() };
assert!(!q.matches(&large_mono));
}
#[test]
fn display_roundtrip() {
let q = MediaQuery::parse("(min-width: 80) and (color)").unwrap();
assert_eq!(q.to_string(), "(min-width: 80) and (color)");
}
#[test]
fn display_roundtrip_comma_and_not() {
let q = MediaQuery::parse("(min-width: 80), not (color)").unwrap();
assert_eq!(q.to_string(), "(min-width: 80), not (color)");
}
#[test]
fn media_query_and_concatenates_conditions() {
let q1 = MediaQuery::parse("(min-width: 80)").unwrap();
let q2 = MediaQuery::parse("(color)").unwrap();
let combined = q1.and(&q2);
assert_eq!(
combined.alternatives,
vec![alt([MediaCondition::MinWidth(80), MediaCondition::Color])],
"AND of two single-condition queries concatenates conditions"
);
let both = MediaContext { cols: 100, no_color: false, ..Default::default() };
let width_only = MediaContext { cols: 100, no_color: true, ..Default::default() };
let color_only = MediaContext { cols: 60, no_color: false, ..Default::default() };
let neither = MediaContext { cols: 60, no_color: true, ..Default::default() };
assert!(combined.matches(&both), "both hold → matches");
assert!(!combined.matches(&width_only), "color missing → no match");
assert!(!combined.matches(&color_only), "width missing → no match");
assert!(!combined.matches(&neither), "neither → no match");
}
#[test]
fn media_query_and_cross_product() {
let q1 = MediaQuery::parse("(min-width: 80), (max-width: 40)").unwrap();
let q2 = MediaQuery::parse("(color)").unwrap();
let combined = q1.and(&q2);
assert_eq!(
combined.alternatives,
vec![
alt([MediaCondition::MinWidth(80), MediaCondition::Color]),
alt([MediaCondition::MaxWidth(40), MediaCondition::Color]),
],
"OR cross-product with AND concatenates per-alternative"
);
let large_color = MediaContext { cols: 100, no_color: false, ..Default::default() };
assert!(combined.matches(&large_color));
let small_color = MediaContext { cols: 30, no_color: false, ..Default::default() };
assert!(combined.matches(&small_color));
let mid_color = MediaContext { cols: 50, no_color: false, ..Default::default() };
assert!(!combined.matches(&mid_color));
let large_mono = MediaContext { cols: 100, no_color: true, ..Default::default() };
assert!(!combined.matches(&large_mono));
}
#[test]
fn media_query_and_empty_short_circuit() {
let empty = MediaQuery::default();
let other = MediaQuery::parse("(min-width: 80)").unwrap();
assert_eq!(empty.and(&other), other, "match-all AND other == other");
assert_eq!(other.and(&empty), other, "other AND match-all == other");
assert_eq!(empty.and(&MediaQuery::default()), MediaQuery::default());
}
#[test]
fn media_query_and_is_now_exact_with_negation() {
let not_q = MediaQuery::parse("not (min-width: 80)").unwrap();
let color_q = MediaQuery::parse("(color)").unwrap();
let combined = not_q.and(&color_q);
assert_eq!(
combined.alternatives,
vec![alt_terms([
not_term(MediaCondition::MinWidth(80)),
term(MediaCondition::Color),
])],
"AND is exact: per-term negation preserved, no whole-alt negation"
);
let small_color = MediaContext { cols: 60, no_color: false, ..Default::default() };
assert!(combined.matches(&small_color), "exact: ¬width AND color → matches");
let large_color = MediaContext { cols: 100, no_color: false, ..Default::default() };
assert!(!combined.matches(&large_color), "exact: width holds so ¬width false → no match");
let small_mono = MediaContext { cols: 60, no_color: true, ..Default::default() };
assert!(!combined.matches(&small_mono), "exact: color missing → no match");
}
#[test]
fn matching_specificity_returns_max_condition_count() {
let q = MediaQuery::parse("(min-width: 80) and (color), (max-width: 40)").unwrap();
let large_color = MediaContext { cols: 100, no_color: false, ..Default::default() };
assert_eq!(q.matching_specificity(&large_color), Some(2));
let small_color = MediaContext { cols: 30, no_color: false, ..Default::default() };
assert_eq!(q.matching_specificity(&small_color), Some(1));
}
#[test]
fn matching_specificity_none_when_no_match() {
let q = MediaQuery::parse("(min-width: 80)").unwrap();
let small = MediaContext { cols: 60, ..Default::default() };
assert_eq!(q.matching_specificity(&small), None);
}
#[test]
fn matching_specificity_zero_for_match_all() {
let empty = MediaQuery::default();
assert_eq!(empty.matching_specificity(&MediaContext::default()), Some(0));
}
#[test]
fn matching_specificity_counts_terms() {
let q = MediaQuery::parse("(min-width: 80) and (color)").unwrap();
let large_color = MediaContext { cols: 100, no_color: false, ..Default::default() };
assert_eq!(q.matching_specificity(&large_color), Some(2));
}
#[test]
fn matching_specificity_counts_negated_terms() {
let q = MediaQuery::parse("not (min-width: 80)").unwrap();
let small = MediaContext { cols: 60, ..Default::default() };
assert_eq!(q.matching_specificity(&small), Some(1));
let q2 = MediaQuery::parse("not (min-width: 80) and (color)").unwrap();
let small_mono = MediaContext { cols: 60, no_color: true, ..Default::default() };
assert_eq!(q2.matching_specificity(&small_mono), None, "color missing → no match");
let small_color = MediaContext { cols: 60, no_color: false, ..Default::default() };
assert_eq!(
q2.matching_specificity(&small_color),
Some(2),
"both terms satisfied → 2-term specificity"
);
}
#[test]
fn not_is_per_term() {
let q = MediaQuery::parse("not (min-width: 80) and (color)").unwrap();
assert_eq!(q.alternatives.len(), 1, "one alternative, not two");
let alt0 = &q.alternatives[0];
assert_eq!(alt0.terms.len(), 2, "two terms in the alternative");
assert_eq!(alt0.terms[0], not_term(MediaCondition::MinWidth(80)));
assert_eq!(alt0.terms[1], term(MediaCondition::Color));
}
#[test]
fn per_term_negation_matches() {
let q = MediaQuery::parse("not (min-width: 80)").unwrap();
assert!(q.matches(&ctx(60, 24)));
assert!(!q.matches(&ctx(100, 24)));
let q2 = MediaQuery::parse("not (min-width: 80) and (color)").unwrap();
let small_color = MediaContext { cols: 60, no_color: false, ..Default::default() };
let small_mono = MediaContext { cols: 60, no_color: true, ..Default::default() };
let large_color = MediaContext { cols: 100, no_color: false, ..Default::default() };
assert!(q2.matches(&small_color), "cols<80 AND color → matches");
assert!(!q2.matches(&small_mono), "cols<80 but no color → no match");
assert!(!q2.matches(&large_color), "cols>=80 → ¬min-width false → no match");
}
#[test]
fn comma_with_not_terms() {
let q = MediaQuery::parse("(min-width: 200), not (color)").unwrap();
assert_eq!(q.alternatives.len(), 2);
assert_eq!(q.alternatives[1], alt_neg(MediaCondition::Color));
assert!(q.matches(&no_color_ctx()));
assert!(!q.matches(&ctx(100, 24)));
assert!(q.matches(&ctx(200, 24)));
}
#[test]
fn display_roundtrip_negated_term() {
let q = MediaQuery::parse("not (min-width: 80) and (color)").unwrap();
assert_eq!(q.to_string(), "not (min-width: 80) and (color)");
let q2 = MediaQuery::parse(&q.to_string()).unwrap();
assert_eq!(q, q2);
}
#[test]
fn not_at_end_of_alternative_errors() {
assert!(MediaQuery::parse("(min-width: 80) and not").is_err());
}
}