Files
millionco__react-doctor/native/oxlint/rules/no-img-without-dimensions.rs
2026-08-30 12:43:10 -07:00

2153 lines
76 KiB
Rust

use std::{
collections::{HashMap, HashSet},
fs,
path::Path,
};
use oxc_ast::{
AstKind,
ast::{
Expression, JSXAttribute, JSXAttributeItem, JSXAttributeValue, JSXChild, JSXExpression,
JSXOpeningElement, ObjectPropertyKind,
},
};
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_semantic::NodeId;
use crate::{
context::{ContextHost, LintContext},
rule::Rule,
};
const MESSAGE: &str = "This image reserves no dimensions or aspect ratio before loading, so surrounding content can shift. Add width and height or an explicit aspect-ratio box.";
#[derive(Debug, Default, Clone)]
pub struct NoImgWithoutDimensions;
declare_oxc_lint!(
/// Require images to reserve layout space before loading.
NoImgWithoutDimensions,
react_doctor_native,
perf,
version = "0.1.0",
short_description = "Require images to reserve layout space before loading.",
);
impl Rule for NoImgWithoutDimensions {
fn should_run(&self, ctx: &ContextHost) -> bool {
ctx.source_type().is_jsx() && !is_generated_image_render_filename(ctx)
}
fn run_once<'a>(&self, ctx: &LintContext<'a>) {
let generated_image_ids = generated_image_jsx_opening_element_ids(ctx);
let has_tailwind = has_capability_or_unspecified(ctx, "tailwind");
let mut external_css = None;
for node in ctx.nodes().iter() {
let AstKind::JSXOpeningElement(opening_element) = node.kind() else {
continue;
};
if generated_image_ids.contains(&node.id())
|| resolve_jsx_element_type(opening_element, ctx).map(|(name, _)| name)
!= Some("img")
|| has_jsx_spread(opening_element)
|| is_statically_non_rendered(opening_element, has_tailwind)
{
continue;
}
let class_name = get_static_class_name(opening_element);
if !has_tailwind && class_name.is_some() {
continue;
}
if has_reserved_image_box(opening_element, has_tailwind, ctx)
|| has_reserved_parent_box(node.id(), has_tailwind, ctx)
{
continue;
}
let external_css = external_css.get_or_insert_with(|| StaticCssResolver::new(ctx));
if has_reserved_external_css_box(node.id(), opening_element, external_css, ctx)
|| has_unresolved_box(opening_element, has_tailwind, ctx)
|| has_unresolved_parent_box(node.id(), has_tailwind, ctx)
{
continue;
}
ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(opening_element.span));
}
}
}
#[derive(Default)]
struct ReservedBoxEvidence {
has_aspect_ratio: bool,
has_height: bool,
has_width: bool,
}
#[derive(Default, Clone)]
struct ExternalCssBoxEvidence {
has_aspect_ratio: bool,
has_height: bool,
has_width: bool,
height_reserves_with_parent: bool,
width_reserves_with_parent: bool,
}
#[derive(Default, Clone, Copy)]
struct ParentBoxAxes {
has_height: bool,
has_width: bool,
}
fn has_reserved_image_box(
opening_element: &JSXOpeningElement<'_>,
has_tailwind: bool,
ctx: &LintContext<'_>,
) -> bool {
let mut evidence = ReservedBoxEvidence::default();
if let Some(width) = get_authoritative_jsx_attribute(opening_element, "width", false) {
evidence.has_width = dimension_attribute_reserves_space(width, ctx);
}
if let Some(height) = get_authoritative_jsx_attribute(opening_element, "height", false) {
evidence.has_height = dimension_attribute_reserves_space(height, ctx);
}
let mut inline_evidence = ReservedBoxEvidence::default();
merge_inline_box_evidence(opening_element, &mut inline_evidence, ctx);
if !has_tailwind {
evidence.has_aspect_ratio |= inline_evidence.has_aspect_ratio;
evidence.has_height |= inline_evidence.has_height;
evidence.has_width |= inline_evidence.has_width;
} else if let Some(class_name) = get_static_class_name(opening_element) {
let tokens = tailwind_class_name_tokens(class_name);
let aspect_ratio =
resolve_effective_box_utility(&tokens, |utility| utility.starts_with("aspect-"));
let width = resolve_effective_box_utility(&tokens, |utility| {
utility.starts_with("size-") || utility.starts_with("w-")
});
let height = resolve_effective_box_utility(&tokens, |utility| {
utility.starts_with("size-") || utility.starts_with("h-")
});
evidence.has_aspect_ratio |= aspect_ratio
.utility
.is_some_and(tailwind_aspect_ratio_reserves_space)
|| !aspect_ratio.is_important && inline_evidence.has_aspect_ratio;
evidence.has_width |= width
.utility
.is_some_and(|utility| tailwind_dimension_reserves_space(utility, false))
|| !width.is_important && inline_evidence.has_width;
evidence.has_height |= height
.utility
.is_some_and(|utility| tailwind_dimension_reserves_space(utility, true))
|| !height.is_important && inline_evidence.has_height;
} else {
evidence.has_aspect_ratio |= inline_evidence.has_aspect_ratio;
evidence.has_height |= inline_evidence.has_height;
evidence.has_width |= inline_evidence.has_width;
}
evidence_reserves_box(&evidence, false)
}
#[derive(Default)]
struct EffectiveBoxUtility<'a> {
utility: Option<&'a str>,
is_important: bool,
is_ambiguous: bool,
}
fn resolve_effective_box_utility<'a>(
tokens: &'a [TailwindClassNameToken<'a>],
predicate: impl Fn(&str) -> bool,
) -> EffectiveBoxUtility<'a> {
let applicable = tokens
.iter()
.filter(|token| token.variants.is_empty() && predicate(token.utility))
.collect::<Vec<_>>();
let is_important = applicable.iter().any(|token| token.is_important);
let mut utility = None;
let mut is_ambiguous = false;
for token in applicable {
if is_important && !token.is_important {
continue;
}
if utility.is_some_and(|previous| previous != token.utility) {
is_ambiguous = true;
utility = None;
break;
}
utility = Some(token.utility);
}
EffectiveBoxUtility {
utility,
is_important,
is_ambiguous,
}
}
fn dimension_attribute_reserves_space(attribute: &JSXAttribute<'_>, ctx: &LintContext<'_>) -> bool {
match attribute.value.as_ref() {
Some(JSXAttributeValue::StringLiteral(literal)) => {
let value = literal.value.trim();
!value.is_empty()
&& value.chars().all(|character| character.is_ascii_digit())
&& value.parse::<f64>().is_ok_and(|value| value > 0.0)
}
Some(JSXAttributeValue::ExpressionContainer(container)) => match &container.expression {
JSXExpression::NumericLiteral(literal) => {
literal.value.is_finite() && literal.value > 0.0
}
JSXExpression::StringLiteral(literal) => {
let value = literal.value.trim();
!value.is_empty()
&& value.chars().all(|character| character.is_ascii_digit())
&& value.parse::<f64>().is_ok_and(|value| value > 0.0)
}
JSXExpression::BooleanLiteral(_)
| JSXExpression::NullLiteral(_)
| JSXExpression::EmptyExpression(_) => false,
JSXExpression::Identifier(identifier)
if matches!(identifier.name.as_str(), "Infinity" | "NaN" | "undefined")
&& ctx.is_reference_to_global_variable(identifier) =>
{
false
}
JSXExpression::UnaryExpression(unary) if unary.operator.is_void() => false,
JSXExpression::UnaryExpression(unary) => {
if let Some(value) = static_positive_number(&unary.argument) {
return match unary.operator {
oxc_syntax::operator::UnaryOperator::UnaryPlus => value > 0.0,
oxc_syntax::operator::UnaryOperator::UnaryNegation => -value > 0.0,
_ => true,
};
}
!matches!(
unary.operator,
oxc_syntax::operator::UnaryOperator::UnaryPlus
| oxc_syntax::operator::UnaryOperator::UnaryNegation
) || !matches!(unary.argument.get_inner_expression(), Expression::Identifier(identifier) if matches!(identifier.name.as_str(), "Infinity" | "NaN" | "undefined") && ctx.is_reference_to_global_variable(identifier))
}
_ => true,
},
_ => false,
}
}
fn static_positive_number(expression: &Expression<'_>) -> Option<f64> {
match expression.get_inner_expression() {
Expression::NumericLiteral(number) if number.value.is_finite() => Some(number.value),
_ => None,
}
}
fn merge_inline_box_evidence(
opening_element: &JSXOpeningElement<'_>,
evidence: &mut ReservedBoxEvidence,
ctx: &LintContext<'_>,
) {
let Some(style_attribute) = get_authoritative_jsx_attribute(opening_element, "style", false)
else {
return;
};
let Some(style) = get_inline_style_object_expression(style_attribute) else {
return;
};
evidence.has_width |= get_effective_static_style_property(style, "width")
.is_some_and(|property| style_property_reserves_space(&property.value, false, false, ctx));
evidence.has_height |= get_effective_static_style_property(style, "height")
.is_some_and(|property| style_property_reserves_space(&property.value, false, true, ctx));
evidence.has_aspect_ratio |= get_effective_static_style_property(style, "aspectRatio")
.is_some_and(|property| style_property_reserves_space(&property.value, true, false, ctx));
}
fn style_property_reserves_space(
expression: &Expression<'_>,
is_aspect_ratio: bool,
is_height: bool,
ctx: &LintContext<'_>,
) -> bool {
match expression.get_inner_expression() {
Expression::NumericLiteral(number) => number.value.is_finite() && number.value > 0.0,
Expression::StringLiteral(literal) => {
let value = literal.value.trim().to_ascii_lowercase();
if value.is_empty() || matches!(value.as_str(), "auto" | "none") {
return false;
}
if value.starts_with("calc(")
|| value.starts_with("clamp(")
|| value.starts_with("max(")
|| value.starts_with("min(")
|| value.starts_with("var(")
{
return true;
}
if is_aspect_ratio {
let value = value.strip_prefix("auto ").unwrap_or(&value);
let parts = value.split('/').map(str::trim).collect::<Vec<_>>();
return parts.len() <= 2
&& parts.iter().all(|part| {
part.parse::<f64>()
.is_ok_and(|number| number.is_finite() && number > 0.0)
});
}
if is_height && value.ends_with('%') {
return false;
}
css_length_is_positive(&value)
}
Expression::Identifier(identifier)
if matches!(identifier.name.as_str(), "Infinity" | "NaN" | "undefined")
&& ctx.is_reference_to_global_variable(identifier) =>
{
false
}
Expression::UnaryExpression(unary) if unary.operator.is_void() => false,
Expression::UnaryExpression(unary) => {
if let Some(value) = static_positive_number(&unary.argument) {
return match unary.operator {
oxc_syntax::operator::UnaryOperator::UnaryPlus => value > 0.0,
oxc_syntax::operator::UnaryOperator::UnaryNegation => -value > 0.0,
_ => true,
};
}
!matches!(
unary.operator,
oxc_syntax::operator::UnaryOperator::UnaryPlus
| oxc_syntax::operator::UnaryOperator::UnaryNegation
) || !matches!(unary.argument.get_inner_expression(), Expression::Identifier(identifier) if matches!(identifier.name.as_str(), "Infinity" | "NaN" | "undefined") && ctx.is_reference_to_global_variable(identifier))
}
Expression::BooleanLiteral(_) | Expression::NullLiteral(_) => false,
_ => true,
}
}
fn css_length_is_positive(value: &str) -> bool {
let number_end = value
.char_indices()
.take_while(|(_, character)| character.is_ascii_digit() || *character == '.')
.last()
.map_or(0, |(index, character)| index + character.len_utf8());
const CSS_LENGTH_UNITS: [&str; 49] = [
"cap", "ch", "cm", "cqb", "cqh", "cqi", "cqmax", "cqmin", "cqw", "dvb", "dvh", "dvi",
"dvmax", "dvmin", "dvw", "em", "ex", "ic", "in", "lh", "lvb", "lvh", "lvi", "lvmax",
"lvmin", "lvw", "mm", "pc", "pt", "px", "q", "rcap", "rch", "rem", "rex", "ric", "rlh",
"svb", "svh", "svi", "svmax", "svmin", "svw", "vb", "vh", "vi", "vmax", "vmin", "vw",
];
number_end > 0
&& value[..number_end]
.parse::<f64>()
.is_ok_and(|number| number.is_finite() && number > 0.0)
&& (&value[number_end..] == "%" || CSS_LENGTH_UNITS.contains(&&value[number_end..]))
}
fn merge_tailwind_box_evidence(
opening_element: &JSXOpeningElement<'_>,
evidence: &mut ReservedBoxEvidence,
) {
let Some(class_name) = get_static_class_name(opening_element) else {
return;
};
let tokens = tailwind_class_name_tokens(class_name);
evidence.has_width |= get_effective_tailwind_class_name_token(&tokens, |utility| {
utility.starts_with("size-") || utility.starts_with("w-")
})
.is_some_and(|utility| tailwind_dimension_reserves_space(utility, false));
evidence.has_height |= get_effective_tailwind_class_name_token(&tokens, |utility| {
utility.starts_with("size-") || utility.starts_with("h-")
})
.is_some_and(|utility| tailwind_dimension_reserves_space(utility, true));
evidence.has_aspect_ratio |=
get_effective_tailwind_class_name_token(&tokens, |utility| utility.starts_with("aspect-"))
.is_some_and(tailwind_aspect_ratio_reserves_space);
}
fn tailwind_dimension_reserves_space(utility: &str, is_height: bool) -> bool {
let value = utility
.split_once('-')
.map(|(_, value)| value)
.unwrap_or("");
if value.is_empty()
|| matches!(value, "auto" | "fit" | "min" | "max" | "0")
|| value.contains("[auto]")
|| value.contains("[fit-content]")
|| value.contains("[min-content]")
|| value.contains("[max-content]")
{
return false;
}
!(is_height
&& (value == "full"
|| value.contains('/')
|| value.starts_with('[') && value.ends_with("%]")))
}
fn tailwind_aspect_ratio_reserves_space(utility: &str) -> bool {
let value = utility.strip_prefix("aspect-").unwrap_or("");
if matches!(value, "" | "auto" | "[auto]") {
return false;
}
let value = value.trim_matches(['[', ']']);
if matches!(value, "square" | "video")
|| value.starts_with("var(")
|| value.starts_with("calc(")
{
return true;
}
let parts = value.split('/').map(str::trim).collect::<Vec<_>>();
if parts.len() == 2 {
return parts.iter().all(|part| {
part.parse::<f64>()
.is_ok_and(|number| number.is_finite() && number > 0.0)
});
}
!value.starts_with(|character: char| character.is_ascii_digit())
}
fn evidence_reserves_box(evidence: &ReservedBoxEvidence, has_implicit_width: bool) -> bool {
let has_width = evidence.has_width || has_implicit_width;
has_width && evidence.has_height
|| evidence.has_aspect_ratio && (has_width || evidence.has_height)
}
fn is_statically_non_rendered(opening_element: &JSXOpeningElement<'_>, has_tailwind: bool) -> bool {
if get_authoritative_jsx_attribute(opening_element, "hidden", false)
.is_some_and(attribute_is_statically_truthy)
{
return true;
}
if has_tailwind
&& get_static_class_name(opening_element).is_some_and(tailwind_hides_at_every_display_scope)
{
return true;
}
get_authoritative_jsx_attribute(opening_element, "style", false)
.and_then(get_inline_style_object_expression)
.and_then(|style| get_effective_static_style_property(style, "display"))
.is_some_and(|property| matches!(&property.value, Expression::StringLiteral(literal) if literal.value.eq_ignore_ascii_case("none")))
}
fn tailwind_hides_at_every_display_scope(class_name: &str) -> bool {
let tokens = tailwind_class_name_tokens(class_name);
let mut target_scopes = vec![Vec::new()];
target_scopes.extend(
tokens
.iter()
.filter(|token| {
!token.variants.is_empty() && is_tailwind_display_utility(token.utility)
})
.map(|token| token.variants.clone()),
);
target_scopes.iter().all(|target_scope| {
effective_tailwind_display_at_scope(&tokens, target_scope) == Some("hidden")
})
}
fn effective_tailwind_display_at_scope<'a>(
tokens: &'a [TailwindClassNameToken<'a>],
target_scope: &[&str],
) -> Option<&'a str> {
let applicable = tokens
.iter()
.filter(|token| {
token.variants == target_scope && is_tailwind_display_utility(token.utility)
})
.collect::<Vec<_>>();
let is_important = applicable.iter().any(|token| token.is_important);
let mut utility = None;
for token in applicable {
if is_important && !token.is_important {
continue;
}
if utility.is_some_and(|previous| previous != token.utility) {
return None;
}
utility = Some(token.utility);
}
utility
}
fn is_tailwind_display_utility(utility: &str) -> bool {
matches!(
utility,
"block"
| "contents"
| "flex"
| "flow-root"
| "grid"
| "hidden"
| "inline"
| "inline-block"
| "inline-flex"
| "inline-grid"
| "inline-table"
| "list-item"
| "table"
| "table-caption"
| "table-cell"
| "table-column"
| "table-column-group"
| "table-footer-group"
| "table-header-group"
| "table-row"
| "table-row-group"
)
}
fn attribute_is_statically_truthy(attribute: &JSXAttribute<'_>) -> bool {
match attribute.value.as_ref() {
None | Some(JSXAttributeValue::StringLiteral(_)) => true,
Some(JSXAttributeValue::ExpressionContainer(container)) => match &container.expression {
JSXExpression::BooleanLiteral(boolean) => boolean.value,
JSXExpression::StringLiteral(_) | JSXExpression::NumericLiteral(_) => true,
_ => false,
},
_ => false,
}
}
fn has_unresolved_box(
opening_element: &JSXOpeningElement<'_>,
has_tailwind: bool,
ctx: &LintContext<'_>,
) -> bool {
if let Some(class_attribute) =
get_authoritative_jsx_attribute(opening_element, "className", false)
{
if let Some(class_name) = get_static_class_name(opening_element) {
if !class_name.trim().is_empty() && has_tailwind {
let tokens = tailwind_class_name_tokens(class_name);
let is_ambiguous = [
resolve_effective_box_utility(&tokens, |utility| {
utility.starts_with("aspect-")
}),
resolve_effective_box_utility(&tokens, |utility| {
utility.starts_with("size-") || utility.starts_with("w-")
}),
resolve_effective_box_utility(&tokens, |utility| {
utility.starts_with("size-") || utility.starts_with("h-")
}),
]
.iter()
.any(|resolution| resolution.is_ambiguous);
if is_ambiguous {
return true;
}
}
} else if let Some(JSXAttributeValue::ExpressionContainer(container)) =
class_attribute.value.as_ref()
{
let Some(expression) = container.expression.as_expression() else {
return false;
};
match expression.get_inner_expression() {
Expression::NullLiteral(_) | Expression::BooleanLiteral(_) => return false,
Expression::UnaryExpression(unary) if unary.operator.is_void() => return false,
Expression::Identifier(identifier)
if identifier.name == "undefined"
&& ctx.is_reference_to_global_variable(identifier) =>
{
return false;
}
_ => return true,
}
} else {
return class_attribute.value.is_some();
}
if has_tailwind
&& get_static_class_name(opening_element).is_some_and(|class_name| {
tailwind_class_name_tokens(class_name)
.iter()
.any(|token| !is_known_image_box_utility(token.utility))
})
{
return true;
}
}
let Some(style_attribute) = get_authoritative_jsx_attribute(opening_element, "style", false)
else {
return false;
};
let Some(style) = get_inline_style_object_expression(style_attribute) else {
return true;
};
style.properties.iter().any(|property| match property {
ObjectPropertyKind::ObjectProperty(property) => property.key.static_name().is_none(),
ObjectPropertyKind::SpreadProperty(_) => true,
})
}
fn is_known_image_box_utility(utility: &str) -> bool {
utility.starts_with("aspect-")
|| utility.starts_with("size-")
|| utility.starts_with("w-")
|| utility.starts_with("h-")
|| is_tailwind_margin_utility(utility)
|| utility.starts_with("border")
|| utility.starts_with("max-h-")
|| utility.starts_with("max-w-")
|| utility.starts_with("object-")
|| utility.starts_with("opacity-")
|| utility.starts_with("rounded")
|| utility.starts_with("shadow")
|| is_tailwind_display_utility(utility)
|| matches!(utility, "collapse" | "grayscale" | "invisible" | "visible")
}
fn is_tailwind_margin_utility(utility: &str) -> bool {
let utility = utility.strip_prefix('-').unwrap_or(utility);
let Some(remainder) = utility.strip_prefix('m') else {
return false;
};
let remainder =
if remainder.chars().next().is_some_and(|character| {
matches!(character, 't' | 'r' | 'b' | 'l' | 'e' | 's' | 'x' | 'y')
}) {
&remainder[1..]
} else {
remainder
};
remainder.starts_with('-') && remainder.len() > 1
}
fn has_reserved_parent_box(node_id: NodeId, has_tailwind: bool, ctx: &LintContext<'_>) -> bool {
let Some(parent) = direct_parent_opening_element(node_id, ctx) else {
return false;
};
if has_jsx_spread(parent) {
return false;
}
let mut has_implicit_width = matches!(
crate::utils::get_jsx_element_name(&parent.name).as_ref(),
"article"
| "aside"
| "blockquote"
| "details"
| "div"
| "dl"
| "fieldset"
| "figure"
| "footer"
| "form"
| "h1"
| "h2"
| "h3"
| "h4"
| "h5"
| "h6"
| "header"
| "hr"
| "li"
| "main"
| "nav"
| "ol"
| "p"
| "pre"
| "section"
| "ul"
);
let style = get_authoritative_jsx_attribute(parent, "style", false)
.and_then(get_inline_style_object_expression);
if let Some(style) = style {
if let Some(display) = get_effective_static_style_property(style, "display")
.and_then(|property| static_style_string(&property.value))
{
has_implicit_width = matches!(
display.to_ascii_lowercase().as_str(),
"block" | "flex" | "flow-root" | "grid" | "list-item"
);
}
let position = get_effective_static_style_property(style, "position")
.and_then(|property| static_style_string(&property.value));
let float = get_effective_static_style_property(style, "float")
.and_then(|property| static_style_string(&property.value));
if position.is_some_and(|value| {
matches!(value.to_ascii_lowercase().as_str(), "absolute" | "fixed")
}) || float.is_some_and(|value| !value.eq_ignore_ascii_case("none"))
{
has_implicit_width = false;
}
}
if has_tailwind && let Some(class_name) = get_static_class_name(parent) {
let tokens = tailwind_class_name_tokens(class_name);
if let Some(display) = get_effective_tailwind_class_name_token(&tokens, |utility| {
is_tailwind_display_utility(utility)
}) {
has_implicit_width = matches!(
display,
"block" | "flex" | "flow-root" | "grid" | "list-item"
);
}
let position = get_effective_tailwind_class_name_token(&tokens, |utility| {
matches!(
utility,
"static" | "fixed" | "absolute" | "relative" | "sticky"
)
});
let float = get_effective_tailwind_class_name_token(&tokens, |utility| {
matches!(
utility,
"float-start" | "float-end" | "float-right" | "float-left" | "float-none"
)
});
if matches!(position, Some("absolute" | "fixed"))
|| float.is_some_and(|utility| utility != "float-none")
{
has_implicit_width = false;
}
}
let mut evidence = ReservedBoxEvidence::default();
merge_inline_box_evidence(parent, &mut evidence, ctx);
if has_tailwind {
merge_tailwind_box_evidence(parent, &mut evidence);
}
evidence_reserves_box(&evidence, has_implicit_width)
}
fn static_style_string<'a>(expression: &'a Expression<'_>) -> Option<&'a str> {
match expression.get_inner_expression() {
Expression::StringLiteral(literal) => Some(literal.value.as_str()),
_ => None,
}
}
fn direct_parent_opening_element<'a>(
node_id: NodeId,
ctx: &LintContext<'a>,
) -> Option<&'a JSXOpeningElement<'a>> {
let image_element = ctx.nodes().parent_node(node_id);
if !matches!(image_element.kind(), AstKind::JSXElement(_)) {
return None;
}
let parent = ctx.nodes().parent_node(image_element.id());
let AstKind::JSXElement(parent_element) = parent.kind() else {
return None;
};
Some(&parent_element.opening_element)
}
fn element_has_implicit_width<'a>(
opening_element: &JSXOpeningElement<'a>,
ctx: &LintContext<'a>,
) -> bool {
resolve_jsx_element_type(opening_element, ctx).is_some_and(|(name, _)| {
matches!(
name,
"article"
| "aside"
| "blockquote"
| "details"
| "div"
| "dl"
| "fieldset"
| "figure"
| "footer"
| "form"
| "h1"
| "h2"
| "h3"
| "h4"
| "h5"
| "h6"
| "header"
| "hr"
| "li"
| "main"
| "nav"
| "ol"
| "p"
| "pre"
| "section"
| "ul"
)
})
}
fn has_unresolved_parent_box<'a>(
node_id: NodeId,
has_tailwind: bool,
ctx: &LintContext<'a>,
) -> bool {
direct_parent_opening_element(node_id, ctx).is_some_and(|parent| {
(!has_tailwind && get_static_class_name(parent).is_some())
|| has_jsx_spread(parent)
|| has_unresolved_box(parent, has_tailwind, ctx)
})
}
fn has_jsx_spread(opening_element: &JSXOpeningElement<'_>) -> bool {
opening_element
.attributes
.iter()
.any(|attribute| matches!(attribute, JSXAttributeItem::SpreadAttribute(_)))
}
fn external_css_values<'a>(style: &'a StaticCssStyle, property: &str) -> Option<&'a str> {
(!style.ambiguous_properties.contains(property))
.then(|| style.values_by_property.get(property))
.flatten()
.map(String::as_str)
}
fn external_css_length_reserves_without_parent(value: &str, is_height: bool) -> bool {
if value.is_empty() || matches!(value, "auto" | "none") {
return false;
}
if ["calc(", "clamp(", "max(", "min("]
.iter()
.any(|prefix| value.starts_with(prefix))
{
return !value.contains('%') && !css_value_contains_word(value, "auto");
}
css_length_is_positive(value) && (!is_height || !value.ends_with('%'))
}
fn external_css_length_reserves_with_parent(value: &str, is_height: bool) -> bool {
if external_css_length_reserves_without_parent(value, is_height) {
return true;
}
if ["calc(", "clamp(", "max(", "min("]
.iter()
.any(|prefix| value.starts_with(prefix))
{
return !css_value_contains_word(value, "auto") && !css_value_contains_word(value, "var");
}
css_length_is_positive(value) && value.ends_with('%')
}
fn css_value_contains_word(value: &str, word: &str) -> bool {
value.match_indices(word).any(|(index, _)| {
let before = value[..index].chars().next_back();
let after = value[index + word.len()..].chars().next();
before.is_none_or(|character| !character.is_ascii_alphanumeric() && character != '_')
&& after.is_none_or(|character| !character.is_ascii_alphanumeric() && character != '_')
})
}
fn external_css_aspect_ratio_reserves_space(value: &str) -> bool {
let value = value.strip_prefix("auto ").unwrap_or(value);
let parts = value.split('/').map(str::trim).collect::<Vec<_>>();
parts.len() == 2
&& parts.iter().all(|part| {
part.parse::<f64>()
.is_ok_and(|number| number.is_finite() && number > 0.0)
})
}
fn get_external_css_box_evidence(style: &StaticCssStyle) -> ExternalCssBoxEvidence {
let width = external_css_values(style, "width");
let height = external_css_values(style, "height");
ExternalCssBoxEvidence {
has_aspect_ratio: external_css_values(style, "aspect-ratio")
.is_some_and(external_css_aspect_ratio_reserves_space),
has_height: height
.is_some_and(|value| external_css_length_reserves_without_parent(value, true)),
has_width: width
.is_some_and(|value| external_css_length_reserves_without_parent(value, false)),
height_reserves_with_parent: height
.is_some_and(|value| external_css_length_reserves_with_parent(value, true)),
width_reserves_with_parent: width
.is_some_and(|value| external_css_length_reserves_with_parent(value, false)),
}
}
fn get_external_css_box_evidence_with_inline_overrides(
node_id: NodeId,
opening_element: &JSXOpeningElement<'_>,
resolver: &StaticCssResolver,
ctx: &LintContext<'_>,
) -> Option<ExternalCssBoxEvidence> {
let mut evidence = get_external_css_box_evidence(&resolver.resolve(node_id, ctx));
let Some(style_attribute) = get_authoritative_jsx_attribute(opening_element, "style", false)
else {
return Some(evidence);
};
let style = get_inline_style_object_expression(style_attribute)?;
let mut inline_evidence = ReservedBoxEvidence::default();
merge_inline_box_evidence(opening_element, &mut inline_evidence, ctx);
if get_effective_static_style_property(style, "aspectRatio").is_some() {
evidence.has_aspect_ratio = inline_evidence.has_aspect_ratio;
}
if get_effective_static_style_property(style, "height").is_some() {
evidence.has_height = inline_evidence.has_height;
evidence.height_reserves_with_parent = false;
}
if get_effective_static_style_property(style, "width").is_some() {
evidence.has_width = inline_evidence.has_width;
evidence.width_reserves_with_parent = false;
}
Some(evidence)
}
fn inline_style_overrides_any(
opening_element: &JSXOpeningElement<'_>,
property_names: &[&str],
) -> bool {
get_authoritative_jsx_attribute(opening_element, "style", false)
.and_then(get_inline_style_object_expression)
.is_some_and(|style| {
property_names.iter().any(|property_name| {
get_effective_static_style_property(style, property_name).is_some()
})
})
}
fn external_css_has_positive_flex_grow(style: &StaticCssStyle) -> bool {
let mut values = [
external_css_values(style, "flex-grow"),
external_css_values(style, "flex"),
]
.into_iter()
.flatten();
let Some(first) = values.next() else {
return false;
};
std::iter::once(first).chain(values).all(|value| {
value
.split_ascii_whitespace()
.next()
.and_then(|number| number.parse::<f64>().ok())
.is_some_and(|number| number.is_finite() && number > 0.0)
})
}
fn get_external_css_element_box_axes<'a>(
node_id: NodeId,
opening_element: &JSXOpeningElement<'a>,
resolver: &StaticCssResolver,
ctx: &LintContext<'a>,
) -> ParentBoxAxes {
let style = resolver.resolve(node_id, ctx);
let Some(evidence) = get_external_css_box_evidence_with_inline_overrides(
node_id,
opening_element,
resolver,
ctx,
) else {
return ParentBoxAxes::default();
};
let parent_id = parent_opening_node_id(node_id, ctx);
let parent_axes = parent_id.map_or(
ParentBoxAxes {
has_height: resolver.has_definite_react_root_height,
has_width: true,
},
|parent_id| {
let AstKind::JSXOpeningElement(parent) = ctx.nodes().get_node(parent_id).kind() else {
return ParentBoxAxes::default();
};
get_external_css_element_box_axes(parent_id, parent, resolver, ctx)
},
);
let mut has_width = evidence.has_width
|| evidence.width_reserves_with_parent && parent_axes.has_width
|| element_has_implicit_width(opening_element, ctx);
let mut has_height =
evidence.has_height || evidence.height_reserves_with_parent && parent_axes.has_height;
let Some(parent_id) = parent_id else {
return ParentBoxAxes {
has_height,
has_width,
};
};
let AstKind::JSXOpeningElement(parent) = ctx.nodes().get_node(parent_id).kind() else {
return ParentBoxAxes {
has_height,
has_width,
};
};
if inline_style_overrides_any(opening_element, &["alignSelf", "flex", "flexGrow"])
|| inline_style_overrides_any(
parent,
&["alignItems", "display", "flexDirection", "flexWrap"],
)
{
return ParentBoxAxes {
has_height,
has_width,
};
}
let parent_style = resolver.resolve(parent_id, ctx);
let display = external_css_values(&parent_style, "display");
let flex_direction = external_css_values(&parent_style, "flex-direction").unwrap_or("row");
let flex_wrap = external_css_values(&parent_style, "flex-wrap").unwrap_or("nowrap");
let align_items = external_css_values(&parent_style, "align-items").unwrap_or("stretch");
let align_self = external_css_values(&style, "align-self").unwrap_or("auto");
let is_flex_container = matches!(display, Some("flex" | "inline-flex"));
let is_row_direction = matches!(flex_direction, "row" | "row-reverse");
let is_column_direction = matches!(flex_direction, "column" | "column-reverse");
if is_flex_container && (is_row_direction || is_column_direction) && flex_wrap == "nowrap" {
if external_css_has_positive_flex_grow(&style) {
if is_row_direction && parent_axes.has_width {
has_width = true;
}
if is_column_direction && parent_axes.has_height {
has_height = true;
}
}
if align_items == "stretch" && matches!(align_self, "auto" | "stretch") {
if is_row_direction && parent_axes.has_height {
has_height = true;
}
if is_column_direction && parent_axes.has_width {
has_width = true;
}
}
}
if evidence.has_aspect_ratio {
has_height |= has_width;
has_width |= has_height;
}
ParentBoxAxes {
has_height,
has_width,
}
}
fn get_external_css_parent_box_axes(
node_id: NodeId,
resolver: &StaticCssResolver,
ctx: &LintContext<'_>,
) -> ParentBoxAxes {
let Some(parent_id) = parent_opening_node_id(node_id, ctx) else {
return ParentBoxAxes::default();
};
let AstKind::JSXOpeningElement(parent) = ctx.nodes().get_node(parent_id).kind() else {
return ParentBoxAxes::default();
};
get_external_css_element_box_axes(parent_id, parent, resolver, ctx)
}
fn has_reserved_external_css_box(
node_id: NodeId,
opening_element: &JSXOpeningElement<'_>,
resolver: &StaticCssResolver,
ctx: &LintContext<'_>,
) -> bool {
let mut class_evidence = ReservedBoxEvidence::default();
let mut has_aspect_ratio_utility = false;
let mut has_height_utility = false;
let mut has_width_utility = false;
if let Some(class_attribute) =
get_authoritative_jsx_attribute(opening_element, "className", false)
{
let class_name = get_static_class_name(opening_element);
if class_name.is_none() && class_attribute.value.is_some() {
return false;
}
let Some(class_name) = class_name else {
return false;
};
merge_tailwind_box_evidence(opening_element, &mut class_evidence);
for token in tailwind_class_name_tokens(class_name) {
has_aspect_ratio_utility |= token.utility.starts_with("aspect-");
has_height_utility |=
token.utility.starts_with("size-") || token.utility.starts_with("h-");
has_width_utility |=
token.utility.starts_with("size-") || token.utility.starts_with("w-");
}
}
let Some(evidence) = get_external_css_box_evidence_with_inline_overrides(
node_id,
opening_element,
resolver,
ctx,
) else {
return false;
};
let combined = ExternalCssBoxEvidence {
has_aspect_ratio: class_evidence.has_aspect_ratio
|| !has_aspect_ratio_utility && evidence.has_aspect_ratio,
has_height: class_evidence.has_height || !has_height_utility && evidence.has_height,
has_width: class_evidence.has_width || !has_width_utility && evidence.has_width,
height_reserves_with_parent: !has_height_utility && evidence.height_reserves_with_parent,
width_reserves_with_parent: !has_width_utility && evidence.width_reserves_with_parent,
};
if combined.has_width && combined.has_height
|| combined.has_aspect_ratio && (combined.has_width || combined.has_height)
{
return true;
}
let parent_axes = get_external_css_parent_box_axes(node_id, resolver, ctx);
let has_width =
combined.has_width || combined.width_reserves_with_parent && parent_axes.has_width;
let has_height =
combined.has_height || combined.height_reserves_with_parent && parent_axes.has_height;
has_width && has_height || combined.has_aspect_ratio && (has_width || has_height)
}
const TRACKED_CSS_PROPERTIES: [&str; 10] = [
"align-items",
"align-self",
"aspect-ratio",
"display",
"flex",
"flex-direction",
"flex-grow",
"flex-wrap",
"height",
"width",
];
#[derive(Clone)]
struct StaticCssDeclaration {
property: String,
value: String,
is_important: bool,
declaration_order: usize,
}
#[derive(Clone)]
struct StaticCssRule {
selectors: Vec<String>,
declarations: Vec<StaticCssDeclaration>,
cascade_layer_key: Option<String>,
is_conditional: bool,
source_order: usize,
}
#[derive(Default)]
struct StaticCssResolver {
rules: Vec<StaticCssRule>,
has_definite_react_root_height: bool,
}
#[derive(Default)]
struct StaticCssStyle {
ambiguous_properties: HashSet<String>,
values_by_property: HashMap<String, String>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum StaticSelectorMatch {
Ambiguous,
Match,
NoMatch,
}
#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
struct CssSpecificity(usize, usize, usize);
#[derive(Clone)]
struct MatchedCssDeclaration {
declaration: StaticCssDeclaration,
cascade_layer_key: Option<String>,
source_order: usize,
specificity: CssSpecificity,
}
impl StaticCssResolver {
fn new(ctx: &LintContext<'_>) -> Self {
let file_path = ctx.file_path();
if !file_path.is_absolute() {
return Self::default();
}
let Ok(metadata) = fs::metadata(file_path) else {
return Self::default();
};
if metadata.len() > 2_000_000 {
return Self::default();
}
let Ok(source) = fs::read_to_string(file_path) else {
return Self::default();
};
let directory = file_path.parent().unwrap_or_else(|| Path::new(""));
let mut stylesheet_paths = Vec::new();
for statement in source.split(['\n', ';']) {
let trimmed = statement.trim_start();
if !trimmed.starts_with("import ") {
continue;
}
let Some(import_source) = quoted_import_source(trimmed) else {
continue;
};
let import_path = import_source
.split(['?', '#'])
.next()
.unwrap_or(import_source);
if !import_path.starts_with('.') || !import_path.ends_with(".css") {
continue;
}
let stylesheet_path = directory.join(import_path);
if stylesheet_path.parent() == Some(directory)
&& !stylesheet_paths.contains(&stylesheet_path)
{
stylesheet_paths.push(stylesheet_path);
}
}
let mut rules = Vec::new();
let mut source_order = 0;
let mut anonymous_layer_count = 0;
for stylesheet_path in stylesheet_paths {
let Ok(metadata) = fs::metadata(&stylesheet_path) else {
continue;
};
if metadata.len() > 2_000_000 {
continue;
}
let Ok(stylesheet) = fs::read_to_string(stylesheet_path) else {
continue;
};
parse_css_rule_list(
&strip_css_comments(&stylesheet),
None,
None,
false,
&mut rules,
&mut source_order,
&mut anonymous_layer_count,
);
}
let mut resolver = Self {
rules,
has_definite_react_root_height: false,
};
resolver.has_definite_react_root_height = ["html", "body", "root"].iter().all(|target| {
let style = resolver.resolve_root(target);
!style.ambiguous_properties.contains("height")
&& style.values_by_property.get("height").map(String::as_str) == Some("100%")
});
resolver
}
fn resolve(&self, node_id: NodeId, ctx: &LintContext<'_>) -> StaticCssStyle {
self.resolve_with_match(|selector| selector_matches(selector, node_id, ctx))
}
fn resolve_root(&self, target: &str) -> StaticCssStyle {
self.resolve_with_match(|selector| selector_matches_root_target(selector, target))
}
fn resolve_with_match(
&self,
mut selector_match: impl FnMut(&str) -> StaticSelectorMatch,
) -> StaticCssStyle {
let mut style = StaticCssStyle::default();
let mut candidates: HashMap<String, Vec<MatchedCssDeclaration>> = HashMap::new();
for rule in &self.rules {
let mut matching_specificity: Option<CssSpecificity> = None;
let mut ambiguous_specificity: Option<CssSpecificity> = None;
for selector in &rule.selectors {
let specificity = css_selector_specificity(selector);
match selector_match(selector) {
StaticSelectorMatch::Match => {
matching_specificity = Some(
matching_specificity
.map_or(specificity, |current| current.max(specificity)),
);
}
StaticSelectorMatch::Ambiguous => {
ambiguous_specificity = Some(
ambiguous_specificity
.map_or(specificity, |current| current.max(specificity)),
);
}
StaticSelectorMatch::NoMatch => {}
}
}
if rule.is_conditional {
if matching_specificity.is_some() || ambiguous_specificity.is_some() {
for declaration in &rule.declarations {
style
.ambiguous_properties
.insert(declaration.property.clone());
}
}
continue;
}
if ambiguous_specificity.is_some_and(|ambiguous| {
matching_specificity.is_none_or(|matching| ambiguous > matching)
}) {
for declaration in &rule.declarations {
style
.ambiguous_properties
.insert(declaration.property.clone());
}
}
let Some(specificity) = matching_specificity else {
continue;
};
for declaration in &rule.declarations {
candidates
.entry(declaration.property.clone())
.or_default()
.push(MatchedCssDeclaration {
declaration: declaration.clone(),
cascade_layer_key: rule.cascade_layer_key.clone(),
source_order: rule.source_order,
specificity,
});
}
}
for (property, candidates) in candidates {
match resolve_css_cascade(&candidates) {
Some(Ok(value)) => {
style.values_by_property.insert(property, value);
}
Some(Err(())) => {
style.ambiguous_properties.insert(property);
}
None => {}
}
}
style
}
}
fn quoted_import_source(source: &str) -> Option<&str> {
let quote_index = source.find(['\'', '"'])?;
let quote = source.as_bytes()[quote_index];
let remainder = &source[quote_index + 1..];
let end = remainder.bytes().position(|byte| byte == quote)?;
Some(&remainder[..end])
}
fn strip_css_comments(source: &str) -> String {
let mut output = String::with_capacity(source.len());
let mut remainder = source;
while let Some(start) = remainder.find("/*") {
output.push_str(&remainder[..start]);
let Some(end) = remainder[start + 2..].find("*/") else {
break;
};
remainder = &remainder[start + end + 4..];
}
output.push_str(remainder);
output
}
fn parse_css_rule_list(
source: &str,
parent_selectors: Option<&[String]>,
cascade_layer_key: Option<&str>,
is_conditional: bool,
rules: &mut Vec<StaticCssRule>,
source_order: &mut usize,
anonymous_layer_count: &mut usize,
) {
let mut cursor = 0;
while let Some(open_offset) = find_top_level_character(&source[cursor..], '{') {
let open = cursor + open_offset;
let header = source[cursor..open].trim().trim_matches(';').trim();
let Some(close) = find_matching_css_delimiter(source, open, b'{', b'}') else {
break;
};
let body = &source[open + 1..close];
if let Some(layer_name) = header.strip_prefix("@layer") {
let layer_name = layer_name.trim();
let layer_key = if layer_name.is_empty() {
let key = format!("anonymous-{}", *anonymous_layer_count);
*anonymous_layer_count += 1;
key
} else if let Some(parent_layer) = cascade_layer_key {
format!("{parent_layer}.{layer_name}")
} else {
layer_name.to_string()
};
parse_css_rule_list(
body,
parent_selectors,
Some(&layer_key),
is_conditional,
rules,
source_order,
anonymous_layer_count,
);
} else if header.starts_with('@') {
let conditional = matches!(
header
.split_ascii_whitespace()
.next()
.unwrap_or("")
.trim_start_matches('@'),
"container" | "media" | "-moz-document" | "scope" | "starting-style" | "supports"
);
if conditional {
parse_css_rule_list(
body,
parent_selectors,
cascade_layer_key,
true,
rules,
source_order,
anonymous_layer_count,
);
}
} else if !header.is_empty() {
let selectors = combine_css_selectors(parent_selectors, &split_css_list(header));
parse_css_style_body(
body,
&selectors,
cascade_layer_key,
is_conditional,
rules,
source_order,
anonymous_layer_count,
);
}
cursor = close + 1;
}
}
fn parse_css_style_body(
source: &str,
selectors: &[String],
cascade_layer_key: Option<&str>,
is_conditional: bool,
rules: &mut Vec<StaticCssRule>,
source_order: &mut usize,
anonymous_layer_count: &mut usize,
) {
let mut declarations_source = String::new();
let mut cursor = 0;
while let Some(open_offset) = find_top_level_character(&source[cursor..], '{') {
let open = cursor + open_offset;
let segment = &source[cursor..open];
let header_start = segment.rfind(';').map_or(0, |index| index + 1);
declarations_source.push_str(&segment[..header_start]);
let header = segment[header_start..].trim();
let Some(close) = find_matching_css_delimiter(source, open, b'{', b'}') else {
break;
};
let body = &source[open + 1..close];
if header.starts_with('@') {
parse_css_rule_list(
&format!("{header}{{{body}}}"),
Some(selectors),
cascade_layer_key,
is_conditional,
rules,
source_order,
anonymous_layer_count,
);
} else {
let nested_selectors = combine_css_selectors(Some(selectors), &split_css_list(header));
parse_css_style_body(
body,
&nested_selectors,
cascade_layer_key,
is_conditional,
rules,
source_order,
anonymous_layer_count,
);
}
cursor = close + 1;
}
declarations_source.push_str(&source[cursor..]);
let declarations = parse_css_declarations(&declarations_source);
if !declarations.is_empty() {
rules.push(StaticCssRule {
selectors: selectors.to_vec(),
declarations,
cascade_layer_key: cascade_layer_key.map(str::to_string),
is_conditional,
source_order: *source_order,
});
}
*source_order += 1;
}
fn find_top_level_character(source: &str, target: char) -> Option<usize> {
let mut square_depth = 0_u32;
let mut parenthesis_depth = 0_u32;
let mut quote = None;
let mut escaped = false;
for (index, character) in source.char_indices() {
if escaped {
escaped = false;
continue;
}
if character == '\\' {
escaped = true;
continue;
}
if let Some(active_quote) = quote {
if character == active_quote {
quote = None;
}
continue;
}
if matches!(character, '\'' | '"') {
quote = Some(character);
continue;
}
match character {
'[' => square_depth += 1,
']' => square_depth = square_depth.saturating_sub(1),
'(' => parenthesis_depth += 1,
')' => parenthesis_depth = parenthesis_depth.saturating_sub(1),
_ => {}
}
if character == target && square_depth == 0 && parenthesis_depth == 0 {
return Some(index);
}
}
None
}
fn find_matching_css_delimiter(
source: &str,
open_index: usize,
open: u8,
close: u8,
) -> Option<usize> {
let bytes = source.as_bytes();
let mut depth = 0_u32;
let mut quote = None;
let mut escaped = false;
for (index, byte) in bytes.iter().copied().enumerate().skip(open_index) {
if escaped {
escaped = false;
continue;
}
if byte == b'\\' {
escaped = true;
continue;
}
if let Some(active_quote) = quote {
if byte == active_quote {
quote = None;
}
continue;
}
if matches!(byte, b'\'' | b'"') {
quote = Some(byte);
} else if byte == open {
depth += 1;
} else if byte == close {
depth = depth.saturating_sub(1);
if depth == 0 {
return Some(index);
}
}
}
None
}
fn split_css_list(source: &str) -> Vec<String> {
no_img_split_css_top_level(source, ',')
.into_iter()
.map(|part| part.trim().to_string())
.filter(|part| !part.is_empty())
.collect()
}
fn no_img_split_css_top_level(source: &str, delimiter: char) -> Vec<&str> {
let mut parts = Vec::new();
let mut start = 0;
let mut square_depth = 0_u32;
let mut parenthesis_depth = 0_u32;
let mut quote = None;
for (index, character) in source.char_indices() {
if let Some(active_quote) = quote {
if character == active_quote {
quote = None;
}
continue;
}
if matches!(character, '\'' | '"') {
quote = Some(character);
continue;
}
match character {
'[' => square_depth += 1,
']' => square_depth = square_depth.saturating_sub(1),
'(' => parenthesis_depth += 1,
')' => parenthesis_depth = parenthesis_depth.saturating_sub(1),
_ => {}
}
if character == delimiter && square_depth == 0 && parenthesis_depth == 0 {
parts.push(&source[start..index]);
start = index + character.len_utf8();
}
}
parts.push(&source[start..]);
parts
}
fn combine_css_selectors(parent: Option<&[String]>, children: &[String]) -> Vec<String> {
let Some(parents) = parent else {
return children.to_vec();
};
parents
.iter()
.flat_map(|parent| {
children.iter().map(move |child| {
if child.contains('&') {
child.replace('&', parent)
} else {
format!("{parent} {child}")
}
})
})
.collect()
}
fn parse_css_declarations(source: &str) -> Vec<StaticCssDeclaration> {
no_img_split_css_top_level(source, ';')
.into_iter()
.filter_map(|declaration| {
let (property, value) = declaration.split_once(':')?;
let property = property.trim().to_ascii_lowercase();
if !TRACKED_CSS_PROPERTIES.contains(&property.as_str()) {
return None;
}
let mut value = value.trim().to_ascii_lowercase();
let is_important = value.ends_with("!important");
if is_important {
value.truncate(value.len() - "!important".len());
value = value.trim().to_string();
}
Some((property, value, is_important))
})
.enumerate()
.map(
|(declaration_order, (property, value, is_important))| StaticCssDeclaration {
property,
value,
is_important,
declaration_order,
},
)
.collect()
}
fn resolve_css_cascade(candidates: &[MatchedCssDeclaration]) -> Option<Result<String, ()>> {
if candidates.is_empty() {
return None;
}
let has_important = candidates
.iter()
.any(|candidate| candidate.declaration.is_important);
let important_candidates = candidates
.iter()
.filter(|candidate| candidate.declaration.is_important == has_important)
.collect::<Vec<_>>();
let has_layered = important_candidates
.iter()
.any(|candidate| candidate.cascade_layer_key.is_some());
let has_unlayered = important_candidates
.iter()
.any(|candidate| candidate.cascade_layer_key.is_none());
let use_layered = if has_important {
has_layered
} else {
!has_unlayered
};
let eligible = important_candidates
.into_iter()
.filter(|candidate| candidate.cascade_layer_key.is_some() == use_layered)
.collect::<Vec<_>>();
if use_layered {
let layer_keys = eligible
.iter()
.filter_map(|candidate| candidate.cascade_layer_key.as_deref())
.collect::<HashSet<_>>();
let values = eligible
.iter()
.map(|candidate| candidate.declaration.value.as_str())
.collect::<HashSet<_>>();
if layer_keys.len() > 1 && values.len() > 1 {
return Some(Err(()));
}
}
eligible
.into_iter()
.max_by_key(|candidate| {
(
candidate.specificity,
candidate.source_order,
candidate.declaration.declaration_order,
)
})
.map(|candidate| Ok(candidate.declaration.value.clone()))
}
fn combine_selector_match(
left: StaticSelectorMatch,
right: StaticSelectorMatch,
) -> StaticSelectorMatch {
if left == StaticSelectorMatch::NoMatch || right == StaticSelectorMatch::NoMatch {
StaticSelectorMatch::NoMatch
} else if left == StaticSelectorMatch::Ambiguous || right == StaticSelectorMatch::Ambiguous {
StaticSelectorMatch::Ambiguous
} else {
StaticSelectorMatch::Match
}
}
fn combine_alternative_selector_matches(
matches: impl IntoIterator<Item = StaticSelectorMatch>,
) -> StaticSelectorMatch {
let mut has_ambiguous = false;
for selector_match in matches {
if selector_match == StaticSelectorMatch::Match {
return StaticSelectorMatch::Match;
}
has_ambiguous |= selector_match == StaticSelectorMatch::Ambiguous;
}
if has_ambiguous {
StaticSelectorMatch::Ambiguous
} else {
StaticSelectorMatch::NoMatch
}
}
fn selector_matches(selector: &str, node_id: NodeId, ctx: &LintContext<'_>) -> StaticSelectorMatch {
let selector = selector.trim();
let Some((left, combinator, right)) = split_rightmost_css_combinator(selector) else {
return css_compound_matches(selector, node_id, ctx);
};
let right_match = css_compound_matches(right, node_id, ctx);
if right_match == StaticSelectorMatch::NoMatch {
return right_match;
}
match combinator {
'>' => parent_opening_node_id(node_id, ctx)
.map_or(StaticSelectorMatch::NoMatch, |parent| {
combine_selector_match(right_match, selector_matches(left, parent, ctx))
}),
' ' => {
let mut parent = parent_opening_node_id(node_id, ctx);
let mut matches = Vec::new();
while let Some(parent_id) = parent {
matches.push(selector_matches(left, parent_id, ctx));
parent = parent_opening_node_id(parent_id, ctx);
}
combine_selector_match(right_match, combine_alternative_selector_matches(matches))
}
_ => StaticSelectorMatch::Ambiguous,
}
}
fn split_rightmost_css_combinator(selector: &str) -> Option<(&str, char, &str)> {
let mut square_depth = 0_u32;
let mut parenthesis_depth = 0_u32;
let mut candidate = None;
let characters = selector.char_indices().collect::<Vec<_>>();
for (position, (index, character)) in characters.iter().copied().enumerate() {
match character {
'[' => square_depth += 1,
']' => square_depth = square_depth.saturating_sub(1),
'(' => parenthesis_depth += 1,
')' => parenthesis_depth = parenthesis_depth.saturating_sub(1),
'>' if square_depth == 0 && parenthesis_depth == 0 => {
candidate = Some((index, '>', index + 1));
}
character
if character.is_whitespace() && square_depth == 0 && parenthesis_depth == 0 =>
{
let previous_non_whitespace = selector[..index].trim_end();
let next_index = characters[position..]
.iter()
.find(|(_, next)| !next.is_whitespace())
.map_or(selector.len(), |(next_index, _)| *next_index);
if !previous_non_whitespace.ends_with('>')
&& selector[next_index..].chars().next() != Some('>')
{
candidate = Some((previous_non_whitespace.len(), ' ', next_index));
}
}
_ => {}
}
}
let (left_end, combinator, right_start) = candidate?;
let left = selector[..left_end].trim();
let right = selector[right_start..].trim();
(!left.is_empty() && !right.is_empty()).then_some((left, combinator, right))
}
fn css_compound_matches(
compound: &str,
node_id: NodeId,
ctx: &LintContext<'_>,
) -> StaticSelectorMatch {
let node = ctx.nodes().get_node(node_id);
let AstKind::JSXOpeningElement(opening_element) = node.kind() else {
return StaticSelectorMatch::NoMatch;
};
let bytes = compound.as_bytes();
let mut index = 0;
let mut result = StaticSelectorMatch::Match;
while index < bytes.len() {
if bytes[index].is_ascii_whitespace() {
index += 1;
continue;
}
let component_match = match bytes[index] {
b'*' => {
index += 1;
StaticSelectorMatch::Match
}
b'.' | b'#' => {
let marker = bytes[index];
let end = css_identifier_end(bytes, index + 1);
let name = &compound[index + 1..end];
index = end;
css_named_selector_matches(opening_element, marker, name)
}
b'[' => {
let Some(end) = find_matching_css_delimiter(compound, index, b'[', b']') else {
return StaticSelectorMatch::Ambiguous;
};
let selector_match =
css_attribute_selector_matches(opening_element, &compound[index + 1..end]);
index = end + 1;
selector_match
}
b':' => {
if bytes.get(index + 1) == Some(&b':') {
return StaticSelectorMatch::NoMatch;
}
let name_end = css_identifier_end(bytes, index + 1);
let name = &compound[index + 1..name_end];
if bytes.get(name_end) == Some(&b'(') {
let Some(end) = find_matching_css_delimiter(compound, name_end, b'(', b')')
else {
return StaticSelectorMatch::Ambiguous;
};
let arguments = &compound[name_end + 1..end];
index = end + 1;
css_functional_pseudo_matches(name, arguments, node_id, ctx)
} else {
index = name_end;
css_pseudo_matches(name, node_id, ctx)
}
}
_ => {
let end = css_identifier_end(bytes, index);
if end == index {
return StaticSelectorMatch::Ambiguous;
}
let tag = &compound[index..end];
index = end;
if resolve_jsx_element_type(opening_element, ctx)
.is_some_and(|(name, _)| name.eq_ignore_ascii_case(tag))
{
StaticSelectorMatch::Match
} else {
StaticSelectorMatch::NoMatch
}
}
};
result = combine_selector_match(result, component_match);
if result == StaticSelectorMatch::NoMatch {
return result;
}
}
result
}
fn css_identifier_end(bytes: &[u8], start: usize) -> usize {
let mut index = start;
while index < bytes.len()
&& (bytes[index].is_ascii_alphanumeric() || matches!(bytes[index], b'-' | b'_' | b'\\'))
{
index += 1;
}
index
}
fn css_named_selector_matches(
opening_element: &JSXOpeningElement<'_>,
marker: u8,
name: &str,
) -> StaticSelectorMatch {
let attribute_name = if marker == b'.' { "className" } else { "id" };
let Some(attribute) = get_authoritative_jsx_attribute(opening_element, attribute_name, false)
else {
return StaticSelectorMatch::NoMatch;
};
let Some(value) = get_static_attribute_string(attribute) else {
return StaticSelectorMatch::Ambiguous;
};
let matches = if marker == b'.' {
value
.split_ascii_whitespace()
.any(|class_name| class_name == name)
} else {
value == name
};
if matches {
StaticSelectorMatch::Match
} else {
StaticSelectorMatch::NoMatch
}
}
fn get_static_attribute_string<'a>(attribute: &'a JSXAttribute<'_>) -> Option<&'a str> {
match attribute.value.as_ref()? {
JSXAttributeValue::StringLiteral(literal) => Some(literal.value.as_str()),
_ => None,
}
}
fn css_attribute_selector_matches(
opening_element: &JSXOpeningElement<'_>,
selector: &str,
) -> StaticSelectorMatch {
let operators = ["~=", "|=", "^=", "$=", "*=", "="];
let operation = operators
.iter()
.find_map(|operator| selector.find(operator).map(|index| (*operator, index)));
let attribute_name = operation
.map_or(selector, |(_, index)| &selector[..index])
.trim();
let Some(attribute) = get_authoritative_jsx_attribute(opening_element, attribute_name, false)
else {
return StaticSelectorMatch::NoMatch;
};
let Some((operator, index)) = operation else {
return StaticSelectorMatch::Match;
};
let Some(attribute_value) = get_static_attribute_string(attribute) else {
return StaticSelectorMatch::Ambiguous;
};
let mut selector_value = selector[index + operator.len()..].trim();
let is_case_insensitive = selector_value.ends_with(" i");
if is_case_insensitive {
selector_value = selector_value[..selector_value.len() - 2].trim_end();
}
selector_value = selector_value.trim_matches(['\'', '"']);
let (attribute_value, selector_value) = if is_case_insensitive {
(
attribute_value.to_ascii_lowercase(),
selector_value.to_ascii_lowercase(),
)
} else {
(attribute_value.to_string(), selector_value.to_string())
};
let matches = match operator {
"=" => attribute_value == selector_value,
"~=" => attribute_value
.split_ascii_whitespace()
.any(|part| part == selector_value),
"|=" => {
attribute_value == selector_value
|| attribute_value.starts_with(&format!("{selector_value}-"))
}
"^=" => attribute_value.starts_with(&selector_value),
"$=" => attribute_value.ends_with(&selector_value),
"*=" => attribute_value.contains(&selector_value),
_ => false,
};
if matches {
StaticSelectorMatch::Match
} else {
StaticSelectorMatch::NoMatch
}
}
fn css_pseudo_matches(name: &str, node_id: NodeId, ctx: &LintContext<'_>) -> StaticSelectorMatch {
if matches!(
name,
"active" | "focus" | "focus-visible" | "focus-within" | "hover"
) {
return StaticSelectorMatch::NoMatch;
}
if matches!(name, "first-child" | "last-child") {
let Some((index, total)) = static_jsx_element_position(node_id, ctx) else {
return StaticSelectorMatch::Ambiguous;
};
let matches = if name == "first-child" {
index == 1
} else {
index == total
};
return if matches {
StaticSelectorMatch::Match
} else {
StaticSelectorMatch::NoMatch
};
}
StaticSelectorMatch::Ambiguous
}
fn css_functional_pseudo_matches(
name: &str,
arguments: &str,
node_id: NodeId,
ctx: &LintContext<'_>,
) -> StaticSelectorMatch {
if matches!(name, "is" | "where" | "any" | "local" | "global") {
return combine_alternative_selector_matches(
split_css_list(arguments)
.iter()
.map(|selector| selector_matches(selector, node_id, ctx)),
);
}
if name == "not" {
return match combine_alternative_selector_matches(
split_css_list(arguments)
.iter()
.map(|selector| selector_matches(selector, node_id, ctx)),
) {
StaticSelectorMatch::Match => StaticSelectorMatch::NoMatch,
StaticSelectorMatch::NoMatch => StaticSelectorMatch::Match,
StaticSelectorMatch::Ambiguous => StaticSelectorMatch::Ambiguous,
};
}
if name == "nth-child" {
if arguments.contains(" of ") {
return StaticSelectorMatch::Ambiguous;
}
let Some((index, _)) = static_jsx_element_position(node_id, ctx) else {
return StaticSelectorMatch::Ambiguous;
};
let argument = arguments.trim().to_ascii_lowercase();
let matches = if argument == "odd" {
index % 2 == 1
} else if argument == "even" {
index % 2 == 0
} else if let Ok(expected) = argument.parse::<usize>() {
index == expected
} else {
nth_child_expression_matches(&argument, index as i64)
};
return if matches {
StaticSelectorMatch::Match
} else {
StaticSelectorMatch::NoMatch
};
}
StaticSelectorMatch::Ambiguous
}
fn nth_child_expression_matches(expression: &str, index: i64) -> bool {
let Some(n_index) = expression.find('n') else {
return false;
};
let coefficient = match expression[..n_index].trim() {
"" | "+" => 1,
"-" => -1,
value => match value.parse::<i64>() {
Ok(value) => value,
Err(_) => return false,
},
};
let offset = expression[n_index + 1..].trim().parse::<i64>().unwrap_or(0);
let difference = index - offset;
coefficient != 0 && difference % coefficient == 0 && difference / coefficient >= 0
}
fn static_jsx_element_position(node_id: NodeId, ctx: &LintContext<'_>) -> Option<(usize, usize)> {
let opening_node = ctx.nodes().get_node(node_id);
let element_node = ctx.nodes().parent_node(opening_node.id());
let AstKind::JSXElement(element) = element_node.kind() else {
return None;
};
let parent_node = ctx.nodes().parent_node(element_node.id());
let AstKind::JSXElement(parent) = parent_node.kind() else {
return None;
};
let mut siblings = Vec::new();
for child in &parent.children {
match child {
JSXChild::Element(child_element) => siblings.push(child_element.span),
JSXChild::Text(text) if text.value.trim().is_empty() => {}
_ => return None,
}
}
let index = siblings.iter().position(|span| *span == element.span)?;
Some((index + 1, siblings.len()))
}
fn parent_opening_node_id(node_id: NodeId, ctx: &LintContext<'_>) -> Option<NodeId> {
let opening_node = ctx.nodes().get_node(node_id);
let element_node = ctx.nodes().parent_node(opening_node.id());
if !matches!(element_node.kind(), AstKind::JSXElement(_)) {
return None;
}
let parent_node = ctx.nodes().parent_node(element_node.id());
let AstKind::JSXElement(parent) = parent_node.kind() else {
return None;
};
Some(parent.opening_element.node_id.get())
}
fn selector_matches_root_target(selector: &str, target: &str) -> StaticSelectorMatch {
let selector = selector.trim();
if split_rightmost_css_combinator(selector).is_some() || selector.contains("::") {
return StaticSelectorMatch::NoMatch;
}
let expected = if target == "root" { "#root" } else { target };
if selector == expected || selector == format!("*{expected}") {
StaticSelectorMatch::Match
} else if selector.starts_with(expected) {
StaticSelectorMatch::Ambiguous
} else {
StaticSelectorMatch::NoMatch
}
}
fn css_selector_specificity(selector: &str) -> CssSpecificity {
let mut ids = 0;
let mut classes = 0;
let mut types = 0;
let bytes = selector.as_bytes();
let mut index = 0;
let mut expects_type = true;
while index < bytes.len() {
match bytes[index] {
b'#' => {
ids += 1;
index = css_identifier_end(bytes, index + 1);
expects_type = false;
}
b'.' | b'[' => {
classes += 1;
if bytes[index] == b'[' {
index = find_matching_css_delimiter(selector, index, b'[', b']')
.map_or(bytes.len(), |end| end + 1);
} else {
index = css_identifier_end(bytes, index + 1);
}
expects_type = false;
}
b':' => {
let name_end = css_identifier_end(bytes, index + 1);
let name = &selector[index + 1..name_end];
if bytes.get(name_end) == Some(&b'(') {
let Some(end) = find_matching_css_delimiter(selector, name_end, b'(', b')')
else {
break;
};
if name != "where" {
let maximum = split_css_list(&selector[name_end + 1..end])
.iter()
.map(|part| css_selector_specificity(part))
.max()
.unwrap_or_default();
ids += maximum.0;
classes += maximum.1;
types += maximum.2;
}
index = end + 1;
} else {
classes += 1;
index = name_end;
}
expects_type = false;
}
b'>' => {
index += 1;
expects_type = true;
}
byte if byte.is_ascii_whitespace() => {
index += 1;
expects_type = true;
}
b'*' => {
index += 1;
expects_type = false;
}
_ => {
let end = css_identifier_end(bytes, index);
if expects_type && end > index {
types += 1;
}
index = end.max(index + 1);
expects_type = false;
}
}
}
CssSpecificity(ids, classes, types)
}