diff --git a/.changepacks/changepack_log_unresolved_global_inputs.json b/.changepacks/changepack_log_unresolved_global_inputs.json new file mode 100644 index 000000000..421f3bf5c --- /dev/null +++ b/.changepacks/changepack_log_unresolved_global_inputs.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Reject unresolved imports and font-face values, misplaced font-template expressions and unusable functional-selector arguments with located build errors, preserving static values and genuine optional absence without a styling runtime.", + "date": "2026-10-19T00:00:00.000Z" +} diff --git a/libs/css/src/lib.rs b/libs/css/src/lib.rs index d918ba28c..75b7aa79f 100644 --- a/libs/css/src/lib.rs +++ b/libs/css/src/lib.rs @@ -15,6 +15,7 @@ pub mod optimize_value; mod property_names; pub mod quoted; pub mod rm_css_comment; +pub mod selector_params; mod selector_separator; pub mod shorthand; pub mod slot_reservation; diff --git a/libs/css/src/selector_params.rs b/libs/css/src/selector_params.rs new file mode 100644 index 000000000..a16927500 --- /dev/null +++ b/libs/css/src/selector_params.rs @@ -0,0 +1,37 @@ +use crate::style_selector::StyleSelector; + +/// The bare functional pseudo targeted by `add_selector_params`, without allocating. +#[must_use] +pub fn parameter_name(selector: &StyleSelector) -> Option<&str> { + let value = match selector { + StyleSelector::Selector(value) + | StyleSelector::Global(value, _) + | StyleSelector::At { + selector: Some(value), + .. + } => value, + StyleSelector::At { selector: None, .. } => return None, + }; + let end = crate::params_position(value)?; + value[..end].rsplit(':').next() +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case("&:nth-child", Some("nth-child"))] + #[case(":is([role=group],[data-group]):nth-child &:hover", Some("nth-child"))] + #[case("&:nth-child(2):hover", None)] + #[case("&:hover", None)] + #[case("&::slotted", Some("slotted"))] + fn finds_the_same_bare_target_as_the_formatter( + #[case] selector: &str, + #[case] expected: Option<&str>, + ) { + let selector = StyleSelector::Selector(selector.to_string()); + assert_eq!(parameter_name(&selector), expected); + } +} diff --git a/libs/extractor/src/barrel_pipeline_tests.rs b/libs/extractor/src/barrel_pipeline_tests.rs index 7293f7934..4ba7f8890 100644 --- a/libs/extractor/src/barrel_pipeline_tests.rs +++ b/libs/extractor/src/barrel_pipeline_tests.rs @@ -8,7 +8,7 @@ fn resolver(specifier: &str, _: &str) -> Option { match specifier { "./ui" => Some(ResolvedModule { path: "/src/ui.ts".to_string(), - code: "export { css, Box } from '@devup-ui/react';".to_string(), + code: "export { css, Box, globalCss } from '@devup-ui/react';".to_string(), }), _ => None, } @@ -159,3 +159,107 @@ fn source_map_and_units_survive_barrel_namespace_alias_and_boa_layers() assert_eq!((token.get_src_line(), token.get_src_col()), (5, 13)); Ok(()) } + +#[rstest] +#[case("import { globalCss } from './ui';", "globalCss")] +#[case("import * as UI from './ui';", "UI.globalCss")] +#[serial] +fn global_imports_evaluate_pure_calls_after_barrel_rewriting( + #[case] import: &str, + #[case] api: &str, + #[values(false, true)] single_css: bool, +) -> Result<(), Box> { + // Given: a structural URL needs evaluation after the project import is rewritten. + reset_class_map(); + reset_file_map(); + let source = format!( + "{import}\n{api}({{imports:[{{url:(() => 'pure.css')()}}]}});\nexport const after = 1;" + ); + // When: the real barrel and value-evaluation pipeline extracts the global. + let output = extract_with_modules( + "/src/app.tsx", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + true, + &resolver, + )?; + // Then: the exact import, dependency and following authored coordinate survive. + let urls: Vec<_> = output + .styles + .iter() + .map(|style| match style { + crate::ExtractStyleValue::Import(import) => import.url.as_str(), + other => panic!("expected import, got {other:?}"), + }) + .collect(); + assert_eq!(urls, vec!["\"pure.css\""]); + assert_eq!(output.dependencies, vec!["/src/ui.ts"]); + let map = + oxc_sourcemap::SourceMap::from_json_string(output.map.as_deref().ok_or("missing map")?)?; + assert_eq!(map.get_source_content(0), Some(source.as_str())); + let (line, generated) = output + .code + .lines() + .enumerate() + .find(|(_, line)| line.contains("after")) + .ok_or("missing declaration")?; + let column = position(generated, "after"); + let token = map + .get_tokens() + .find(|token| { + usize::try_from(token.get_dst_line()) == Ok(line) + && usize::try_from(token.get_dst_col()) == Ok(column) + }) + .ok_or("missing declaration token")?; + assert_eq!((token.get_src_line(), token.get_src_col()), (2, 13)); + Ok(()) +} + +#[rstest] +#[case(("font-family:${() => \"Face\"};", "() => \"Face\"", "() => \"Face\""))] +#[case(("font-family:${() => \" at build time: \"};", "() => \" at build time: \"", "() => \" at build time: \""))] +#[case(("font-family:${class {}};", "class {}", "(class {})"))] +#[case(("font-family:${function() {}};", "function() {}", "(function() {})"))] +#[case(("${p => p.theme.fonts.body}:Face;", "p => p.theme.fonts.body", "(p) => p.theme.fonts.body"))] +#[case(("${p => p.theme.fonts.body};font-family:Face;", "p => p.theme.fonts.body", "(p) => p.theme.fonts.body"))] +#[serial] +fn font_interpolations_keep_authored_refusals_after_barrel_rewriting( + #[case] fixture: (&str, &str, &str), + #[values(false, true)] namespace: bool, + #[values(false, true)] single_css: bool, +) { + // Given: a rewritten global contains a function or misplaced theme interpolation. + reset_class_map(); + reset_file_map(); + let (text, marker, rejected) = fixture; + let (import, api) = if namespace { + ("import * as UI from './ui';", "UI.globalCss") + } else { + ("import { globalCss } from './ui';", "globalCss") + }; + let statement = format!("{api}({{fontFaces:[`{text}src:url(face.woff2);`]}});"); + let column = position(&statement, marker) + 1; + let source = format!("{import}\n{statement}"); + // When: extraction sees the invalid structural leaf through the project barrel. + let error = failure(extract_with_modules( + "/src/app.tsx", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + false, + &resolver, + )); + // Then: neither evaluation nor rewrite mapping can discard or relocate the refusal. + assert_eq!( + error, + format!( + "/src/app.tsx:2:{column}: `globalCss()` cannot use `{rejected}` at build time: {}", + crate::extractor::global_inputs::FONT_TEMPLATE_INPUT + ) + ); +} diff --git a/libs/extractor/src/dead_properties/calls.rs b/libs/extractor/src/dead_properties/calls.rs index e8284fb9d..8f5513960 100644 --- a/libs/extractor/src/dead_properties/calls.rs +++ b/libs/extractor/src/dead_properties/calls.rs @@ -12,6 +12,9 @@ use oxc_ast_visit::{ use oxc_span::GetSpan; use rustc_hash::FxHashMap; +#[path = "params.rs"] +mod params; + pub(super) fn collect( stylesheet: crate::vanilla_extract::Stylesheet<'_>, package: &str, @@ -149,12 +152,45 @@ impl Calls<'_> { }); crate::locate(self.filename, self.source, offset) } + + fn params(&mut self, expression: &Expression<'_>) { + for (offset, leaf) in params::errors(expression, self.scoping) { + self.errors.push(( + offset, + crate::utils::build_time_error( + "css", + &leaf, + crate::extractor::selector_params::SELECTOR_PARAMS, + ), + )); + } + } } impl<'a> Visit<'a> for Calls<'_> { fn visit_tagged_template_expression(&mut self, tagged: &TaggedTemplateExpression<'a>) { let tag = crate::utils::unwrap_syntax_only(&tagged.tag); if self.api(tag).as_deref() == Some("css") { + let mut before = String::new(); + for (index, quasi) in tagged.quasi.quasis.iter().enumerate() { + before.push_str(&quasi.value.raw); + if let Some(expression) = tagged.quasi.expressions.get(index) { + match crate::css_utils::interpolation_place( + &before, + &tagged.quasi.quasis[index + 1..], + ) { + crate::css_utils::Place::Statement => self.params(expression), + crate::css_utils::Place::Value => { + before.push_str("__EXPR__"); + continue; + } + crate::css_utils::Place::Other => {} + } + if let Some(text) = crate::utils::get_string_by_literal_expression(expression) { + before.push_str(&text); + } + } + } // Keep the template site and its eval/await/yield expressions outside the forwarding closure. let receiver = match tag { Expression::StaticMemberExpression(member) => { @@ -188,6 +224,15 @@ impl<'a> Visit<'a> for Calls<'_> { }; if let Some((argument, kind)) = shape { self.ranges.push((call.span.start, call.span.end)); + if api == "css" { + for argument in &call.arguments { + if let Some(expression) = argument.as_expression() { + self.params(expression); + } else if let oxc_ast::ast::Argument::SpreadElement(spread) = argument { + self.params(&spread.argument); + } + } + } if !(api == "styleVariants" && call.arguments.len() > 1) && let Some(expression) = call .arguments diff --git a/libs/extractor/src/dead_properties/params.rs b/libs/extractor/src/dead_properties/params.rs new file mode 100644 index 000000000..9b4fde703 --- /dev/null +++ b/libs/extractor/src/dead_properties/params.rs @@ -0,0 +1,200 @@ +use crate::{ + extractor::{ + selector_params::{ArgumentList, parse, parse_expression}, + style_inputs::absent, + }, + own_css_context::{Contexts, Frame, under}, + utils::{ + get_string_by_literal_expression, get_string_by_property_key, readable_code, + unwrap_syntax_only, + }, +}; +use oxc_ast::ast::{ArrayExpressionElement, Expression, ObjectPropertyKind}; +use oxc_span::GetSpan; + +pub(super) fn errors( + expression: &Expression<'_>, + scoping: &oxc_semantic::Scoping, +) -> Vec<(u32, String)> { + #[derive(Clone, Copy)] + enum Kind { + Styles, + Selectors, + At(css::style_selector::AtRuleKind), + Layers, + } + let mut pending = vec![(expression, Kind::Styles, None)]; + let mut contexts = Contexts::default(); + let mut errors = Vec::new(); + while let Some((expression, kind, mut path)) = pending.pop() { + match unwrap_syntax_only(expression) { + Expression::ArrayExpression(array) => { + for item in array.elements.iter().rev() { + if let Some(value) = item.as_expression() { + pending.push((value, kind, path)); + } + } + } + Expression::ObjectExpression(object) => { + let mut keys = rustc_hash::FxHashSet::default(); + let mut properties = Vec::new(); + let mut complete = true; + let mut candidates: Vec<_> = object.properties.iter().collect(); + while let Some(property) = candidates.pop() { + let property = match property { + ObjectPropertyKind::ObjectProperty(property) => property, + ObjectPropertyKind::SpreadProperty(spread) => { + if let Expression::ObjectExpression(inner) = + unwrap_syntax_only(&spread.argument) + { + candidates.extend(inner.properties.iter()); + continue; + } + complete = false; + break; + } + }; + let Some(name) = get_string_by_property_key(&property.key) else { + complete = false; + break; + }; + if keys.insert(name.clone()) { + properties.push((name, property)); + } + } + if matches!(kind, Kind::Styles) + && contexts.selected(path) + && !complete + && !keys.contains("params") + { + path = Some(contexts.uncertain(path)); + } + if matches!(kind, Kind::Styles) + && contexts.selected(path) + && let Some((_, property)) = + properties.iter().find(|(name, _)| name == "params") + { + let value = &property.value; + let mut partial = false; + let parsed = if property.kind != oxc_ast::ast::PropertyKind::Init { + partial = true; + Ok(None) + } else if let Expression::ArrayExpression(array) = unwrap_syntax_only(value) { + let entries: Vec<_> = array + .elements + .iter() + .map(|element| { + let leaf = match element { + ArrayExpressionElement::Elision(_) => return Ok(None), + ArrayExpressionElement::SpreadElement(_) => { + partial = true; + return Ok(None); + } + _ => element.to_expression(), + }; + if !known(leaf, scoping) { + partial = true; + return Ok(None); + } + get_string_by_literal_expression(leaf) + .map(|text| Some((text.into_owned(), leaf))) + .ok_or(leaf) + }) + .collect(); + parse(ArgumentList::List(entries, value), || { + !partial && contexts.requires_argument(path) + }) + } else if known(value, scoping) { + parse_expression( + value, + absent(value, None) || unbound_undefined(value, scoping), + || contexts.requires_argument(path), + ) + } else { + partial = true; + Ok(None) + }; + match parsed { + Ok(_) if partial => path = Some(contexts.uncertain(path)), + Ok(Some(params)) => path = Some(contexts.push(path, Frame::Params(params))), + Ok(None) => {} + Err(leaf) => { + errors.push((leaf.span().start, readable_code(leaf))); + continue; + } + } + } + for (name, property) in properties { + let mut push = |kind, frame: Option| { + let child_path = + frame.map_or(path, |frame| Some(contexts.push(path, frame))); + pending.push((&property.value, kind, child_path)); + }; + match kind { + Kind::Styles => { + if matches!( + name.as_str(), + "props" | "vars" | "styleVars" | "params" | "as" + ) || css::is_special_property::is_special_property(&name) + { + continue; + } + let child = if name == "selectors" { + (Kind::Selectors, None) + } else if name == "@layer" { + (Kind::Layers, None) + } else if let Some(kind) = + crate::extractor::extract_style_from_expression::at_rule_record_kind( + &name, + ) + { + (Kind::At(kind), None) + } else { + let frame = (name + .starts_with([':', '.', '#', '[', '*', '>', '+', '~', '_']) + || name.contains('&') + || css::at_rule::split_at_rule_key(&name).is_some()) + .then(|| under(&name)) + .flatten(); + (Kind::Styles, frame) + }; + push(child.0, child.1); + } + Kind::Selectors => { + if let Some(frame) = under(&name) { + push(Kind::Styles, Some(frame)); + } + } + Kind::At(kind) => push(Kind::Styles, Some(Frame::At(kind, name))), + Kind::Layers => push(Kind::Styles, Some(Frame::Layer(name))), + } + } + } + _ => {} + } + } + errors +} + +fn unbound_undefined(value: &Expression<'_>, scoping: &oxc_semantic::Scoping) -> bool { + matches!(unwrap_syntax_only(value), Expression::Identifier(id) + if id.name == "undefined" + && id.reference_id.get().is_some_and(|reference| { + scoping.get_reference(reference).symbol_id().is_none() + })) +} + +fn known(value: &Expression<'_>, scoping: &oxc_semantic::Scoping) -> bool { + matches!( + unwrap_syntax_only(value), + Expression::ArrayExpression(_) + | Expression::ObjectExpression(_) + | Expression::NullLiteral(_) + | Expression::ArrowFunctionExpression(_) + | Expression::FunctionExpression(_) + | Expression::RegExpLiteral(_) + | Expression::BigIntLiteral(_) + ) || get_string_by_literal_expression(value).is_some() + || absent(value, None) + || unbound_undefined(value, scoping) +} diff --git a/libs/extractor/src/dead_properties_declaration_boundary_tests.rs b/libs/extractor/src/dead_properties_declaration_boundary_tests.rs index 069bccd77..93d1ac198 100644 --- a/libs/extractor/src/dead_properties_declaration_boundary_tests.rs +++ b/libs/extractor/src/dead_properties_declaration_boundary_tests.rs @@ -141,35 +141,13 @@ fn keeps_import_metadata_when_validating_global_selector_maps() Ok(()) } -#[test] +#[rstest] #[serial] -fn keeps_font_face_literals_when_a_descriptor_object_has_an_unreadable_spread() --> Result<(), Box> { - // Given a spread that has no statically readable font-face descriptors. - reset_class_map(); - reset_file_map(); +fn rejects_font_face_descriptor_objects_when_a_spread_is_unreadable( + #[values(false, true)] single_css: bool, +) { let source = "import { globalCss } from '@devup-ui/react';\nglobalCss({ fontFaces:[{...external, fontFamily:'BoundaryFont', src:'local(BoundaryFont)'}] });"; - - // When the global font-face declaration API extracts literal descriptors. - let output = extract("font.tsx", source, ExtractOption::default())?; - - // Then the existing spread-ignore behavior preserves the authored literals. - let faces: Vec<_> = output - .styles - .iter() - .filter_map(|style| match style { - ExtractStyleValue::FontFace(face) => Some(face.properties.clone()), - _ => None, - }) - .collect(); - assert_eq!( - faces, - vec![std::collections::BTreeMap::from([ - ("font-family".to_string(), "BoundaryFont".to_string()), - ("src".to_string(), "local(BoundaryFont)".to_string()), - ])] - ); - Ok(()) + crate::unresolved_global_inputs_tests::rejects_at(source, "external", single_css); } #[rstest] diff --git a/libs/extractor/src/extractor/extract_global_style_from_expression.rs b/libs/extractor/src/extractor/extract_global_style_from_expression.rs index 9053f834e..a4524a7e6 100644 --- a/libs/extractor/src/extractor/extract_global_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_global_style_from_expression.rs @@ -1,18 +1,13 @@ -use std::borrow::Cow; - use crate::{ ExtractStyleProp, - css_utils::{CssToStyleResult, css_to_style_literal}, - extract_style::{ - extract_font_face::ExtractFontFace, extract_import::ExtractImport, - extract_style_value::ExtractStyleValue, - }, + extract_style::extract_style_value::ExtractStyleValue, extractor::{ GlobalExtractResult, extract_style_from_expression::{ LiteralHandling, at_rule_record_kind, extract_style_from_expression, misplaced, place_in_layer, unreadable, unreadable_key, yield_typography, }, + global_inputs::{GlobalInputs, font_faces, imports}, }, utils::{ SELECTOR_NAME, get_str_by_property_key, get_string_by_literal_expression, @@ -21,13 +16,11 @@ use crate::{ }; use css::{ at_rule::{media_shorthand_query, split_at_rule_key}, - disassemble_property, - optimize_multi_css_value::{check_multi_css_optimize, optimize_multi_css_value, wrap_url}, style_selector::{AtRule, AtRuleKind, StyleSelector, is_selector_name}, utils::to_kebab_case, }; use oxc_ast::{ - ast::{ArrayExpressionElement, Expression, ObjectPropertyKind}, + ast::{Expression, ObjectPropertyKind}, builder::AstBuilder, }; use oxc_span::GetSpan; @@ -35,7 +28,7 @@ use oxc_span::GetSpan; pub fn extract_global_style_from_expression<'a>( ast_builder: &AstBuilder<'a>, expression: &mut Expression<'a>, - file: &str, + inputs: &GlobalInputs<'_, 'a>, ) -> GlobalExtractResult<'a> { let invalid: Vec<_> = crate::dead_properties::authored_errors( expression, @@ -56,7 +49,7 @@ pub fn extract_global_style_from_expression<'a>( }; } let mut styles = vec![]; - collect_global_styles(ast_builder, expression, file, &[], &mut styles); + collect_global_styles(ast_builder, expression, inputs, &[], &mut styles); GlobalExtractResult { styles, style_order: None, @@ -81,7 +74,7 @@ fn global_at_rule_key(name: &str) -> Option { fn collect_global_styles<'a>( ast_builder: &AstBuilder<'a>, expression: &mut Expression<'a>, - file: &str, + inputs: &GlobalInputs<'_, 'a>, at_rules: &[AtRule], styles: &mut Vec>, ) { @@ -109,7 +102,7 @@ fn collect_global_styles<'a>( collect_global_styles( ast_builder, &mut entry.value, - file, + inputs, &nested, styles, ); @@ -133,7 +126,7 @@ fn collect_global_styles<'a>( collect_global_styles( ast_builder, &mut o.value, - file, + inputs, at_rules, &mut layered, ); @@ -142,168 +135,17 @@ fn collect_global_styles<'a>( } else if let Some(at_rule) = global_at_rule_key(&name) { let mut nested = at_rules.to_vec(); nested.push(at_rule); - collect_global_styles(ast_builder, &mut o.value, file, &nested, styles); + collect_global_styles( + ast_builder, + &mut o.value, + inputs, + &nested, + styles, + ); } else if name == "imports" { - if let Expression::ArrayExpression(arr) = &o.value { - for p in &arr.elements { - // `...spread` elements carry no statically readable url. - let Some(element) = p.as_expression() else { - continue; - }; - if let Expression::ObjectExpression(obj) = element { - let mut url = None; - let mut query = None; - for p in &obj.properties { - if let ObjectPropertyKind::ObjectProperty(o) = p - && let Some(ident) = get_str_by_property_key(&o.key) - { - if ident == "url" { - url = - get_string_by_literal_expression(&o.value); - } else if ident == "query" { - query = - get_string_by_literal_expression(&o.value); - } - } - } - if let Some(url) = url { - // Build `"url"` (+ optional ` query`) - // in one presized buffer instead of a - // nested `format!` that allocates a - // throwaway inner `String`. - let mut import_url = String::with_capacity( - url.len() - + 2 - + query.as_ref().map_or(0, |q| q.len() + 1), - ); - import_url.push('"'); - import_url.push_str(&url); - import_url.push('"'); - if let Some(query) = query { - import_url.push(' '); - import_url.push_str(&query); - } - styles.push(ExtractStyleProp::Static( - ExtractStyleValue::Import(ExtractImport { - file: file.to_string(), - order: 0, - url: import_url, - }), - )); - } - } else if !matches!(element, Expression::NumericLiteral(_)) - && let Some(url) = get_string_by_literal_expression(element) - { - styles.push(ExtractStyleProp::Static( - ExtractStyleValue::Import(ExtractImport { - file: file.to_string(), - order: 0, - url: url.into_owned(), - }), - )); - } - } - } + styles.extend(imports(ast_builder, &mut o.value, inputs)); } else if name == "fontFaces" { - if let Expression::ArrayExpression(arr) = &o.value { - for p in &arr.elements { - if let ArrayExpressionElement::ObjectExpression(o) = p { - let invalid: Vec<_> = o - .properties - .iter() - .filter_map(|property| match property { - ObjectPropertyKind::ObjectProperty(property) => { - crate::dead_properties::authored_declaration_error( - &get_str_by_property_key(&property.key)?, - property.key.span().start, - ) - } - ObjectPropertyKind::SpreadProperty(_) => None, - }) - .collect(); - if !invalid.is_empty() { - styles.extend(invalid); - continue; - } - let properties = o - .properties - .iter() - .filter_map(|p| { - if let ObjectPropertyKind::ObjectProperty(o) = p - && let Some(property_name) = - get_str_by_property_key(&o.key) - && let Some(s) = - get_string_by_literal_expression(&o.value) - { - let it = disassemble_property(&property_name) - .map(|p| { - let v = if check_multi_css_optimize(&p) - { - optimize_multi_css_value(&s) - } else { - Cow::Borrowed(&*s) - }; - if p == "src" { - ( - p.into_owned(), - wrap_url(&v).into_owned(), - ) - } else { - (p.into_owned(), v.into_owned()) - } - }); - Some(it.collect::>()) - } else { - None - } - }) - .flatten() - .collect(); - styles.push(ExtractStyleProp::Static( - ExtractStyleValue::FontFace(ExtractFontFace { - file: file.to_string(), - order: 0, - properties, - }), - )); - } else if let ArrayExpressionElement::TemplateLiteral(t) = p { - let invalid = crate::dead_properties::template_errors(t); - if !invalid.is_empty() { - styles.extend(invalid); - continue; - } - let css_styles = css_to_style_literal(t, 0, &None) - .into_iter() - .filter_map(|ex| { - if let CssToStyleResult::Static(st) = ex { - Some(ExtractStyleValue::Static(st)) - } else { - None - } - }) - .collect::>(); - styles.push(ExtractStyleProp::Static( - ExtractStyleValue::FontFace(ExtractFontFace { - order: 0, - properties: css_styles - .iter() - .filter_map(|p| { - if let ExtractStyleValue::Static(st) = p { - Some(( - st.property().to_string(), - st.value().to_string(), - )) - } else { - None - } - }) - .collect(), - file: file.to_string(), - }), - )); - } - } - } + styles.extend(font_faces(ast_builder, &mut o.value, inputs)); } else { // Handle @layer property in globalStyle // Extract the layer name if present in the style object @@ -335,7 +177,7 @@ fn collect_global_styles<'a>( } else { name }, - file.to_string(), + inputs.file.to_string(), ); // `None` when the enclosing at-rules can never match together. let selector = at_rules.iter().try_fold(global, |selector, rule| { @@ -344,7 +186,10 @@ fn collect_global_styles<'a>( let extracted = selector .map(|selector| { extract_style_from_expression( - ast_builder, + super::style_inputs::StyleInputs::with_values( + ast_builder, + inputs.values, + ), None, &mut o.value, 0, @@ -374,7 +219,7 @@ fn collect_global_styles<'a>( collect_global_styles( ast_builder, o.argument.get_inner_expression_mut(), - file, + inputs, at_rules, styles, ); diff --git a/libs/extractor/src/extractor/extract_keyframes_from_expression.rs b/libs/extractor/src/extractor/extract_keyframes_from_expression.rs index 4f4a11614..591d5aae0 100644 --- a/libs/extractor/src/extractor/extract_keyframes_from_expression.rs +++ b/libs/extractor/src/extractor/extract_keyframes_from_expression.rs @@ -9,14 +9,11 @@ use crate::{ }, utils::{get_string_by_property_key, readable_code, runtime_value, unwrap_syntax_only_mut}, }; -use oxc_ast::{ - ast::{Expression, ObjectPropertyKind}, - builder::AstBuilder, -}; +use oxc_ast::ast::{Expression, ObjectPropertyKind}; use oxc_span::GetSpan; pub(crate) fn extract_keyframes_with_location<'a>( - ast_builder: &AstBuilder<'a>, + inputs: super::style_inputs::StyleInputs<'_, 'a>, expression: &mut Expression<'a>, ) -> (KeyframesExtractResult, Option) { let mut keyframes = ExtractKeyframes::default(); @@ -67,7 +64,7 @@ pub(crate) fn extract_keyframes_with_location<'a>( continue; }; let ExtractResult { styles, .. } = extract_style_from_expression( - ast_builder, + inputs, None, &mut o.value, 0, diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index 2e9d5a700..698a1f92f 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -1,3 +1,4 @@ +use super::style_inputs::StyleInputs; use crate::{ ExtractStyleProp, css_utils::{css_to_style, css_to_style_literal, theme_var_reference}, @@ -14,7 +15,6 @@ use crate::{ }, }; use css::{ - add_selector_params, at_rule::{media_shorthand_query, split_at_rule_key}, disassemble_property, get_enum_property_map, get_enum_property_value, is_enum_property, is_special_property::is_special_property, @@ -221,7 +221,7 @@ fn create_static_styles<'a>( } pub fn extract_style_from_expression<'a>( - ast_builder: &AstBuilder<'a>, + inputs: StyleInputs<'_, 'a>, name: Option<&str>, expression: &mut Expression<'a>, level: u8, @@ -229,7 +229,7 @@ pub fn extract_style_from_expression<'a>( literal_handling: LiteralHandling, ) -> ExtractResult<'a> { extract_with_policy::( - ast_builder, + inputs, name, expression, level, @@ -241,7 +241,7 @@ pub fn extract_style_from_expression<'a>( /// Extract styles, optionally retaining authored locations of dynamic object leaves. pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( - ast_builder: &AstBuilder<'a>, + inputs: StyleInputs<'_, 'a>, name: Option<&str>, expression: &mut Expression<'a>, level: u8, @@ -250,7 +250,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( prepare: &mut impl FnMut(&mut Expression<'a>), ) -> ExtractResult<'a> { extract_projected::( - ast_builder, + inputs, name, expression, (level, selector), @@ -261,7 +261,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( } pub(super) fn extract_projected<'a, const LOCATE: bool>( - ast_builder: &AstBuilder<'a>, + inputs: StyleInputs<'_, 'a>, name: Option<&str>, expression: &mut Expression<'a>, (level, selector): (u8, &Option), @@ -269,6 +269,7 @@ pub(super) fn extract_projected<'a, const LOCATE: bool>( projection: &mut super::style_order_projection::Projection<'a>, prepare: &mut impl FnMut(&mut Expression<'a>), ) -> ExtractResult<'a> { + let ast_builder = inputs.ast; let mut typo = false; let expression = unwrap_syntax_only_mut(expression); let offset = expression.span().start; @@ -352,7 +353,7 @@ pub(super) fn extract_projected<'a, const LOCATE: bool>( let ExtractResult { styles, tag: _tag, .. } = projection.extract::( - ast_builder, + inputs, Some(disassembled), sibling.as_mut().unwrap_or(&mut prop.value), (0, &None), @@ -372,7 +373,7 @@ pub(super) fn extract_projected<'a, const LOCATE: bool>( let ExtractResult { styles, tag: _tag, .. } = projection.extract::( - ast_builder, + inputs, None, &mut prop.argument, (0, &None), @@ -405,7 +406,7 @@ pub(super) fn extract_projected<'a, const LOCATE: bool>( consequent: Some(Box::new(ExtractStyleProp::StaticArray( projection .extract::( - ast_builder, + inputs, None, &mut conditional.consequent, (level, &None), @@ -417,7 +418,7 @@ pub(super) fn extract_projected<'a, const LOCATE: bool>( alternate: Some(Box::new(ExtractStyleProp::StaticArray( projection .extract::( - ast_builder, + inputs, None, &mut conditional.alternate, (level, selector), @@ -461,7 +462,7 @@ pub(super) fn extract_projected<'a, const LOCATE: bool>( let mut styles = vec![]; for part in split_selector_list(name) { styles.extend(extract_under_key::( - ast_builder, + inputs, (part, offset), &mut expression.clone_in_with_semantic_ids(ast_builder.allocator()), (level, selector), @@ -508,7 +509,7 @@ pub(super) fn extract_projected<'a, const LOCATE: bool>( let offset = o.key.span().start; for part in split_selector_list(key_name.trim()) { props.extend(extract_under_key::( - ast_builder, + inputs, (part, offset), &mut o.value.clone_in_with_semantic_ids(ast_builder.allocator()), (level, selector), @@ -536,7 +537,7 @@ pub(super) fn extract_projected<'a, const LOCATE: bool>( { let mut styles = projection .extract::( - ast_builder, + inputs, None, &mut o.value, (level, selector), @@ -569,7 +570,7 @@ pub(super) fn extract_projected<'a, const LOCATE: bool>( props.extend( projection .extract::( - ast_builder, + inputs, None, &mut o.value, (level, &Some(at_selector)), @@ -590,7 +591,7 @@ pub(super) fn extract_projected<'a, const LOCATE: bool>( let offset = expression.span().start; return ExtractResult { styles: extract_under_key::( - ast_builder, + inputs, (name, offset), expression, (level, selector), @@ -718,7 +719,7 @@ pub(super) fn extract_projected<'a, const LOCATE: bool>( None => unreadable(expression), }, Expression::ComputedMemberExpression(mem) => { - extract_member::(ast_builder, name, mem, level, selector, prepare) + extract_member::(inputs, name, mem, level, selector, prepare) } Expression::TemplateLiteral(_) => { let Some(name) = name else { @@ -871,7 +872,7 @@ pub(super) fn extract_projected<'a, const LOCATE: bool>( LogicalOperator::Coalesce => nullish, }; projection.extract::( - ast_builder, + inputs, name, if takes_right { &mut logical.right @@ -888,7 +889,7 @@ pub(super) fn extract_projected<'a, const LOCATE: bool>( { let consequent = literal_truthiness(&conditional.test) == Some(true); projection.extract::( - ast_builder, + inputs, name, if consequent { &mut conditional.consequent @@ -905,7 +906,7 @@ pub(super) fn extract_projected<'a, const LOCATE: bool>( let res = Some(Box::new(ExtractStyleProp::StaticArray( projection .extract::( - ast_builder, + inputs, name, &mut logical.right, (level, selector), @@ -955,7 +956,7 @@ pub(super) fn extract_projected<'a, const LOCATE: bool>( consequent: Some(Box::new(ExtractStyleProp::StaticArray( projection .extract::( - ast_builder, + inputs, name, &mut logical.left, (level, selector), @@ -988,7 +989,7 @@ pub(super) fn extract_projected<'a, const LOCATE: bool>( props.extend( projection .extract::( - ast_builder, + inputs, name, element, (idx as u8, selector), @@ -1010,7 +1011,7 @@ pub(super) fn extract_projected<'a, const LOCATE: bool>( Expression::ConditionalExpression(conditional) => { if is_same_expression(&conditional.consequent, &conditional.alternate) { projection.extract::( - ast_builder, + inputs, name, &mut conditional.consequent, (level, selector), @@ -1025,7 +1026,7 @@ pub(super) fn extract_projected<'a, const LOCATE: bool>( consequent: Some(Box::new(ExtractStyleProp::StaticArray( projection .extract::( - ast_builder, + inputs, name, &mut conditional.consequent, (level, selector), @@ -1037,7 +1038,7 @@ pub(super) fn extract_projected<'a, const LOCATE: bool>( alternate: Some(Box::new(ExtractStyleProp::StaticArray( projection .extract::( - ast_builder, + inputs, name, &mut conditional.alternate, (level, selector), @@ -1054,48 +1055,15 @@ pub(super) fn extract_projected<'a, const LOCATE: bool>( Expression::ObjectExpression(obj) => { flatten_spreads(ast_builder, obj); let mut props = vec![]; - let params = obj.properties.iter().find_map(|p| { - if let ObjectPropertyKind::ObjectProperty(o) = p - && o.key.name().is_some_and(|name| name == "params") - && selector.is_some() - && let Expression::ArrayExpression(array) = &o.value - { - let arr = array.elements.iter(); - let arr = arr.filter_map(|e| { - if let Some(e) = e.as_expression() - && let Some(s) = get_string_by_literal_expression(e) - && !s.is_empty() - { - Some(s) - } else { - None - } - }); - // Fold the filter-mapped literals directly into one - // `String` (prefix `,` after the first) instead of - // collecting into a throwaway `Vec` and - // `join`ing it — same bytes, one fewer heap Vec and no - // per-element re-copy in `join`. - let mut params = String::new(); - for s in arr { - if !params.is_empty() { - params.push(','); - } - params.push_str(&s); - } - Some(params) - } else { - None + let selector = match super::selector_params::resolve(inputs, obj, selector) { + Ok(selector) => selector, + Err(error) => { + return ExtractResult { + styles: vec![*error], + ..ExtractResult::default() + }; } - }); - - let selector = selector.clone().map(|s| { - if let Some(params) = params { - add_selector_params(s, ¶ms) - } else { - s - } - }); + }; for p in &mut obj.properties { let o = match p { @@ -1104,7 +1072,7 @@ pub(super) fn extract_projected<'a, const LOCATE: bool>( props.extend( projection .extract::( - ast_builder, + inputs, None, &mut spread.argument, (level, &selector), @@ -1145,7 +1113,7 @@ pub(super) fn extract_projected<'a, const LOCATE: bool>( props.extend( projection .extract::( - ast_builder, + inputs, Some(name), sibling.as_mut().unwrap_or(&mut o.value), (level, &selector), @@ -1409,7 +1377,7 @@ impl From> for Nested { } fn extract_under_key<'a, const LOCATE: bool>( - ast_builder: &AstBuilder<'a>, + inputs: StyleInputs<'_, 'a>, (key, offset): (&str, u32), expression: &mut Expression<'a>, (level, selector): (u8, &Option), @@ -1421,7 +1389,7 @@ fn extract_under_key<'a, const LOCATE: bool>( Nested::Under(nested) => { projection .extract::( - ast_builder, + inputs, None, expression, (level, &Some(nested)), @@ -1610,7 +1578,7 @@ mod tests { }; let result = extract_style_from_expression( - &AstBuilder::new(&allocator), + (&AstBuilder::new(&allocator)).into(), Some("selectors"), &mut statement.expression, 0, @@ -1644,7 +1612,7 @@ mod tests { }; let result = extract_style_from_expression( - &AstBuilder::new(&allocator), + (&AstBuilder::new(&allocator)).into(), None, &mut statement.expression, 0, diff --git a/libs/extractor/src/extractor/extract_style_from_jsx.rs b/libs/extractor/src/extractor/extract_style_from_jsx.rs index d75e63749..455a79a74 100644 --- a/libs/extractor/src/extractor/extract_style_from_jsx.rs +++ b/libs/extractor/src/extractor/extract_style_from_jsx.rs @@ -1,18 +1,17 @@ +use super::style_inputs::StyleInputs; use crate::extractor::{ ExtractResult, extract_style_from_expression::{LiteralHandling, extract_style_from_expression}, }; use oxc_allocator::{CloneIn, GetAllocator}; -use oxc_ast::{ - ast::{Expression, JSXAttributeValue}, - builder::AstBuilder, -}; +use oxc_ast::ast::{Expression, JSXAttributeValue}; pub fn extract_style_from_jsx<'a>( - ast_builder: &AstBuilder<'a>, + inputs: StyleInputs<'_, 'a>, name: &str, value: &mut JSXAttributeValue<'a>, ) -> ExtractResult<'a> { + let ast_builder = inputs.ast; let expression = match value { JSXAttributeValue::ExpressionContainer(expression) => expression .expression @@ -27,7 +26,7 @@ pub fn extract_style_from_jsx<'a>( expression .map(|mut expression| { extract_style_from_expression( - ast_builder, + inputs, Some(name), &mut expression, 0, diff --git a/libs/extractor/src/extractor/extract_style_from_member_expression.rs b/libs/extractor/src/extractor/extract_style_from_member_expression.rs index 147c6c26f..4d71f4128 100644 --- a/libs/extractor/src/extractor/extract_style_from_member_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_member_expression.rs @@ -1,3 +1,4 @@ +use super::style_inputs::StyleInputs; use crate::{ ExtractStyleProp, extractor::{ @@ -21,13 +22,14 @@ use oxc_span::SPAN; use std::collections::BTreeMap; pub(super) fn extract_member<'a, const LOCATE: bool>( - ast_builder: &AstBuilder<'a>, + inputs: StyleInputs<'_, 'a>, name: Option<&str>, mem: &mut ComputedMemberExpression<'a>, level: u8, selector: &Option, prepare: &mut impl FnMut(&mut Expression<'a>), ) -> ExtractResult<'a> { + let ast_builder = inputs.ast; let mem_expression = &mem .expression .clone_in_with_semantic_ids(ast_builder.allocator()); @@ -102,7 +104,7 @@ pub(super) fn extract_member<'a, const LOCATE: bool>( && let Some(p) = p.as_expression_mut() { return extract_with_policy::( - ast_builder, + inputs, name, p, level, @@ -135,7 +137,7 @@ pub(super) fn extract_member<'a, const LOCATE: bool>( idx.to_string(), Box::new(ExtractStyleProp::StaticArray( extract_with_policy::( - ast_builder, + inputs, name, p, level, @@ -179,7 +181,7 @@ pub(super) fn extract_member<'a, const LOCATE: bool>( { return ExtractResult { styles: extract_with_policy::( - ast_builder, + inputs, name, &mut o.value, level, @@ -203,7 +205,7 @@ pub(super) fn extract_member<'a, const LOCATE: bool>( property_name, Box::new(ExtractStyleProp::StaticArray( extract_with_policy::( - ast_builder, + inputs, name, &mut o.value, level, diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index 02afeed69..2a121ad0e 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -826,7 +826,7 @@ fn resolve_styled_call_target<'a>( /// - styled.div({ bg: "red" }) /// - styled(Component)({ bg: "red" }) pub fn extract_style_from_styled<'a>( - ast_builder: &AstBuilder<'a>, + inputs: super::style_inputs::StyleInputs<'_, 'a>, expression: &mut Expression<'a>, naming: Naming<'_>, bindings: StyledBindings<'_>, @@ -834,6 +834,7 @@ pub fn extract_style_from_styled<'a>( inherited: Option<&StyledDefinition<'a>>, context: StyledContext<'_>, ) -> StyledExtraction<'a> { + let ast_builder = inputs.ast; let imports = bindings.imports; let Naming { split_filename, @@ -1061,7 +1062,7 @@ pub fn extract_style_from_styled<'a>( .. } = composed_rules.unwrap_or_else(|| { let (result, projection) = extract_with_order( - ast_builder, + inputs, None, if let Argument::SpreadElement(spread) = &mut call.arguments[style_index] { &mut spread.argument diff --git a/libs/extractor/src/extractor/global_inputs.rs b/libs/extractor/src/extractor/global_inputs.rs new file mode 100644 index 000000000..563a7c0b0 --- /dev/null +++ b/libs/extractor/src/extractor/global_inputs.rs @@ -0,0 +1,30 @@ +mod fonts; +mod imports; + +pub(super) use fonts::font_faces; +pub(super) use imports::imports; + +use crate::{ExtractStyleProp, style_values::StyleValues, utils::readable_code}; +use oxc_ast::ast::Expression; +use oxc_span::GetSpan; + +pub(crate) const FONT_TEMPLATE_INPUT: &str = "font-face template interpolations must be static CSS text or theme references in descriptor values"; + +pub(crate) struct GlobalInputs<'s, 'a> { + pub file: &'s str, + pub values: &'s StyleValues<'a>, +} + +impl GlobalInputs<'_, '_> { + fn absent(&self, expression: &Expression<'_>) -> bool { + super::style_inputs::absent(expression, Some(self.values)) + } +} + +fn reject<'a>(expression: &Expression<'a>, requirement: &'static str) -> ExtractStyleProp<'a> { + super::extract_style_from_expression::misplaced( + expression.span().start, + readable_code(expression), + requirement, + ) +} diff --git a/libs/extractor/src/extractor/global_inputs/fonts.rs b/libs/extractor/src/extractor/global_inputs/fonts.rs new file mode 100644 index 000000000..00797be31 --- /dev/null +++ b/libs/extractor/src/extractor/global_inputs/fonts.rs @@ -0,0 +1,247 @@ +use super::{FONT_TEMPLATE_INPUT, GlobalInputs, reject}; +use crate::{ + ExtractStyleProp, + css_utils::{CssToStyleResult, css_to_style_template, theme_var_reference}, + extract_style::{extract_font_face::ExtractFontFace, extract_style_value::ExtractStyleValue}, + extractor::extract_style_from_expression::{flatten_spreads, unreadable_key}, + utils::{ + get_str_by_property_key, get_string_by_literal_expression, unwrap_syntax_only, + unwrap_syntax_only_mut, + }, +}; +use css::{ + disassemble_property, + optimize_multi_css_value::{check_multi_css_optimize, optimize_multi_css_value, wrap_url}, +}; +use oxc_ast::{ + ast::{ArrayExpressionElement, Expression, ObjectPropertyKind}, + builder::AstBuilder, +}; +use oxc_span::GetSpan; +use std::{borrow::Cow, collections::BTreeMap}; + +const FONT_TEXT: &str = "font-face descriptors and interpolations must be static text; templates may use theme references only in descriptor values; descriptor spreads must be known at build time"; + +pub(crate) fn font_faces<'a>( + ast: &AstBuilder<'a>, + expression: &mut Expression<'a>, + inputs: &GlobalInputs<'_, '_>, +) -> Vec> { + let expression = unwrap_syntax_only_mut(expression); + let Expression::ArrayExpression(array) = expression else { + return if inputs.absent(expression) + || get_string_by_literal_expression(expression).is_some() + { + vec![] + } else { + vec![reject(expression, FONT_TEXT)] + }; + }; + let mut styles = Vec::new(); + for entry in &mut array.elements { + let start = styles.len(); + let properties = match entry { + ArrayExpressionElement::ObjectExpression(object) => { + flatten_spreads(ast, object); + let invalid: Vec<_> = object + .properties + .iter() + .filter_map(|property| match property { + ObjectPropertyKind::ObjectProperty(property) => { + crate::dead_properties::authored_declaration_error( + &get_str_by_property_key(&property.key)?, + property.key.span().start, + ) + } + ObjectPropertyKind::SpreadProperty(_) => None, + }) + .collect(); + if !invalid.is_empty() { + styles.extend(invalid); + continue; + } + let mut properties = BTreeMap::new(); + for property in &object.properties { + match property { + ObjectPropertyKind::SpreadProperty(spread) => { + styles.push(reject(&spread.argument, FONT_TEXT)); + } + ObjectPropertyKind::ObjectProperty(property) => { + let Some(name) = get_str_by_property_key(&property.key) else { + styles.push(unreadable_key(&property.key, false)); + continue; + }; + let Some(text) = get_string_by_literal_expression(&property.value) + else { + if !inputs.absent(&property.value) { + styles.push(reject(&property.value, FONT_TEXT)); + } + continue; + }; + for name in disassemble_property(&name) { + let value = if check_multi_css_optimize(&name) { + optimize_multi_css_value(&text) + } else { + Cow::Borrowed(text.as_ref()) + }; + let value = if name == "src" { + wrap_url(&value).into_owned() + } else { + value.into_owned() + }; + properties.insert(name.into_owned(), value); + } + } + } + } + properties + } + ArrayExpressionElement::TemplateLiteral(template) => { + let invalid = crate::dead_properties::template_errors(template); + if !invalid.is_empty() { + styles.extend(invalid); + continue; + } + for expression in &template.expressions { + if get_string_by_literal_expression(expression).is_none() + && theme_var_reference(expression).is_none() + { + let requirement = if matches!( + unwrap_syntax_only(expression), + Expression::ArrowFunctionExpression(_) + | Expression::FunctionExpression(_) + | Expression::ClassExpression(_) + ) { + FONT_TEMPLATE_INPUT + } else { + FONT_TEXT + }; + styles.push(reject(expression, requirement)); + } + } + if styles.len() != start { + continue; + } + let parsed = css_to_style_template(template, 0, &None); + styles.extend( + parsed + .statements + .into_iter() + .chain(parsed.unplaced) + .map(|index| reject(&template.expressions[index], FONT_TEMPLATE_INPUT)), + ); + if styles.len() != start { + continue; + } + static_properties(parsed.styles) + } + ArrayExpressionElement::SpreadElement(spread) => { + styles.push(reject(&spread.argument, FONT_TEXT)); + continue; + } + ArrayExpressionElement::Elision(_) => continue, + _ => { + let expression = entry.to_expression(); + if !inputs.absent(expression) + && get_string_by_literal_expression(expression).is_none() + { + styles.push(reject(expression, FONT_TEXT)); + } + continue; + } + }; + if styles.len() == start { + styles.push(ExtractStyleProp::Static(ExtractStyleValue::FontFace( + ExtractFontFace { + file: inputs.file.to_string(), + order: 0, + properties, + }, + ))); + } + } + styles +} + +fn static_properties(styles: Vec) -> BTreeMap { + styles + .into_iter() + .filter_map(|style| match style { + CssToStyleResult::Static(style) => Some((style.property, style.value)), + CssToStyleResult::Dynamic(_) => None, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::css_utils::css_to_style_literal; + use oxc_allocator::Allocator; + use oxc_ast::ast::Statement; + use oxc_parser::Parser; + use oxc_span::SourceType; + + #[test] + fn parser_projection_only_retains_static_descriptors() { + let arena = Allocator::default(); + let parsed = Parser::new( + &arena, + "`font-family: Face; font-weight: ${weight};`;", + SourceType::tsx(), + ) + .parse(); + let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else { + panic!("expected expression") + }; + let Expression::TemplateLiteral(template) = &statement.expression else { + panic!("expected template") + }; + let properties = static_properties(css_to_style_literal(template, 0, &None)); + assert_eq!( + properties, + BTreeMap::from([("font-family".to_string(), "Face".to_string())]) + ); + } + + #[rstest::rstest] + #[case::object("[{boxAlign:'center',src:'face.woff2'}]", 2, "boxAlign")] + #[case::template("[`src:url(a); box-align:center`]", 14, "box-align")] + fn font_collection_rejects_dead_declarations_when_called_directly( + #[case] source: &str, + #[case] offset: u32, + #[case] code: &str, + ) { + // Given a descriptor array before any public-call prevalidation. + let arena = Allocator::default(); + let ast = AstBuilder::new(&arena); + let mut expression = Parser::new(&arena, source, SourceType::tsx()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + let values = crate::style_values::StyleValues::default(); + let inputs = GlobalInputs { + file: "font.tsx", + values: &values, + }; + // When the font collector itself validates the authored declaration. + let styles = font_faces(&ast, &mut expression, &inputs); + // Then the dead key owns the refusal and no partial font-face escapes. + let [ + ExtractStyleProp::Unreadable { + offset: actual, + code: rejected, + requirement, + .. + }, + ] = styles.as_slice() + else { + panic!("expected only a dead-declaration refusal: {styles:?}") + }; + assert_eq!((*actual, rejected.as_str()), (offset, code)); + assert_eq!( + *requirement, + crate::dead_properties::requirement("box-align") + ); + assert!(requirement.is_some()); + } +} diff --git a/libs/extractor/src/extractor/global_inputs/imports.rs b/libs/extractor/src/extractor/global_inputs/imports.rs new file mode 100644 index 000000000..a66dd561a --- /dev/null +++ b/libs/extractor/src/extractor/global_inputs/imports.rs @@ -0,0 +1,111 @@ +use super::{GlobalInputs, reject}; +use crate::{ + ExtractStyleProp, + extract_style::{extract_import::ExtractImport, extract_style_value::ExtractStyleValue}, + extractor::extract_style_from_expression::flatten_spreads, + utils::{get_str_by_property_key, get_string_by_literal_expression, unwrap_syntax_only_mut}, +}; +use oxc_ast::{ + ast::{ArrayExpressionElement, Expression, ObjectPropertyKind}, + builder::AstBuilder, +}; + +const IMPORT_TEXT: &str = "import URLs and queries must be static text; import entries and spreads must be known at build time"; + +pub(crate) fn imports<'a>( + ast: &AstBuilder<'a>, + expression: &mut Expression<'a>, + inputs: &GlobalInputs<'_, '_>, +) -> Vec> { + let expression = unwrap_syntax_only_mut(expression); + let Expression::ArrayExpression(array) = expression else { + return if inputs.absent(expression) + || get_string_by_literal_expression(expression).is_some() + || matches!(expression, Expression::ObjectExpression(object) if object.properties.is_empty()) + { + vec![] + } else { + vec![reject(expression, IMPORT_TEXT)] + }; + }; + let mut styles = Vec::new(); + for entry in &mut array.elements { + match entry { + ArrayExpressionElement::SpreadElement(spread) => { + styles.push(reject(&spread.argument, IMPORT_TEXT)); + } + ArrayExpressionElement::Elision(_) => {} + _ => { + let element = entry.to_expression_mut(); + let text = match unwrap_syntax_only_mut(element) { + Expression::ObjectExpression(object) => { + flatten_spreads(ast, object); + let mut url = None; + let mut query = None; + let start = styles.len(); + for property in &object.properties { + match property { + ObjectPropertyKind::SpreadProperty(spread) => { + styles.push(reject(&spread.argument, IMPORT_TEXT)); + } + ObjectPropertyKind::ObjectProperty(property) => { + let Some(name) = get_str_by_property_key(&property.key) else { + styles.push(crate::extractor::extract_style_from_expression::unreadable_key(&property.key, false)); + continue; + }; + if matches!(name.as_ref(), "url" | "query") { + let value = &property.value; + let text = get_string_by_literal_expression(value); + if text.is_none() && !inputs.absent(value) { + styles.push(reject(value, IMPORT_TEXT)); + } + if name == "url" { + url = text; + } else { + query = text; + } + } + } + } + } + if styles.len() != start { + continue; + } + url.map(|url| { + let mut text = String::with_capacity( + url.len() + 2 + query.as_ref().map_or(0, |query| query.len() + 1), + ); + text.push('"'); + text.push_str(&url); + text.push('"'); + if let Some(query) = query { + text.push(' '); + text.push_str(&query); + } + text + }) + } + Expression::NumericLiteral(_) => None, + element => match get_string_by_literal_expression(element) { + Some(text) => Some(text.into_owned()), + None if inputs.absent(element) => None, + None => { + styles.push(reject(element, IMPORT_TEXT)); + None + } + }, + }; + if let Some(url) = text { + styles.push(ExtractStyleProp::Static(ExtractStyleValue::Import( + ExtractImport { + file: inputs.file.to_string(), + order: 0, + url, + }, + ))); + } + } + } + } + styles +} diff --git a/libs/extractor/src/extractor/mod.rs b/libs/extractor/src/extractor/mod.rs index 7189dd2a4..d34752a21 100644 --- a/libs/extractor/src/extractor/mod.rs +++ b/libs/extractor/src/extractor/mod.rs @@ -9,7 +9,10 @@ pub(super) mod extract_style_from_jsx; pub(super) mod extract_style_from_member_expression; pub(super) mod extract_style_from_styled; pub(super) mod extract_style_from_stylex; +pub(crate) mod global_inputs; pub(super) mod rule_array_evaluation; +pub(crate) mod selector_params; +pub(crate) mod style_inputs; pub(crate) mod style_order_projection; pub(super) mod styled_capability; pub(super) mod styled_css_slot; diff --git a/libs/extractor/src/extractor/rule_array_evaluation.rs b/libs/extractor/src/extractor/rule_array_evaluation.rs index 5badabd41..b33a4bffa 100644 --- a/libs/extractor/src/extractor/rule_array_evaluation.rs +++ b/libs/extractor/src/extractor/rule_array_evaluation.rs @@ -147,6 +147,9 @@ impl<'a, K: Fn(&Expression<'a>) -> bool> Preparation<'_, '_, 'a, K> { if property.method || property.kind != PropertyKind::Init { return None; } + if property.key.name().is_some_and(|name| name == "params") { + continue; + } // Keep authored keys and literal lookup tables intact: // the extractor still owns their policy and atom choices. self.value(&mut property.value, guard)?; diff --git a/libs/extractor/src/extractor/selector_params.rs b/libs/extractor/src/extractor/selector_params.rs new file mode 100644 index 000000000..03d4278b2 --- /dev/null +++ b/libs/extractor/src/extractor/selector_params.rs @@ -0,0 +1,123 @@ +use super::style_inputs::StyleInputs; +use crate::{ + ExtractStyleProp, + utils::{get_string_by_literal_expression, readable_code, unwrap_syntax_only}, +}; +use css::{add_selector_params, style_selector::StyleSelector}; +use oxc_ast::ast::{ArrayExpressionElement, Expression, ObjectExpression, ObjectPropertyKind}; +use oxc_span::GetSpan; + +pub(crate) const SELECTOR_PARAMS: &str = "selector params must be a statically readable argument list; functional-only selectors require a nonempty argument list; computed params arrays must be dense: holes, null and undefined share a null representation after build-time JSON evaluation and are refused; return a dense array"; + +fn reject<'a>(expression: &Expression<'a>) -> Box> { + Box::new(super::extract_style_from_expression::misplaced( + expression.span().start, + readable_code(expression), + SELECTOR_PARAMS, + )) +} + +pub(crate) fn requires_argument(selector: &StyleSelector) -> bool { + css::selector_params::parameter_name(selector) + .is_some_and(|name| !matches!(name, "host" | "cue" | "cue-region")) +} + +pub(crate) enum ArgumentList { + List(I, W), + Absent(W), + Invalid(W), +} + +/// The AST and evaluated adapters supply scalar text, holes, and refusal witnesses. +/// Requiredness is queried only for an absent or entirely empty list. +pub(crate) fn parse( + list: ArgumentList, + required: impl FnOnce() -> bool, +) -> Result, W> +where + I: IntoIterator, W>>, +{ + let (entries, value) = match list { + ArgumentList::List(entries, value) => (entries, value), + ArgumentList::Absent(value) => return if required() { Err(value) } else { Ok(None) }, + ArgumentList::Invalid(value) => return Err(value), + }; + let mut params = String::new(); + let mut empty_argument = None; + for entry in entries { + let Some((text, witness)) = entry? else { + continue; + }; + if text.is_empty() { + empty_argument = empty_argument.or(Some(witness)); + continue; + } + if !params.is_empty() { + params.push(','); + } + params.push_str(&text); + } + if params.is_empty() { + return if required() { + Err(empty_argument.unwrap_or(value)) + } else { + Ok(None) + }; + } + Ok(Some(params)) +} + +pub(crate) fn parse_expression<'r, 'a>( + value: &'r Expression<'a>, + absent: bool, + required: impl FnOnce() -> bool, +) -> Result, &'r Expression<'a>> { + let list = match unwrap_syntax_only(value) { + Expression::ArrayExpression(array) => ArgumentList::List( + array.elements.iter().map(|element| { + let expression = match element { + ArrayExpressionElement::SpreadElement(spread) => return Err(&spread.argument), + ArrayExpressionElement::Elision(_) => return Ok(None), + _ => element.to_expression(), + }; + get_string_by_literal_expression(expression) + .map(|text| Some((text.into_owned(), expression))) + .ok_or(expression) + }), + value, + ), + _ if absent => ArgumentList::Absent(value), + _ => ArgumentList::Invalid(value), + }; + parse(list, required) +} + +pub(super) fn resolve<'a>( + inputs: StyleInputs<'_, 'a>, + object: &ObjectExpression<'a>, + selector: &Option, +) -> Result, Box>> { + let Some(selector) = selector else { + return Ok(None); + }; + let Some(value) = object + .properties + .iter() + .find_map(|property| match property { + ObjectPropertyKind::ObjectProperty(property) + if property.key.name().is_some_and(|name| name == "params") => + { + Some(&property.value) + } + _ => None, + }) + else { + return Ok(Some(selector.clone())); + }; + let params = parse_expression(value, inputs.absent(value), || requires_argument(selector)) + .map_err(reject)?; + Ok(Some(params.map_or_else( + || selector.clone(), + |params| add_selector_params(selector.clone(), ¶ms), + ))) +} diff --git a/libs/extractor/src/extractor/style_inputs.rs b/libs/extractor/src/extractor/style_inputs.rs new file mode 100644 index 000000000..f90195d27 --- /dev/null +++ b/libs/extractor/src/extractor/style_inputs.rs @@ -0,0 +1,117 @@ +use crate::{ + style_values::StyleValues, + utils::{get_string_by_literal_expression, unwrap_syntax_only}, +}; +use oxc_ast::{ + ast::{Expression, UnaryOperator}, + builder::AstBuilder, +}; + +#[derive(Clone, Copy)] +pub(crate) struct StyleInputs<'s, 'a> { + pub ast: &'s AstBuilder<'a>, + values: Option<&'s StyleValues<'s>>, +} + +impl<'s, 'a> StyleInputs<'s, 'a> { + pub const fn with_values(ast: &'s AstBuilder<'a>, values: &'s StyleValues<'s>) -> Self { + Self { + ast, + values: Some(values), + } + } + + pub fn absent(self, expression: &Expression<'_>) -> bool { + absent(expression, self.values) + } +} + +impl<'s, 'a> From<&'s AstBuilder<'a>> for StyleInputs<'s, 'a> { + fn from(ast: &'s AstBuilder<'a>) -> Self { + Self { ast, values: None } + } +} + +pub(crate) fn absent(expression: &Expression<'_>, values: Option<&StyleValues<'_>>) -> bool { + let mut expression = unwrap_syntax_only(expression); + while let Expression::UnaryExpression(unary) = expression + && unary.operator == UnaryOperator::Void + { + expression = unwrap_syntax_only(&unary.argument); + if get_string_by_literal_expression(expression).is_some() { + return true; + } + } + match expression { + Expression::NullLiteral(_) => true, + Expression::Identifier(_) => { + values.is_some_and(|values| values.is_unbound_undefined(expression)) + } + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::extractor::extract_style_from_expression::{ + LiteralHandling, extract_style_from_expression, + }; + use css::style_selector::StyleSelector; + use oxc_allocator::Allocator; + use oxc_parser::Parser; + use oxc_span::SourceType; + + #[rstest::rstest] + #[case::unproven("undefined", false)] + #[case::null("null", true)] + #[case::pure_void("void 0", true)] + fn optional_params_require_proof_when_only_a_builder_is_supplied( + #[case] params: &str, + #[case] accepted: bool, + ) { + // Given an optional selector without any lexical absence metadata. + let arena = Allocator::default(); + let ast = AstBuilder::new(&arena); + let source = format!("({{params:{params},color:'red'}})"); + let mut expression = Parser::new(&arena, &source, SourceType::tsx()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + // When the builder-only entry extracts the selector's rules. + let result = extract_style_from_expression( + (&ast).into(), + None, + &mut expression, + 0, + &Some(StyleSelector::from("hover")), + LiteralHandling::ExpandResponsiveThemeToken, + ); + // Then literal absence retains the declaration, but spelling alone cannot prove it. + if accepted { + let [crate::ExtractStyleProp::Static(crate::ExtractStyleValue::Static(style))] = + result.styles.as_slice() + else { + panic!("expected one hover declaration: {:?}", result.styles) + }; + assert_eq!( + (style.property.as_str(), style.value.as_str()), + ("color", "red") + ); + assert_eq!( + style.selector.as_ref().map(ToString::to_string), + Some("&:hover".to_string()) + ); + } else { + let mut errors = Vec::new(); + crate::utils::unreadable_styles(&result.styles, false, &mut errors); + assert_eq!( + errors, + vec![( + 9, + "undefined".to_string(), + Some(crate::extractor::selector_params::SELECTOR_PARAMS) + )] + ); + } + } +} diff --git a/libs/extractor/src/extractor/style_order_nullish_tests.rs b/libs/extractor/src/extractor/style_order_nullish_tests.rs index d76df512b..c2f72b5da 100644 --- a/libs/extractor/src/extractor/style_order_nullish_tests.rs +++ b/libs/extractor/src/extractor/style_order_nullish_tests.rs @@ -18,7 +18,7 @@ fn authored_branches_when_nullish_choose_the_matching_color( .unwrap_or_else(|error| panic!("{error:?}")); // When: authored projection reconstructs the actual condition and its two sides. let (_, authored) = extract_with_order( - &ast, + (&ast).into(), Some("color"), &mut expression, 0, diff --git a/libs/extractor/src/extractor/style_order_projection.rs b/libs/extractor/src/extractor/style_order_projection.rs index 8a691e96c..3b13d4954 100644 --- a/libs/extractor/src/extractor/style_order_projection.rs +++ b/libs/extractor/src/extractor/style_order_projection.rs @@ -2,6 +2,7 @@ use super::{ ExtractResult, extract_style_from_expression::{self, LiteralHandling}, + style_inputs::StyleInputs, }; use crate::{ExtractStyleProp, composition::Composition}; use css::style_selector::StyleSelector; @@ -83,7 +84,7 @@ impl<'a> Projection<'a> { pub(super) fn extract( &mut self, - ast: &AstBuilder<'a>, + inputs: StyleInputs<'_, 'a>, name: Option<&str>, expression: &mut Expression<'a>, coordinates: (u8, &Option), @@ -96,7 +97,7 @@ impl<'a> Projection<'a> { Self::default() }; let result = extract_style_from_expression::extract_projected::( - ast, + inputs, name, expression, coordinates, @@ -105,7 +106,7 @@ impl<'a> Projection<'a> { prepare, ); if let Some(children) = &mut self.children { - children.push(child.finish(ast, &result.styles)); + children.push(child.finish(inputs.ast, &result.styles)); } result } @@ -194,7 +195,7 @@ impl<'a> Projection<'a> { } pub(crate) fn extract_with_order<'a>( - ast: &AstBuilder<'a>, + inputs: StyleInputs<'_, 'a>, name: Option<&str>, expression: &mut Expression<'a>, level: u8, @@ -202,7 +203,7 @@ pub(crate) fn extract_with_order<'a>( handling: LiteralHandling, ) -> (ExtractResult<'a>, AuthoredStyles<'a>) { extract_with_order_policy::( - ast, + inputs, name, expression, level, @@ -214,7 +215,7 @@ pub(crate) fn extract_with_order<'a>( /// Retain authored chunks with the caller's dynamic-leaf location and preparation policy. pub(crate) fn extract_with_order_policy<'a, const LOCATE: bool>( - ast: &AstBuilder<'a>, + inputs: StyleInputs<'_, 'a>, name: Option<&str>, expression: &mut Expression<'a>, level: u8, @@ -224,7 +225,7 @@ pub(crate) fn extract_with_order_policy<'a, const LOCATE: bool>( ) -> (ExtractResult<'a>, AuthoredStyles<'a>) { let mut projection = Projection::enabled(); let result = extract_style_from_expression::extract_projected::( - ast, + inputs, name, expression, (level, selector), @@ -232,7 +233,7 @@ pub(crate) fn extract_with_order_policy<'a, const LOCATE: bool>( &mut projection, prepare, ); - let authored = projection.finish(ast, &result.styles); + let authored = projection.finish(inputs.ast, &result.styles); (result, authored) } diff --git a/libs/extractor/src/extractor/style_order_projection_tests.rs b/libs/extractor/src/extractor/style_order_projection_tests.rs index cf80fe985..82c982cc8 100644 --- a/libs/extractor/src/extractor/style_order_projection_tests.rs +++ b/libs/extractor/src/extractor/style_order_projection_tests.rs @@ -26,7 +26,7 @@ fn sequential_chunks_keep_previous_atom_when_later_branch_omits_key( .map_err(|error| format!("{error:?}"))?; // When: the same traversal produces legacy emission and sequential authored chunks. let (_, authored) = extract_with_order( - &ast, + (&ast).into(), None, &mut expression, 0, @@ -85,7 +85,7 @@ fn located_dynamic_leaf_keeps_authored_conditional_layer_winners( let mut prepared = 0; // When: the combined entry locates leaves while retaining authored chunks. let (result, authored) = extract_with_order_policy::( - &ast, + (&ast).into(), None, &mut expression, 0, @@ -180,7 +180,7 @@ fn preparation_reaches_nested_shorthand_member_siblings_with_original_binding( let mut prepared = Vec::new(); // When: real binding normalization runs at the combined seam's prepare hook. let (_, authored) = extract_with_order_policy::( - &ast, + StyleInputs::with_values(&ast, &values), None, &mut statement.expression, 0, diff --git a/libs/extractor/src/extractor/styled_extraction_edge_tests.rs b/libs/extractor/src/extractor/styled_extraction_edge_tests.rs index 2e4ec454a..819e15ea6 100644 --- a/libs/extractor/src/extractor/styled_extraction_edge_tests.rs +++ b/libs/extractor/src/extractor/styled_extraction_edge_tests.rs @@ -76,7 +76,7 @@ fn raw_styled_spread_when_received_by_extractor_keeps_reads_or_composition_error let inline_css = FxHashMap::default(); // When let output = extract_style_from_styled( - &ast, + crate::extractor::style_inputs::StyleInputs::with_values(&ast, &values), &mut expression, Naming::default(), StyledBindings { @@ -149,7 +149,7 @@ fn raw_styled_spread_when_array_contains_written_objects_extracts_exact_value() let inline_css = FxHashMap::default(); // When let output = extract_style_from_styled( - &ast, + crate::extractor::style_inputs::StyleInputs::with_values(&ast, &values), &mut expression, Naming::default(), StyledBindings { diff --git a/libs/extractor/src/extractor/styled_props_leaves.rs b/libs/extractor/src/extractor/styled_props_leaves.rs index 1fab80cab..57f9010ac 100644 --- a/libs/extractor/src/extractor/styled_props_leaves.rs +++ b/libs/extractor/src/extractor/styled_props_leaves.rs @@ -48,6 +48,9 @@ impl<'a> Leaves<'_, '_, 'a> { return Err(PropsRulesError::Rules); } let key = get_str_by_property_key(&property.key).ok_or(PropsRulesError::Rules)?; + if key == "params" { + continue; + } if let Expression::ObjectExpression(inner) = unwrap_syntax_only_mut(&mut property.value) { self.object_inner(inner)?; diff --git a/libs/extractor/src/extractor/styled_rule_part.rs b/libs/extractor/src/extractor/styled_rule_part.rs index fed63daad..3eff26d26 100644 --- a/libs/extractor/src/extractor/styled_rule_part.rs +++ b/libs/extractor/src/extractor/styled_rule_part.rs @@ -44,7 +44,12 @@ pub(super) fn extract<'a>( }; } let (mut result, authored) = extract_with_order( - ast, + bindings.map_or_else( + || ast.into(), + |bindings| { + crate::extractor::style_inputs::StyleInputs::with_values(ast, bindings.values) + }, + ), None, rules, 0, diff --git a/libs/extractor/src/extractor/styled_statement_tests.rs b/libs/extractor/src/extractor/styled_statement_tests.rs index 2f51e1820..2057b5b6f 100644 --- a/libs/extractor/src/extractor/styled_statement_tests.rs +++ b/libs/extractor/src/extractor/styled_statement_tests.rs @@ -60,7 +60,7 @@ fn opaque_statement_callback_when_top_level_is_called_with_props_and_false_becom let inline_css = FxHashMap::default(); // When let output = extract_style_from_styled( - &ast, + crate::extractor::style_inputs::StyleInputs::with_values(&ast, &values), &mut expression, Naming::default(), StyledBindings { @@ -125,7 +125,7 @@ fn uncurried_styled_when_second_argument_is_callback_stays_object_only() let inline_css = FxHashMap::default(); // When let output = extract_style_from_styled( - &ast, + crate::extractor::style_inputs::StyleInputs::with_values(&ast, &values), &mut expression, Naming::default(), StyledBindings { diff --git a/libs/extractor/src/extractor/styled_template_mixin.rs b/libs/extractor/src/extractor/styled_template_mixin.rs index b9366c484..d9d2c2788 100644 --- a/libs/extractor/src/extractor/styled_template_mixin.rs +++ b/libs/extractor/src/extractor/styled_template_mixin.rs @@ -109,7 +109,7 @@ impl<'a> MixinReader<'_, 'a, '_> { } Expression::ObjectExpression(_) => { let (_, authored) = extract_with_order( - ast, + crate::extractor::style_inputs::StyleInputs::with_values(ast, bindings.values), None, rules, 0, diff --git a/libs/extractor/src/extractor/template_planning_tests.rs b/libs/extractor/src/extractor/template_planning_tests.rs index 32041c022..8cff7658b 100644 --- a/libs/extractor/src/extractor/template_planning_tests.rs +++ b/libs/extractor/src/extractor/template_planning_tests.rs @@ -45,7 +45,7 @@ fn projected_selectors_when_key_is_dynamic_keep_the_refusal_in_both_views() { .unwrap_or_else(|error| panic!("{error:?}")); // When let (raw, authored) = crate::extractor::style_order_projection::extract_with_order( - &ast, Some("selectors"), &mut expression, 0, &None, + (&ast).into(), Some("selectors"), &mut expression, 0, &None, crate::extractor::extract_style_from_expression::LiteralHandling::ExpandResponsiveThemeToken, ); // Then diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index 7a08e1f29..5b45a1c5d 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -1646,7 +1646,7 @@ impl<'p, 'a> ModuleScope<'p, 'a> { { let (result, mut authored) = crate::extractor::style_order_projection::extract_with_order( - &builder, + (&builder).into(), None, &mut rules, 0, diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index cf1b89d95..7e87e374b 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -128,6 +128,8 @@ mod tailwind_theme; #[doc(hidden)] pub mod test_state; #[cfg(test)] +mod unresolved_global_inputs_tests; +#[cfg(test)] mod utf16_boundary_tests; mod util_type; mod utils; @@ -833,10 +835,14 @@ fn extract_source( // Run the code a value computes, or tell rules the module computes from a // class it composes if (!visitor.errors.is_empty() || visitor.composes_unknown) - && !visitor - .errors - .iter() - .any(|(_, error)| dead_properties::terminal_error(error)) + && !visitor.errors.iter().any(|(_, error)| { + dead_properties::terminal_error(error) + || error + .rsplit_once(" at build time: ") + .is_some_and(|(_, requirement)| { + requirement == extractor::global_inputs::FONT_TEMPLATE_INPUT + }) + }) && values_run.is_none() && !utils::is_vanilla_extract_file(filename) && let Some((computed, value_edits, read)) = build_time_values::evaluate_located( @@ -10540,79 +10546,32 @@ keyframes({ // empty reset_class_map(); reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( - extract( - "test.tsx", - r"import {Box} from '@devup-ui/core' - - ", - ExtractOption { - package: "@devup-ui/core".to_string(), - css_dir: "@devup-ui/core".to_string(), - single_css: false, - import_main_css: false, - import_aliases: HashMap::new() - } - ) - .unwrap() - )); + crate::unresolved_global_inputs_tests::rejects_at( + "import {Box} from '@devup-ui/react';\nexport const view=;", + "[]", + false, + ); reset_class_map(); reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( - extract( - "test.tsx", - r#"import {Box} from '@devup-ui/core' - - "#, - ExtractOption { - package: "@devup-ui/core".to_string(), - css_dir: "@devup-ui/core".to_string(), - single_css: false, - import_main_css: false, - import_aliases: HashMap::new() - } - ) - .unwrap() - )); + crate::unresolved_global_inputs_tests::rejects_at( + "import {Box} from '@devup-ui/react';\nexport const view=;", + "\"\"", + false, + ); reset_class_map(); reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( - extract( - "test.tsx", - r#"import {Box} from '@devup-ui/core' - - "#, - ExtractOption { - package: "@devup-ui/core".to_string(), - css_dir: "@devup-ui/core".to_string(), - single_css: false, - import_main_css: false, - import_aliases: HashMap::new() - } - ) - .unwrap() - )); + crate::unresolved_global_inputs_tests::rejects_at( + "import {Box} from '@devup-ui/react';\nexport const view=;", + "\"\"", + false, + ); reset_class_map(); reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( - extract( - "test.tsx", - r#"import {Box} from '@devup-ui/core' + crate::unresolved_global_inputs_tests::rejects_at( + r#"import {Box} from '@devup-ui/react' "#, - ExtractOption { - package: "@devup-ui/core".to_string(), - css_dir: "@devup-ui/core".to_string(), - single_css: false, - import_main_css: false, - import_aliases: HashMap::new() - } - ) - .unwrap() - )); + "variable", + false, + ); reset_class_map(); reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( - extract( - "test.tsx", - r#"import {globalCss} from '@devup-ui/core' + crate::unresolved_global_inputs_tests::rejects_at( + r#"import {globalCss} from '@devup-ui/react' globalCss({ "_is": { params: ["test", variable], @@ -10647,23 +10597,18 @@ keyframes({ } }) "#, - ExtractOption { - package: "@devup-ui/core".to_string(), - css_dir: "@devup-ui/core".to_string(), - single_css: false, - import_main_css: false, - import_aliases: HashMap::new() - } - ) - .unwrap() - )); + "variable", + false, + ); reset_class_map(); reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( - extract( - "test.tsx", - r#"import {css} from '@devup-ui/core' + assert_debug_snapshot!( + "extract_advenced_selector-8", + ToBTreeSet::from( + extract( + "test.tsx", + r#"import {css} from '@devup-ui/core' css({ "_is": { params: ["test"], @@ -10671,16 +10616,17 @@ keyframes({ } }) "#, - ExtractOption { - package: "@devup-ui/core".to_string(), - css_dir: "@devup-ui/core".to_string(), - single_css: false, - import_main_css: false, - import_aliases: HashMap::new() - } + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false, + import_aliases: HashMap::new() + } + ) + .unwrap() ) - .unwrap() - )); + ); } #[test] @@ -21501,7 +21447,7 @@ export const j = css(...{ bg: 'red' });", ) .unwrap_err() .to_string(), - "test.tsx:2:41: `` cannot use `x` at build time: its values must be literals, theme tokens or constants" + "test.tsx:2:49: `` cannot use `x` at build time: its values must be literals, theme tokens or constants" ); } diff --git a/libs/extractor/src/own_css_bindings.rs b/libs/extractor/src/own_css_bindings.rs index 24e9d8f31..df2ebcfc4 100644 --- a/libs/extractor/src/own_css_bindings.rs +++ b/libs/extractor/src/own_css_bindings.rs @@ -53,7 +53,7 @@ impl<'a> DevupVisitor<'a> { .map(|e| e.clone_in_with_semantic_ids(self.ast.allocator())) .collect(); for expression in &mut slots { - self.style_values.read_in(&self.ast, expression); + self.style_values.read_in_class_names(&self.ast, expression); } for node in &mut plan.nodes { match node { diff --git a/libs/extractor/src/own_css_context.rs b/libs/extractor/src/own_css_context.rs index 49e8eea20..4964ae971 100644 --- a/libs/extractor/src/own_css_context.rs +++ b/libs/extractor/src/own_css_context.rs @@ -21,13 +21,15 @@ pub(crate) enum Frame { struct Link { parent: Option, - frame: Frame, + frame: Option, selected: bool, + target: std::cell::OnceCell>, } #[derive(Default)] pub(crate) struct Contexts { links: Vec, + targets: selector::Targets, } impl Contexts { @@ -36,8 +38,9 @@ impl Contexts { let id = self.links.len(); self.links.push(Link { parent, - frame, + frame: Some(frame), selected, + target: std::cell::OnceCell::new(), }); id } @@ -46,6 +49,42 @@ impl Contexts { path.is_some_and(|id| self.links[id].selected) } + pub fn uncertain(&mut self, path: Option) -> usize { + let id = self.links.len(); + self.links.push(Link { + parent: path, + frame: None, + selected: self.selected(path), + target: std::cell::OnceCell::from(Some(self.targets.uncertain())), + }); + id + } + + pub fn requires_argument(&mut self, path: Option) -> bool { + let mut pending = Vec::new(); + let mut cursor = path; + let mut target = None; + while let Some(id) = cursor { + if let Some(cached) = self.links[id].target.get().copied() { + target = cached; + break; + } + pending.push(id); + cursor = self.links[id].parent; + } + for id in pending.into_iter().rev() { + for frame in self.links[id].frame.as_slice() { + target = match frame { + Frame::At(..) | Frame::Layer(_) => target, + Frame::Selector(template) => Some(self.targets.nest(target, template)), + Frame::Params(_) => self.targets.consume(target), + }; + } + self.links[id].target = std::cell::OnceCell::from(target); + } + self.targets.required(target) + } + pub fn materialize( &self, path: Option, @@ -53,7 +92,7 @@ impl Contexts { let mut links = Vec::new(); let mut cursor = path; while let Some(id) = cursor { - links.push(&self.links[id].frame); + links.extend(self.links[id].frame.as_ref()); cursor = self.links[id].parent; } let mut selector = selector::SelectorText::default(); diff --git a/libs/extractor/src/own_css_lower.rs b/libs/extractor/src/own_css_lower.rs index 55e8d5954..e24441fe7 100644 --- a/libs/extractor/src/own_css_lower.rs +++ b/libs/extractor/src/own_css_lower.rs @@ -9,12 +9,15 @@ use crate::{ }, own_css_context::{Contexts, Frame, under}, own_css_plan::{Node, Plan}, - utils::{RESPONSIVE_ARRAY, SELECTOR_NAME, get_string_by_literal_expression}, + utils::{RESPONSIVE_ARRAY, SELECTOR_NAME}, }; use css::{disassemble_property, style_selector::split_selector_list}; use oxc_allocator::{CloneIn, GetAllocator}; use oxc_ast::{ast::Expression, builder::AstBuilder}; +#[path = "own_css_params.rs"] +pub(crate) mod params; + struct Cursor { id: usize, name: Option, @@ -137,19 +140,28 @@ impl<'a> RuleCompiler<'_, 'a> { if !root_object && contexts.selected(path) && let Some(property) = properties.iter().find(|p| p.name == "params") - && let Node::Array(items) = &plan.nodes[property.value] { - let params: Vec<_> = items - .iter() - .filter_map(|id| match plan.nodes[*id] { - Node::Scalar(slot) => { - get_string_by_literal_expression(&slots[slot]) - .filter(|s| !s.is_empty()) - } - Node::Array(_) | Node::Object(_) => None, - }) - .collect(); - path = Some(contexts.push(path, Frame::Params(params.join(",")))); + match (params::Params { + plan, + value: property.value, + }) + .resolve( + |slot| params::literal(&slots[slot]), + || contexts.requires_argument(path), + ) { + Ok(Some(params)) => { + path = Some(contexts.push(path, Frame::Params(params))); + } + Ok(None) => {} + Err(()) => { + result.styles.push(misplaced( + 0, + "params".into(), + crate::extractor::selector_params::SELECTOR_PARAMS, + )); + return result; + } + } } let properties: Vec<_> = if root_object { properties.iter().collect() @@ -218,7 +230,7 @@ impl<'a> RuleCompiler<'_, 'a> { }; let mut expression = slots[*slot].clone_in_with_semantic_ids(ast.allocator()); let mut styles = extract_style_from_expression( - ast, + ast.into(), name.as_deref(), &mut expression, level, diff --git a/libs/extractor/src/own_css_params.rs b/libs/extractor/src/own_css_params.rs new file mode 100644 index 000000000..4dd2d02e0 --- /dev/null +++ b/libs/extractor/src/own_css_params.rs @@ -0,0 +1,53 @@ +use crate::{ + extractor::selector_params::{ArgumentList, parse}, + own_css_plan::{Node, ParamEntry, Plan}, +}; + +#[derive(Debug, Clone)] +pub(crate) enum Scalar { + Text(String), + Absent, + Invalid, +} + +pub(crate) struct Params<'p> { + pub plan: &'p Plan, + pub value: usize, +} + +impl Params<'_> { + pub(crate) fn resolve( + &self, + mut scalar: impl FnMut(usize) -> Scalar, + required: impl FnOnce() -> bool, + ) -> Result, ()> { + let Self { plan, value } = *self; + let list = match &plan.nodes[value] { + Node::Array(items) => ArgumentList::List( + items.iter().map(|&id| match plan.param_entry(id) { + ParamEntry::Hole => Ok(None), + ParamEntry::Invalid => Err(()), + ParamEntry::Scalar(slot) => match scalar(slot) { + Scalar::Text(text) => Ok(Some((text, ()))), + Scalar::Absent | Scalar::Invalid => Err(()), + }, + }), + (), + ), + Node::Scalar(slot) if matches!(scalar(*slot), Scalar::Absent) => { + ArgumentList::Absent(()) + } + Node::Scalar(_) | Node::Object(_) => ArgumentList::Invalid(()), + }; + parse(list, required) + } +} + +pub(crate) fn literal(expression: &oxc_ast::ast::Expression<'_>) -> Scalar { + if crate::extractor::style_inputs::absent(expression, None) { + Scalar::Absent + } else { + crate::utils::get_string_by_literal_expression(expression) + .map_or(Scalar::Invalid, |text| Scalar::Text(text.into_owned())) + } +} diff --git a/libs/extractor/src/own_css_plan.rs b/libs/extractor/src/own_css_plan.rs index 62797eac8..eb3bb49fa 100644 --- a/libs/extractor/src/own_css_plan.rs +++ b/libs/extractor/src/own_css_plan.rs @@ -22,9 +22,28 @@ pub(crate) struct Plan { pub composing: bool, pub template: Option>, pub origin: Option<(String, String)>, + pub holes: rustc_hash::FxHashSet, + pub param_values: rustc_hash::FxHashMap, + pub has_params: bool, +} + +pub(crate) enum ParamEntry { + Hole, + Scalar(usize), + Invalid, } impl Plan { + pub fn param_entry(&self, id: usize) -> ParamEntry { + if self.holes.contains(&id) { + return ParamEntry::Hole; + } + match self.nodes[id] { + Node::Scalar(slot) => ParamEntry::Scalar(slot), + Node::Array(_) | Node::Object(_) => ParamEntry::Invalid, + } + } + pub fn needs_bridge(&self) -> bool { self.deep || (self.template.is_some() @@ -60,8 +79,13 @@ impl Plan { Node::Array(items) => { code.push('['); pending.push(Step::Text("]")); + if items.last().is_some_and(|id| self.holes.contains(id)) { + pending.push(Step::Text(",")); + } for (index, &item) in items.iter().enumerate().rev() { - pending.push(Step::Node(item)); + if !self.holes.contains(&item) { + pending.push(Step::Node(item)); + } if index > 0 { pending.push(Step::Text(",")); } diff --git a/libs/extractor/src/own_css_selector.rs b/libs/extractor/src/own_css_selector.rs index bdfe38d97..d1361e38d 100644 --- a/libs/extractor/src/own_css_selector.rs +++ b/libs/extractor/src/own_css_selector.rs @@ -130,3 +130,184 @@ fn positions(value: &str, depth: &mut usize) -> Vec { } found } + +#[derive(Clone, Default)] +struct Branch { + targets: Option, + trailing: Option<(String, bool)>, +} + +enum TargetsNode { + Item(Option), + Join(usize, usize, usize), + Unknown(Option), +} + +#[derive(Default)] +pub(crate) struct Targets { + nodes: Vec, + tails: Vec>>, + required: Vec>, + branches: Vec>, +} + +impl Targets { + fn item(&mut self, required: bool, parent: Option) -> usize { + let id = self.nodes.len(); + self.nodes.push(TargetsNode::Item(parent)); + self.required.push(Some(required)); + id + } + + fn join(&mut self, left: Option, right: Option) -> Option { + match (left, right) { + (None, other) | (other, None) => other, + (Some(left), Some(right)) => { + let id = self.nodes.len(); + self.nodes + .push(TargetsNode::Join(left, right, self.tails.len())); + self.tails.push(std::cell::OnceCell::new()); + self.required.push(self.required[right]); + Some(id) + } + } + } + + fn pop(&mut self, root: Option) -> Option { + let mut pending = Vec::new(); + let mut cursor = root?; + let mut tail = loop { + match self.nodes[cursor] { + TargetsNode::Item(tail) | TargetsNode::Unknown(tail) => break tail, + TargetsNode::Join(left, right, cache) => { + if let Some(tail) = self.tails[cache].get().copied() { + break tail; + } + pending.push((cache, left)); + cursor = right; + } + } + }; + for (id, left) in pending.into_iter().rev() { + tail = self.join(Some(left), tail); + self.tails[id] = std::cell::OnceCell::from(tail); + } + tail + } + + pub fn required(&self, state: Option) -> bool { + state.is_some_and(|state| { + self.branches[state].iter().any(|branch| { + branch + .targets + .is_some_and(|id| self.required[id] == Some(true)) + }) + }) + } + + pub fn consume(&mut self, state: Option) -> Option { + let mut branches = self.branches[state?].clone(); + for branch in &mut branches { + branch.targets = self.pop(branch.targets); + branch.trailing = None; + } + Some(self.save(branches)) + } + + pub fn uncertain(&mut self) -> usize { + let id = self.nodes.len(); + self.nodes.push(TargetsNode::Unknown(Some(id))); + self.required.push(None); + self.save(vec![Branch { + targets: Some(id), + trailing: None, + }]) + } + + fn save(&mut self, branches: Vec) -> usize { + let id = self.branches.len(); + self.branches.push(branches); + id + } + + pub fn nest(&mut self, state: Option, template: &str) -> usize { + let parents = state.map_or_else(|| vec![Branch::default()], |id| self.branches[id].clone()); + let mut branches = Vec::new(); + for parent in parents { + for part in split_selector_list(template) { + let mut branch = Branch::default(); + let bytes = part.as_bytes(); + let mut depth = 0usize; + let mut skip = 0; + for (index, byte) in css::quoted::unquoted_bytes(part) { + if index < skip { + continue; + } + match byte { + b'(' | b'[' => depth += 1, + b')' | b']' => depth = depth.saturating_sub(1), + b'&' if depth == 0 => { + let mut inserted = parent.targets; + let mut trailing = parent.trailing.clone(); + if let Some((name, target)) = &mut trailing { + let end = part[index + 1..] + .find(|c: char| !c.is_ascii_alphanumeric() && c != '-') + .map_or(part.len(), |length| index + 1 + length); + if end > index + 1 || bytes.get(end) == Some(&b'(') { + if *target { + inserted = self.pop(inserted); + } + name.push_str(&part[index + 1..end]); + *target = false; + if bytes.get(end) != Some(&b'(') + && let Some(required) = target_kind(name) + { + inserted = Some(self.item(required, inserted)); + *target = true; + } + skip = end; + } + if end < part.len() { + trailing = None; + } + } + branch.targets = self.join(branch.targets, inserted); + branch.trailing = if skip == part.len() || index + 1 == part.len() { + trailing + } else { + None + }; + } + b':' if depth == 0 => { + let start = + index + 1 + usize::from(bytes.get(index + 1) == Some(&b':')); + let end = part[start..] + .find(|c: char| !c.is_ascii_alphanumeric() && c != '-') + .map_or(part.len(), |length| start + length); + let required = (bytes.get(end) != Some(&b'(')) + .then(|| target_kind(&part[start..end])) + .flatten(); + if let Some(required) = required { + branch.targets = Some(self.item(required, branch.targets)); + } + if end == part.len() { + branch.trailing = + Some((part[start..end].to_string(), required.is_some())); + } + skip = end; + } + _ => {} + } + } + branches.push(branch); + } + } + self.save(branches) + } +} + +fn target_kind(name: &str) -> Option { + let selector = StyleSelector::Selector(format!(":{name}")); + css::selector_params::parameter_name(&selector) + .map(|name| !matches!(name, "host" | "cue" | "cue-region")) +} diff --git a/libs/extractor/src/responsive_selector_tests.rs b/libs/extractor/src/responsive_selector_tests.rs index 17241b7b9..c46a1cb69 100644 --- a/libs/extractor/src/responsive_selector_tests.rs +++ b/libs/extractor/src/responsive_selector_tests.rs @@ -270,7 +270,7 @@ fn nested_array_diagnostic_retains_its_exact_span() { }; // When let result = extract_style_from_expression( - &builder, + (&builder).into(), Some("padding"), &mut statement.expression, 0, diff --git a/libs/extractor/src/style_values.rs b/libs/extractor/src/style_values.rs index 2997321f1..e86ff09ae 100644 --- a/libs/extractor/src/style_values.rs +++ b/libs/extractor/src/style_values.rs @@ -2,6 +2,7 @@ //! `const fadeIn = keyframes({ ... })`: the build gives it a name, so styles //! reading the binding read that name +mod absence; use std::rc::Rc; use oxc_allocator::{CloneIn, FromIn, GetAllocator}; @@ -45,6 +46,7 @@ pub struct StyleValues<'a> { texts: FxHashMap, rules: FxHashMap>, calls: FxHashMap>, + absent_references: FxHashSet, } impl<'a> StyleValues<'a> { @@ -59,6 +61,7 @@ impl<'a> StyleValues<'a> { texts: FxHashMap::default(), rules: FxHashMap::default(), calls: FxHashMap::default(), + absent_references: FxHashSet::default(), } } diff --git a/libs/extractor/src/style_values/absence.rs b/libs/extractor/src/style_values/absence.rs new file mode 100644 index 000000000..779d4f9a8 --- /dev/null +++ b/libs/extractor/src/style_values/absence.rs @@ -0,0 +1,180 @@ +use super::StyleValues; +use crate::utils::unwrap_syntax_only; +use oxc_ast::ast::{Expression, IdentifierReference, Program}; +use oxc_ast_visit::Visit; +use oxc_semantic::{Scoping, SemanticBuilder}; +use oxc_span::Span; +use rustc_hash::FxHashSet; + +impl StyleValues<'_> { + pub(crate) fn absence_proof(&self) -> Self { + Self { + scoping: self.scoping.clone(), + absent_references: self.absent_references.clone(), + ..Self::default() + } + } + + /// Prove that an authored `undefined` reads the unbound global, not a binding. + pub(crate) fn is_unbound_undefined(&self, expression: &Expression<'_>) -> bool { + let Expression::Identifier(identifier) = unwrap_syntax_only(expression) else { + return false; + }; + if identifier.name != "undefined" { + return false; + } + self.absent_references.contains(&identifier.span) + || match (&self.scoping, identifier.reference_id.get()) { + (Some(scoping), Some(reference)) => { + scoping.get_reference(reference).symbol_id().is_none() + } + _ => false, + } + } + + /// Keep semantic proof tied to the authored span when extraction clones clear IDs. + pub(crate) fn record_absence(&mut self, program: &Program<'_>) { + if !program.source_text.contains("undefined") { + return; + } + let temporary; + let scoping: &Scoping = if let Some(scoping) = &self.scoping { + scoping + } else { + temporary = SemanticBuilder::new() + .build(program) + .semantic + .into_scoping(); + &temporary + }; + let mut references = AbsenceReferences { + scoping, + spans: FxHashSet::default(), + }; + references.visit_program(program); + self.absent_references = references.spans; + } +} + +struct AbsenceReferences<'s> { + scoping: &'s Scoping, + spans: FxHashSet, +} + +impl<'a> Visit<'a> for AbsenceReferences<'_> { + fn visit_identifier_reference(&mut self, identifier: &IdentifierReference<'a>) { + if identifier.name == "undefined" + && let Some(reference) = identifier.reference_id.get() + && self.scoping.get_reference(reference).symbol_id().is_none() + { + self.spans.insert(identifier.span); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use oxc_allocator::Allocator; + use oxc_ast::ast::Statement; + use oxc_parser::Parser; + use oxc_semantic::SemanticBuilder; + use oxc_span::SourceType; + use rstest::rstest; + + #[rstest] + #[case("undefined;", true)] + #[case("let undefined = runtime; undefined;", false)] + fn absence_survives_cleared_ids_when_scoping_is_not_supplied( + #[case] source: &str, + #[case] absent: bool, + ) { + // Given authored references without a caller-supplied semantic analysis. + let arena = Allocator::default(); + let parsed = Parser::new(&arena, source, SourceType::tsx()).parse(); + let mut values = StyleValues::default(); + values.record_absence(&parsed.program); + let Statement::ExpressionStatement(statement) = + &parsed.program.body[parsed.program.body.len() - 1] + else { + panic!("expected expression"); + }; + let Expression::Identifier(identifier) = &statement.expression else { + panic!("expected identifier"); + }; + identifier.reference_id.set(None); + // When extraction consults the authored-span proof after IDs are cleared. + let result = values.is_unbound_undefined(&statement.expression); + // Then only the unbound global is absent, never a shadowing binding. + assert_eq!(result, absent); + } + + #[rstest] + #[case("undefined;", true)] + #[case("let undefined = runtime; undefined;", false)] + #[case("other;", false)] + #[case("null;", false)] + fn absence_requires_an_unbound_undefined_reference(#[case] source: &str, #[case] absent: bool) { + let arena = Allocator::default(); + let parsed = Parser::new(&arena, source, SourceType::tsx()).parse(); + let scoping = SemanticBuilder::new() + .build(&parsed.program) + .semantic + .into_scoping(); + let values = StyleValues::new(std::rc::Rc::new(scoping)); + let Some(Statement::ExpressionStatement(statement)) = parsed.program.body.last() else { + panic!("expected expression"); + }; + let result = values.is_unbound_undefined(&statement.expression); + assert_eq!(result, absent); + } + + #[rstest] + #[case(false)] + #[case(true)] + fn missing_metadata_never_proves_absence(#[case] with_scoping: bool) { + let arena = Allocator::default(); + let parsed = Parser::new(&arena, "undefined;", SourceType::tsx()).parse(); + let values = if with_scoping { + StyleValues::new(std::rc::Rc::new( + SemanticBuilder::new() + .build(&parsed.program) + .semantic + .into_scoping(), + )) + } else { + StyleValues::default() + }; + let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else { + panic!("expected expression"); + }; + let Expression::Identifier(identifier) = &statement.expression else { + panic!("expected identifier"); + }; + identifier.reference_id.set(None); + assert!(!values.is_unbound_undefined(&statement.expression)); + } + + #[test] + fn recording_requires_reference_metadata_when_scoping_is_supplied() { + // Given real scoping whose authored reference ID was cleared before recording. + let arena = Allocator::default(); + let parsed = Parser::new(&arena, "undefined;", SourceType::tsx()).parse(); + let scoping = SemanticBuilder::new() + .build(&parsed.program) + .semantic + .into_scoping(); + let mut values = StyleValues::new(std::rc::Rc::new(scoping)); + let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else { + panic!("expected expression"); + }; + let Expression::Identifier(identifier) = &statement.expression else { + panic!("expected identifier"); + }; + identifier.reference_id.set(None); + // When recording sees the spelling but has no reference metadata to prove it. + values.record_absence(&parsed.program); + // Then neither a recorded span nor the retained scoping fabricates absence. + assert!(!values.is_unbound_undefined(&statement.expression)); + } +} diff --git a/libs/extractor/src/stylesheet_coverage_contract_tests.rs b/libs/extractor/src/stylesheet_coverage_contract_tests.rs index 2d646e640..1216e8e44 100644 --- a/libs/extractor/src/stylesheet_coverage_contract_tests.rs +++ b/libs/extractor/src/stylesheet_coverage_contract_tests.rs @@ -37,19 +37,213 @@ fn compile(body: &str) -> Result { .map_err(|error| error.to_string()) } +#[test] +#[serial] +fn w82g_serialization_output_matrix() { + // Given + let _state = crate::test_state::TestStateGuard::new(); + let contexts = [ + ( + "jsx-responsive", + "/matrix.tsx", + "export const view=;", + false, + ), + ( + "jsx-call", + "/matrix.tsx", + "export const view=jsx(Box,{p:values()});", + false, + ), + ( + "css-object", + "/matrix.tsx", + "export const card=css({p:values()});", + false, + ), + ( + "styled", + "/matrix.tsx", + "export const Card=styled('div',{p:values()});", + false, + ), + ( + "keyframes", + "/matrix.tsx", + "export const animation=keyframes({from:{p:values()}});", + false, + ), + ( + "stylesheet-values", + "/matrix.css.ts", + "export const card=css({p:values()});", + false, + ), + ( + "stylesheet-export", + "/matrix.css.ts", + concat!( + "export const value=values();export const card=css({", + "p:2});" + ), + false, + ), + ( + "stylesheet-params", + "/matrix.css.ts", + "export const card=css({_is:{params:values(),p:2}});", + true, + ), + ( + "ordinary-params", + "/matrix.tsx", + "export const card=css({_is:{params:values(),p:2}});", + true, + ), + ]; + for (name, filename, body, params) in contexts { + for (kind, middle) in [("hole", ""), ("undefined", "undefined"), ("null", "null")] { + for single_css in [false, true] { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + let array = if params { + format!("['a',{middle},'b']") + } else { + format!("[1,{middle},3]") + }; + let source = format!( + "import{{Box,css,styled,keyframes}}from'@devup-ui/react';import{{jsx}}from'react/jsx-runtime';function values(){{return {array}}}{body}" + ); + let option = crate::ExtractOption { + single_css, + ..crate::ExtractOption::default() + }; + let reaches_literal = crate::build_time_values::has_build_time_values( + filename, &source, &option, None, + ); + // When + let result = extract_without_source_map(filename, &source, option); + // Then: every recorded row is also a permanent behavioral regression. + if params && (kind != "hole" || filename == "/matrix.tsx") { + let error = result + .as_ref() + .err() + .unwrap_or_else(|| panic!("{name}/{kind}: invalid entry accepted")); + assert!( + error + .to_string() + .contains(crate::extractor::selector_params::SELECTOR_PARAMS), + "{error}" + ); + } else { + let output = result + .as_ref() + .unwrap_or_else(|error| panic!("{name}/{kind}: {error}")); + assert!(!output.styles.is_empty(), "{name}/{kind}"); + if params { + assert!(output.styles.iter().any(|style| matches!(style, + ExtractStyleValue::Static(style) if style.selector.as_ref().is_some_and(|selector| selector.to_string() == "&:is(a,b)") + ))); + } + } + let outcome = match result { + Ok(output) => { + serde_json::json!({"code": output.code, "styles": format!("{:?}", output.styles)}) + } + Err(error) => serde_json::json!({"error": error.to_string()}), + }; + eprintln!( + "W82G_MATRIX {}", + serde_json::json!({"context": name, "kind": kind, "singleCss": single_css, "params": params, "source": source, "filename": filename, "reachesLiteral": reaches_literal, "outcome": outcome}) + ); + } + } + } +} + #[rstest::rstest] -#[case("css(base,{props:data,styleOrder:3,p:2})")] -#[case("css('external',{props:data,p:2})")] -#[case("css(['external',null,false,undefined,{props:data,p:2}])")] -#[case("css({props:data,_hover:{as:'section',p:2}})")] -#[case("css({props:data,_nthChild:{params:[{},[],'2n'],p:2}})")] -#[case(concat!("css({props:data,':hover':{", "p:2},'[data-on]':{", "w:3},'> a':{", "h:4}})"))] -#[case(concat!("css({props:data,'@media (min-width:1px)':{'@media not print and (color)':{", "p:2}}})"))] +#[case("css(base,{props:data,styleOrder:3,p:2})", false)] +#[case("css('external',{props:data,p:2})", false)] +#[case("css(['external',null,false,undefined,{props:data,p:2}])", false)] +#[case("css({props:data,_hover:{as:'section',p:2}})", false)] +#[case("css({props:data,_hover:{params:[],p:2}})", false)] +#[case("css({props:data,_nthChild:{params:['2n'],p:2}})", false)] +#[case("css({props:data,_host:{params:['','x'],p:2}})", false)] +#[case("css({props:data,_hover:{params:['x'],p:2}})", false)] +#[case("css({props:data,_hover:{params:null,p:2}})", false)] +#[case("css({props:data,_hover:{params:undefined,p:2}})", false)] +#[case("css({props:data,_hover:{params:void 0,p:2}})", false)] +#[case(concat!("css({props:data,_nthChild:{", "p:2}})"), false)] +#[case("css({props:data,_is:{params:[base],p:2}})", false)] +#[case("css({props:data,_is:{params:[`.${base}`],p:2}})", false)] +#[case( + "css({props:data,_nthChild:{params:(()=>['2n'])(),_hover:{params:[],p:2}}})", + false +)] +#[case("css({props:data,_is:{params:(()=>['a',,'b'])(),p:2}})", true)] +#[case( + "css({props:data,_groupNthChild:{params:['2n'],_hover:{params:[],p:2}}})", + false +)] +#[case( + "css({props:data,selectors:{\"&[data-note=':is']:hover\":{params:[],p:2}}})", + false +)] +#[case( + "css({props:data,selectors:{\"&[data-note='x']:nth-child\":{params:['2n'],p:2}}})", + false +)] +#[case(concat!("css({props:data,':hover':{", "p:2},'[data-on]':{", "w:3},'> a':{", "h:4}})"), false)] +#[case(concat!("css({props:data,'@media (min-width:1px)':{'@media not print and (color)':{", "p:2}}})"), false)] +#[case("css({props:data,_is:{params:(()=>['a',null,'b'])(),p:2}})", true)] +#[case("css({props:data,_is:{params:(()=>['a',undefined,'b'])(),p:2}})", true)] #[serial] -fn coverage_contract_bridge_keeps_shallow_rules(#[case] expression: &str) -> Result<(), String> { +fn coverage_contract_bridge_keeps_shallow_rules( + #[case] expression: &str, + #[case] refused: bool, + #[values(false, true)] single_css: bool, +) -> Result<(), String> { // Given: independent shallow extraction and an ignored, heap-built payload. let prefix = "const base=css({color:'red'});"; + let compile = |body: &str| { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + extract_without_source_map( + "/coverage-contract.css.ts", + &format!("import{{css}}from'@devup-ui/react';{body}"), + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .map_err(|error| error.to_string()) + }; let shallow = expression.replace("props:data", "props:0"); + if refused { + for (body, owner) in [ + (format!("{prefix}export const card={shallow};"), "(()=>"), + ( + format!( + "{prefix}let data=0;for(let i=0;i<16;i++)data={{payload:data}};export const card={expression};" + ), + "css({props:data", + ), + ] { + let source = format!("import{{css}}from'@devup-ui/react';{body}"); + let column = source.find(owner).ok_or("missing supplying helper call")? + 1; + let error = compile(&body) + .err() + .ok_or("lossy computed params accepted")?; + assert_eq!( + error, + format!( + "/coverage-contract.css.ts:1:{column}: `css()` cannot use `null` at build time: {}", + crate::extractor::selector_params::SELECTOR_PARAMS + ) + ); + } + return Ok(()); + } let expected = compile(&format!("{prefix}export const card={shallow};"))?; assert!(expected.styles.iter().any(|style| matches!(style, ExtractStyleValue::Static(style) if style.property() == "padding" && style.value() == "8px" @@ -65,6 +259,36 @@ fn coverage_contract_bridge_keeps_shallow_rules(#[case] expression: &str) -> Res Ok(()) } +#[rstest::rstest] +#[case("[{},[],'2n']", "{}")] +#[case("[[],'2n']", "[]")] +#[case("['2n',{}]", "{}")] +#[case("['2n',[]]", "[]")] +#[serial] +fn coverage_contract_bridge_rejects_unreadable_selector_params( + #[case] params: &str, + #[case] marker: &str, +) -> Result<(), String> { + // Given an unreadable params entry alongside the same ignored deep payload. + let body = format!( + "let data=0;for(let i=0;i<16;i++)data={{payload:data}};export const card=css({{props:data,_nthChild:{{params:{params},p:2}}}});" + ); + let source = format!("import{{css}}from'@devup-ui/react';{body}"); + let column = source.find(marker).ok_or("missing params entry")? + 1; + // When the bridge validates the actual authored params rather than discarding them. + let error = compile(&body).err().ok_or("unreadable params accepted")?; + // Then the original entry receives the same located refusal as shallow extraction. + assert!( + error.starts_with(&format!("/coverage-contract.css.ts:1:{column}:")), + "{error}" + ); + assert!( + error.contains(crate::extractor::selector_params::SELECTOR_PARAMS), + "{error}" + ); + Ok(()) +} + #[rstest::rstest] #[case("selectors:{invalidSelector:{p:2}}")] #[case(concat!("_invalidSelector:{", "p:2}"))] @@ -82,6 +306,55 @@ fn coverage_contract_bridge_rejects_invalid_selectors(#[case] rules: &str) -> Re Ok(()) } +#[rstest::rstest] +#[case("ordinary", "['a',,'b']", true)] +#[case("ordinary", "['a',undefined,'b']", false)] +#[case("shallow", "['a',,'b']", true)] +#[case("shallow", "['a',undefined,'b']", false)] +#[case("deep", "['a',,'b']", true)] +#[case("deep", "['a',undefined,'b']", false)] +#[serial] +fn coverage_contract_sparse_params_follow_the_same_contract( + #[case] mode: &str, + #[case] params: &str, + #[case] accepted: bool, +) -> Result<(), String> { + let _state = crate::test_state::TestStateGuard::new(); + let prefix = if mode == "deep" { + concat!("let data=0;for(let i=0;i<16;i++)data={", "payload:data};") + } else { + "const data=0;" + }; + let source = format!( + "import{{css}}from'@devup-ui/react';{prefix}export const card=css({{props:data,_is:{{params:{params},color:'red'}}}});" + ); + let filename = if mode == "ordinary" { + "/coverage-contract.tsx" + } else { + "/coverage-contract.css.ts" + }; + let result = extract_without_source_map(filename, &source, ExtractOption::default()) + .map_err(|error| error.to_string()); + if accepted { + let output = result?; + assert!(output.styles.iter().any(|style| matches!(style, + ExtractStyleValue::Static(style) if style.selector.as_ref().is_some_and(|selector| selector.to_string().contains(":is(a,b)")) + ))); + } else { + let error = result.err().ok_or("present undefined params accepted")?; + let column = source.find("undefined").ok_or("missing undefined entry")? + 1; + assert!( + error.starts_with(&format!("{filename}:1:{column}:")), + "{error}" + ); + assert!( + error.contains(crate::extractor::selector_params::SELECTOR_PARAMS), + "{error}" + ); + } + Ok(()) +} + #[test] #[serial] fn coverage_contract_bridge_template_keeps_scalar_declarations() -> Result<(), String> { @@ -210,3 +483,309 @@ fn coverage_contract_bridge_template_keeps_registered_scalar_on_card() -> Result } Ok(()) } + +#[rstest::rstest] +#[case(concat!("css({", "p:1},{props:data,_is:{params:['a',{}],p:2}})"))] +#[case("css({...extra,props:data,_is:{params:['a',{}],p:2}})")] +#[case("css({[key]:1,props:data,_is:{params:['a',{}],p:2}})")] +#[case("css({p:1,p:3,props:data,_is:{params:['a',{}],p:2}})")] +#[case("css({props:data,_is:{params:[computed(),{}],p:2}})")] +#[case("css({...{_is:{params:['a',{}],p:2}},props:data})")] +#[case("css(...[{props:data,_is:{params:['a',{}],p:2}}])")] +#[case("css({props:data,_is:{params:['a',{}],p:2},p:1,p:3})")] +#[case("css({props:data,_hover:{...extra,_nthChild:{params:[],p:2}}})")] +#[serial] +fn coverage_contract_preflight_keeps_definitely_winning_bad_leaves( + #[case] expression: &str, + #[values(false, true)] single_css: bool, +) -> Result<(), String> { + // Given: unknown metadata precedes the definite winning authored container. + let _state = crate::test_state::TestStateGuard::new(); + let rule = format!("export const card={expression};"); + let source = format!( + "import{{css}}from'@devup-ui/react';\nlet data=0;for(let i=0;i<16;i++)data={{payload:data}};const extra={{color:'red'}};const key='color';function computed(){{return 'a'}}\n{rule}" + ); + let column = rule + .find("{}") + .or_else(|| rule.find("[]")) + .ok_or("missing authored params witness")? + + 1; + // When + let error = extract_without_source_map( + "/winning-params.css.ts", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .err() + .ok_or("winning bad params accepted")? + .to_string(); + // Then + assert!( + error.starts_with(&format!("/winning-params.css.ts:3:{column}:")), + "{error}" + ); + assert!( + error.contains(crate::extractor::selector_params::SELECTOR_PARAMS), + "{error}" + ); + Ok(()) +} + +#[rstest::rstest] +#[case("css({props:data,_is:{params:[{}],params:['a'],p:2}})")] +#[case("css({props:data,_is:{params:[{}],p:9},_is:{params:['a'],p:2}})")] +#[case("css({props:data,_is:{params:[{}],p:9},...winner})")] +#[case("css({props:data,_is:{...oldParams,params:['a'],p:2}})")] +#[case("css({props:data,_is:{params:[{}],...paramsWinner,p:2}})")] +#[case("css({props:data,_is:{params:[{}],p:9},[key]:{params:['a'],p:2}})")] +#[serial] +fn coverage_contract_preflight_defers_overridden_bad_metadata( + #[case] expression: &str, + #[values(false, true)] single_css: bool, +) -> Result<(), String> { + // Given: the runtime winner differs from the overwritten invalid metadata. + let _state = crate::test_state::TestStateGuard::new(); + let expected = compile("export const card=css({_is:{params:['a'],p:2}});")?; + let source = format!( + "import{{css}}from'@devup-ui/react';let data=0;for(let i=0;i<16;i++)data={{payload:data}};const winner={{_is:{{params:['a'],p:2}}}};const oldParams={{params:[{{}}]}};const paramsWinner={{params:['a']}};const key='_is';export const card={expression};" + ); + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + // When + let output = extract_without_source_map( + "/winning-params.css.ts", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .map_err(|error| error.to_string())?; + // Then + assert_eq!(output.styles, expected.styles); + Ok(()) +} + +#[rstest::rstest] +#[case("css`${{_is:{params:[],p:2}}}`", false)] +#[case("css`color:${{_is:{params:[],p:2}}};`", true)] +fn coverage_contract_authored_preflight_only_owns_template_statements( + #[case] expression: &str, + #[case] accepted: bool, +) -> Result<(), String> { + // Given: identical object data placed at the two real interpolation boundaries. + let source = format!("import{{css}}from'@devup-ui/react';export const card={expression};"); + // When + let result = + crate::dead_properties::instrument(&source, "/template-params.css.ts", "@devup-ui/react"); + // Then + if accepted { + result.map_err(|error| format!("value data received a params diagnostic: {error}"))?; + } else { + let error = result.err().ok_or("statement empty params accepted")?; + let column = source.find("[]").ok_or("missing authored empty list")? + 1; + assert!( + error.starts_with(&format!("/template-params.css.ts:1:{column}:")), + "{error}" + ); + assert!( + error.contains(crate::extractor::selector_params::SELECTOR_PARAMS), + "{error}" + ); + } + Ok(()) +} + +#[rstest::rstest] +#[serial] +fn coverage_contract_computed_invalid_selector_keeps_dead_key_ownership( + #[values(false, true)] single_css: bool, +) -> Result<(), String> { + // Given: authored metadata cannot see through the evaluated selector-map call. + let _state = crate::test_state::TestStateGuard::new(); + let rule = "export const card=css({props:data,...rules()});"; + let source = format!( + "import{{css}}from'@devup-ui/react';\nlet data=0;for(let i=0;i<16;i++)data={{payload:data}};function rules(){{return {{selectors:{{_invalidSelector:{{boxAlign:'center'}}}}}}}}\n{rule}" + ); + let column = rule.find("css(").ok_or("missing real styling call")? + 1; + // When + let error = extract_without_source_map( + "/dead-selector-params.css.ts", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .err() + .ok_or("dead selector-map declaration accepted")? + .to_string(); + // Then + assert!( + error.starts_with(&format!("/dead-selector-params.css.ts:3:{column}:")), + "{error}" + ); + assert!(error.contains("unprefixed box-align"), "{error}"); + assert!(!error.contains(crate::utils::SELECTOR_NAME), "{error}"); + Ok(()) +} + +#[test] +fn nonparams_matrix_keeps_exact_legacy_materialization() -> Result<(), String> { + let contexts = [ + ( + "/matrix.tsx", + "export const view=;", + false, + ), + ( + "/matrix.tsx", + "export const view=jsx(Box,{p:values()});", + false, + ), + ("/matrix.tsx", "export const card=css({p:values()});", true), + ( + "/matrix.tsx", + "export const Card=styled('div',{p:values()});", + false, + ), + ( + "/matrix.tsx", + "export const animation=keyframes({from:{p:values()}});", + true, + ), + ( + "/matrix.css.ts", + "export const card=css({p:values()});", + true, + ), + ( + "/matrix.css.ts", + concat!( + "export const value=values();export const card=css({", + "p:2});" + ), + false, + ), + ]; + for (filename, body, reaches) in contexts { + for middle in ["", "undefined", "null"] { + for single_css in [false, true] { + // Given: independent main normalization for each of the 42 nonparams rows. + let source = format!( + "import{{Box,css,styled,keyframes}}from'@devup-ui/react';import{{jsx}}from'react/jsx-runtime';function values(){{return [1,{middle},3]}}{body}" + ); + let expected = reaches.then(|| source.replace("p:values()", "p:[1,null,3]")); + // When + let actual = crate::build_time_values::evaluate_located( + &source, + filename, + &ExtractOption { + single_css, + ..ExtractOption::default() + }, + None, + &crate::imported_constants::Unknown::default(), + ) + .map_err(|error| format!("{error:?}"))?; + // Then + assert_eq!( + actual.map(|value| value.0), + expected, + "{filename}/{body}/{middle}/{single_css}" + ); + } + } + } + Ok(()) +} + +#[rstest::rstest] +#[case( + "_nthChild", + "get params(){reads++;return ['2n']}", + "&:nth-child(2n):hover", + 1 +)] +#[case("_host", "set params(value){reads++}", "&:host:hover", 0)] +#[case("_nthChild", "params:[...values()]", "&:nth-child(2n):hover", 1)] +#[serial] +fn coverage_contract_partial_authored_params_use_native_values_once( + #[case] selector: &str, + #[case] metadata: &str, + #[case] expected_selector: &str, + #[case] reads: usize, + #[values(false, true)] single_css: bool, +) -> Result<(), String> { + // Given: valid native metadata sits beside an independently valid sibling. + let _state = crate::test_state::TestStateGuard::new(); + let expected = compile(&format!( + "export const card=css({{color:'red',selectors:{{'{expected_selector}':{{p:2}}}}}});" + ))?; + let source = format!( + "import{{css}}from'@devup-ui/react';let reads=0;function values(){{reads++;return ['2n']}}let data=0;for(let i=0;i<16;i++)data={{payload:data}};export const card=css({{props:data,color:'red',{selector}:{{{metadata},_hover:{{params:[],p:2}}}}}});export const count=reads;" + ); + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + // When + let actual = extract_without_source_map( + "/partial-params.css.ts", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .map_err(|error| error.to_string())?; + // Then + assert_eq!(actual.styles, expected.styles); + assert!( + actual + .code + .contains(&format!("export const count = {reads};")), + "{}", + actual.code + ); + Ok(()) +} + +#[rstest::rstest] +#[case("get params(){return ['2n']},_is:{params:['a',{}],p:2}", "{}")] +#[case("set params(value){},_is:{params:['a',{}],p:2}", "{}")] +#[case("params:[...values(),{}],p:2", "{}")] +#[case("get params(){return ['2n']},_nthChild:{params:[],p:2}", "[]")] +#[case("params:[...values()],_nthChild:{params:[],p:2}", "[]")] +fn coverage_contract_partial_params_preserve_known_leaf_and_required_errors( + #[case] rules: &str, + #[case] witness: &str, +) -> Result<(), String> { + // Given: uncertain parent params cannot hide a known bad leaf or a fresh required target. + let source = format!( + "import{{css}}from'@devup-ui/react';function values(){{return ['2n']}}export const card=css({{color:'red',_nthChild:{{{rules}}}}});" + ); + let offset = source + .rfind(witness) + .ok_or("missing authored refusal witness")?; + let location = crate::locate("/partial-errors.css.ts", &source, offset); + // When + let error = + crate::dead_properties::instrument(&source, "/partial-errors.css.ts", "@devup-ui/react") + .err() + .ok_or("known bad params were deferred")?; + // Then + assert_eq!( + error, + format!( + "{location}: {}", + crate::utils::build_time_error( + "css", + witness, + crate::extractor::selector_params::SELECTOR_PARAMS + ) + ) + ); + Ok(()) +} diff --git a/libs/extractor/src/unresolved_global_inputs_tests.rs b/libs/extractor/src/unresolved_global_inputs_tests.rs new file mode 100644 index 000000000..50168412d --- /dev/null +++ b/libs/extractor/src/unresolved_global_inputs_tests.rs @@ -0,0 +1,60 @@ +mod boundaries; +mod coverage_edges; +mod fonts; +mod function_values; +mod functional_empty; +mod imports; +mod optional_params; +mod params; +mod positives; + +use crate::{ExtractOption, ExtractOutput, dead_properties_test_utils::position, extract}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; + +fn extract_fixture( + source: &str, + single_css: bool, +) -> Result> { + reset_class_map(); + reset_file_map(); + extract( + "structural.tsx", + source, + ExtractOption { + single_css, + import_aliases: std::collections::HashMap::from([( + "@emotion/react".to_string(), + crate::ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }, + ) +} + +pub(super) fn rejects_at(source: &str, needle: &str, single_css: bool) { + // Given an original-source marker, not its containing call or object. + let offset = position(source, needle); + let before = &source[..offset]; + let line = before.bytes().filter(|byte| *byte == b'\n').count() + 1; + let column = before + .rsplit_once('\n') + .map_or(before, |(_, tail)| tail) + .chars() + .count() + + 1; + // When the real extraction pipeline processes the structural input. + let error = crate::dead_properties_test_utils::failure(extract_fixture(source, single_css)); + // Then both the location and rejected expression survive preprocessing. + let (location, message) = error + .split_once(": ") + .unwrap_or_else(|| panic!("unlocated error: {error}")); + assert_eq!(location, format!("structural.tsx:{line}:{column}")); + let (_, rejected) = message + .split_once(" cannot use `") + .unwrap_or_else(|| panic!("unexpected diagnostic: {message}")); + let (code, requirement) = rejected + .split_once("` at build time: ") + .unwrap_or_else(|| panic!("missing requirement: {rejected}")); + assert_eq!(code, needle); + assert_ne!(requirement, ""); +} diff --git a/libs/extractor/src/unresolved_global_inputs_tests/boundaries.rs b/libs/extractor/src/unresolved_global_inputs_tests/boundaries.rs new file mode 100644 index 000000000..ccb12f7d8 --- /dev/null +++ b/libs/extractor/src/unresolved_global_inputs_tests/boundaries.rs @@ -0,0 +1,91 @@ +use super::{extract_fixture, rejects_at}; +use crate::ExtractStyleValue; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("fontFaces: unknownFaces", "unknownFaces")] +#[case("fontFaces: tick()", "tick()")] +#[case("fontFaces: [unknownFace]", "unknownFace")] +#[case("fontFaces: [...unknownFaces]", "unknownFaces")] +#[serial] +fn structural_boundaries_reject_effects_and_unknown_font_collections( + #[case] properties: &str, + #[case] marker: &str, + #[values(false, true)] single_css: bool, +) { + let source = + format!("import {{globalCss}} from '@devup-ui/react';\nglobalCss({{{properties}}});"); + rejects_at(&source, marker, single_css); +} + +#[rstest] +#[case("imports: [{url: 'base.css', query: (tick(), undefined)}]")] +#[case("fontFaces: [{fontFamily: 'Face', fontStyle: (tick(), undefined)}]")] +#[case("body: {_nthChild: {params: [(tick(), undefined)], color: 'red'}}")] +#[serial] +fn optional_looking_sequences_keep_effects_and_original_locations( + #[case] properties: &str, + #[values(false, true)] single_css: bool, +) { + let statement = format!("globalCss({{{properties}}});"); + let column = crate::dead_properties_test_utils::position(&statement, "(tick(), undefined)") + 1; + let source = format!("import {{globalCss}} from '@devup-ui/react';\n{statement}"); + let error = crate::dead_properties_test_utils::failure(extract_fixture(&source, single_css)); + let (diagnostic, requirement) = error + .split_once(" at build time: ") + .unwrap_or_else(|| panic!("unexpected diagnostic: {error}")); + assert_eq!( + diagnostic, + format!("structural.tsx:2:{column}: `globalCss()` cannot use `tick(), undefined`") + ); + assert_ne!(requirement, ""); +} + +#[rstest] +#[case("imports: [1, , null, undefined]", 0)] +#[case("fontFaces: [1, , null, undefined]", 0)] +#[case("fontFaces: null", 0)] +#[case("fontFaces: 17", 0)] +#[case("fontFaces: undefined", 0)] +#[case("imports: [{...{url: 'face.css', query: 'print'}}]", 1)] +#[case("fontFaces: [{...{fontFamily: 'Face', src: 'face.woff2'}}]", 1)] +#[serial] +fn structural_boundaries_keep_absence_and_literal_spreads( + #[case] properties: &str, + #[case] expected: usize, + #[values(false, true)] single_css: bool, +) { + let source = + format!("import {{globalCss}} from '@devup-ui/react';\nglobalCss({{{properties}}});"); + let output = extract_fixture(&source, single_css).unwrap_or_else(|error| panic!("{error}")); + assert_eq!(output.styles.len(), expected); + for style in &output.styles { + match style { + ExtractStyleValue::Import(import) => assert_eq!(import.url, "\"face.css\" print"), + ExtractStyleValue::FontFace(face) => assert_eq!(face.properties["font-family"], "Face"), + other => panic!("expected literal structural style, got {other:?}"), + } + } +} + +#[rstest] +#[serial] +fn params_preserve_joining_and_nonrequired_absence(#[values(false, true)] single_css: bool) { + let source = "import {Box} from '@devup-ui/react';\nexport const view = ;"; + let output = extract_fixture(source, single_css).unwrap_or_else(|error| panic!("{error}")); + let mut selectors: Vec<_> = output + .styles + .iter() + .map(|style| match style { + ExtractStyleValue::Static(style) => style + .selector + .as_ref() + .unwrap_or_else(|| panic!("missing selector")) + .to_string(), + other => panic!("expected static selector, got {other:?}"), + }) + .collect(); + selectors.sort_unstable(); + assert_eq!(selectors, vec!["&:hover", "&:is(p,a)"]); +} diff --git a/libs/extractor/src/unresolved_global_inputs_tests/coverage_edges.rs b/libs/extractor/src/unresolved_global_inputs_tests/coverage_edges.rs new file mode 100644 index 000000000..1a8121221 --- /dev/null +++ b/libs/extractor/src/unresolved_global_inputs_tests/coverage_edges.rs @@ -0,0 +1,377 @@ +use super::{extract_fixture, rejects_at}; +use crate::ExtractStyleValue; +use css::style_selector::StyleSelector; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[serial] +fn group_nth_params_require_an_argument( + #[values("_groupNthChild", "_groupNthOfType", "_groupNthLastChild")] selector: &str, + #[values(false, true)] single_css: bool, +) { + let source = format!( + "import {{Box}} from '@devup-ui/react';\nexport const view=;" + ); + rejects_at(&source, "[]", single_css); +} + +#[rstest] +#[case("unknownKey")] +#[case("tick()")] +#[serial] +fn font_descriptor_keys_reject_unknown_computation( + #[case] key: &str, + #[values(false, true)] single_css: bool, +) { + let statement = format!("globalCss({{fontFaces:[{{[{key}]:'normal',src:'face.woff2'}}]}});"); + let column = crate::dead_properties_test_utils::position(&statement, key) + 1; + let source = format!("import {{globalCss}} from '@devup-ui/react';\n{statement}"); + let error = crate::dead_properties_test_utils::failure(extract_fixture(&source, single_css)); + let (location, _) = error + .split_once(": ") + .unwrap_or_else(|| panic!("unlocated error: {error}")); + assert_eq!(location, format!("structural.tsx:2:{column}")); + assert!(error.contains(&format!("cannot use `[{key}]` at build time:"))); +} + +#[rstest] +#[case("{params:null,color:'red'}", None)] +#[case("{_nthChild:{params:['2'],color:'red'}}", Some("&:nth-child(2)"))] +#[serial] +fn params_keep_the_media_selector_context( + #[case] rules: &str, + #[case] expected: Option<&str>, + #[values(false, true)] single_css: bool, +) { + let source = format!( + "import {{Box}} from '@devup-ui/react';\nexport const view=;" + ); + let output = extract_fixture(&source, single_css).unwrap_or_else(|error| panic!("{error}")); + let selectors: Vec<_> = output + .styles + .iter() + .map(|style| match style { + ExtractStyleValue::Static(style) => match style.selector.as_ref() { + Some(StyleSelector::At { selector, .. }) => selector.as_deref(), + other => panic!("expected media selector, got {other:?}"), + }, + other => panic!("expected static declaration, got {other:?}"), + }) + .collect(); + assert_eq!(selectors, vec![expected]); +} + +#[rstest] +#[case((":hover", "&:hover", false))] +#[case((":host", "&:host", false))] +#[case((":nth-child", "&:nth-child", true))] +#[case(("::part", "&::part", true))] +#[case((":is", "&:is", true))] +#[serial] +fn evaluated_params_shapes_match_shallow_when_bridge_is_forced( + #[case] target: (&str, &str, bool), + #[values((None, None), (Some("[]"), Some("")), (Some("['']"), Some("")), (Some("['','x']"), Some("x")), (Some("['2n']"), Some("2n")), (Some("['a','b']"), Some("a,b")))] + shape: (Option<&str>, Option<&str>), + #[values(false, true)] single_css: bool, +) -> Result<(), String> { + // Given: identical authored rule coordinates and an independent shallow result. + let _state = crate::test_state::TestStateGuard::new(); + let (selector, bare, required) = target; + let member = shape + .0 + .map_or_else(String::new, |value| format!("params:{value},")); + let rule = format!("export const card=css({{props:data,'{selector}':{{{member}p:2}}}});"); + let source = |setup| format!("import{{css}}from'@devup-ui/react';\n{setup}\n{rule}"); + let compile = |source: &str| { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + crate::extract_without_source_map( + "/bridge-params.css.ts", + source, + crate::ExtractOption { + single_css, + ..crate::ExtractOption::default() + }, + ) + .map_err(|error| error.to_string()) + }; + let expected = compile(&source(concat!( + "let data=0;for(let i=0;i<1;i++)data={", + "payload:data};" + ))); + // When: the ignored payload requires indexed evaluated lowering. + let actual = compile(&source(concat!( + "let data=0;for(let i=0;i<16;i++)data={", + "payload:data};" + ))); + // Then: exact selector bytes or the exact authored empty-list refusal agree. + if required && shape.1 == Some("") { + let marker = if shape.0 == Some("[]") { "[]" } else { "''" }; + let column = rule.find(marker).ok_or("missing empty witness")? + 1; + let expected = expected.err().ok_or("shallow empty params accepted")?; + let actual = actual.err().ok_or("bridge empty params accepted")?; + assert!( + expected.starts_with(&format!("/bridge-params.css.ts:3:{column}:")), + "{expected}" + ); + assert!( + expected.contains(crate::extractor::selector_params::SELECTOR_PARAMS), + "{expected}" + ); + assert_eq!(actual, expected); + } else { + let expected = expected?; + let actual = actual?; + let selector = shape + .1 + .filter(|text| !text.is_empty()) + .map_or_else(|| bare.to_string(), |text| format!("{bare}({text})")); + let selectors: Vec<_> = actual + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) => { + style.selector.as_ref().map(ToString::to_string) + } + _ => None, + }) + .collect(); + assert_eq!(selectors, vec![selector]); + assert_eq!(actual.styles, expected.styles); + assert_eq!(actual.code, expected.code); + } + Ok(()) +} + +#[rstest] +#[case("_hover", "null")] +#[case("_hover", "undefined")] +#[case("_host", "void 0")] +#[case("_host", "[]")] +#[serial] +fn evaluated_absence_keeps_optional_bridge_selectors( + #[case] selector: &str, + #[case] value: &str, + #[values(false, true)] single_css: bool, +) -> Result<(), String> { + // Given: the metadata comes from a real evaluated helper, not lexical spelling. + let _state = crate::test_state::TestStateGuard::new(); + let source = format!( + "import{{css}}from'@devup-ui/react';let data=0;for(let i=0;i<16;i++)data={{payload:data}};function value(){{return {value}}}export const card=css({{props:data,{selector}:{{params:value(),p:2}}}});" + ); + // When + let output = crate::extract_without_source_map( + "/bridge-absence.css.ts", + &source, + crate::ExtractOption { + single_css, + ..crate::ExtractOption::default() + }, + ) + .map_err(|error| error.to_string())?; + // Then + let expected = if selector == "_hover" { + "&:hover" + } else { + "&:host" + }; + let selectors: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) => style.selector.as_ref().map(ToString::to_string), + _ => None, + }) + .collect(); + assert_eq!(selectors, vec![expected]); + Ok(()) +} + +#[rstest] +#[serial] +fn evaluated_shadowed_undefined_is_value_proof_but_unresolved_shadow_is_not( + #[values(false, true)] single_css: bool, +) -> Result<(), String> { + // Given + let _state = crate::test_state::TestStateGuard::new(); + let source = concat!( + "import{css}from'@devup-ui/react';\nlet data=0;for(let i=0;i<16;i++)data={", + "payload:data};\nfunction make(undefined){return css({props:data,_hover:{params:undefined,p:2}})}\nexport const card=make(void 0);" + ); + let option = crate::ExtractOption { + single_css, + ..crate::ExtractOption::default() + }; + let error = crate::extract_without_source_map("/bridge-shadow.tsx", source, option.clone()) + .err() + .ok_or("unresolved shadow accepted")? + .to_string(); + let line = source.lines().nth(2).ok_or("missing helper")?; + let column = line.find("params:undefined").ok_or("missing shadow")? + "params:".len() + 1; + assert!( + error.starts_with(&format!("/bridge-shadow.tsx:3:{column}:")), + "{error}" + ); + assert!( + error.contains(crate::extractor::selector_params::SELECTOR_PARAMS), + "{error}" + ); + // When + let output = crate::extract_without_source_map("/bridge-shadow.css.ts", source, option) + .map_err(|error| error.to_string())?; + // Then + assert!(output.styles.iter().any(|style| matches!(style, ExtractStyleValue::Static(style) if style.selector.as_ref().is_some_and(|selector| selector.to_string() == "&:hover")))); + Ok(()) +} + +#[rstest] +#[case("_nthChild", "[]", None)] +#[case("_nthChild", "['']", None)] +#[case("_nthChild", "['2n']", Some("&:nth-child(2n)"))] +#[case("_host", "[]", Some("&:host"))] +#[case("_host", "['.scope']", Some("&:host(.scope)"))] +#[serial] +fn media_and_layer_wrappers_preserve_native_params_requiredness( + #[case] target: &str, + #[case] params: &str, + #[case] selector: Option<&str>, + #[values(false, true)] single_css: bool, +) -> Result<(), String> { + // Given: media and layer frames wrap, rather than replace, the pending target. + let _state = crate::test_state::TestStateGuard::new(); + let compile = |source: &str| { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + crate::extract_without_source_map( + "/wrapped-params.css.ts", + source, + crate::ExtractOption { + single_css, + ..crate::ExtractOption::default() + }, + ) + .map_err(|error| error.to_string()) + }; + let source = format!( + "import{{css}}from'@devup-ui/react';let data=0;for(let i=0;i<16;i++)data={{payload:data}};export const card=css({{props:data,{target}:{{'@media':{{screen:{{'@layer':{{card:{{params:{params},p:2}}}}}}}}}}}});" + ); + let expected = selector.map(|selector| compile(&format!( + "import{{css}}from'@devup-ui/react';export const card=css({{'@layer':{{card:{{'@media':{{screen:{{selectors:{{'{selector}':{{p:2}}}}}}}}}}}}}});" + ))).transpose()?; + // When + let actual = compile(&source); + // Then + if let Some(expected) = expected { + assert_eq!(actual?.styles, expected.styles); + } else { + let witness = if params == "[]" { "[]" } else { "''" }; + let offset = source.find(witness).ok_or("missing empty params")?; + let location = crate::locate("/wrapped-params.css.ts", &source, offset); + let error = actual + .err() + .ok_or("wrapped required selector accepted empty params")?; + let printed = if params == "[]" { "[]" } else { "\"\"" }; + assert_eq!( + error, + format!( + "{location}: {}", + crate::utils::build_time_error( + "css", + printed, + crate::extractor::selector_params::SELECTOR_PARAMS + ) + ) + ); + } + Ok(()) +} + +#[rstest] +#[case(( + "", + "_nthChild:{selectors:{'& &':{params:['2n'],selectors:{'&':{params:['3n'],_host:{params:[],p:2},_cue:{params:[],w:3}}}}}}", + concat!("selectors:{'&:nth-child(3n) &:nth-child(2n):host':{", "p:2},'&:nth-child(3n) &:nth-child(2n)::cue':{", "w:3}}") +))] +#[case(( + "", + "_host:{selectors:{'&-context':{params:['.scope'],_hover:{params:[],p:2}}}}", + concat!("selectors:{'&:host-context(.scope):hover':{", "p:2}}") +))] +#[case(( + "let left={params:[],p:2};left={_host:left};left={params:['3n'],...left};left={selectors:{'&':left}};left={params:['2n'],...left};let right={params:[],w:3};right={_host:right};right={params:['5n'],...right};right={selectors:{'&':right}};right={params:['4n'],...right};let forks={selectors:{'&':left,'.copy &':right}};", + "_nthChild:{selectors:{'& &':forks}}", + concat!("selectors:{'&:nth-child(3n) &:nth-child(2n):host':{", "p:2},'.copy &:nth-child(5n) &:nth-child(4n):host':{", "w:3}}") +))] +#[serial] +fn native_pending_target_joins_and_lexical_replacement_match_flat_selectors( + #[case] fixture: (&str, &str, &str), + #[values(false, true)] single_css: bool, +) -> Result<(), String> { + // Given: independent flat selectors specify argument attachment and lexical replacement. + let _state = crate::test_state::TestStateGuard::new(); + let (setup, nested, flat) = fixture; + let compile = |body: &str| { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + crate::extract_without_source_map( + "/pending-params.css.ts", + &format!("import{{css}}from'@devup-ui/react';{body}"), + crate::ExtractOption { + single_css, + ..crate::ExtractOption::default() + }, + ) + .map_err(|error| error.to_string()) + }; + let expected = compile(&format!("export const card=css({{{flat}}});"))?; + let body = format!( + "let data=0;for(let i=0;i<16;i++)data={{payload:data}};{setup}export const card=css({{props:data,{nested}}});" + ); + // When + let actual = compile(&body)?; + // Then + assert_eq!(actual.styles, expected.styles); + assert_eq!(actual.code, expected.code); + Ok(()) +} + +#[rstest] +#[serial] +fn native_cached_join_tail_preserves_required_error_in_second_fork( + #[values(false, true)] single_css: bool, +) -> Result<(), String> { + // Given: the first fork consumes both targets; the second must retain one required target. + let _state = crate::test_state::TestStateGuard::new(); + let rule = "export const card=css({props:data,_nthChild:{selectors:{'& &':forks}}});"; + let source = format!( + "import{{css}}from'@devup-ui/react';\nlet data=0;for(let i=0;i<16;i++)data={{payload:data}};let left={{params:[],p:2}};left={{_host:left}};left={{params:['3n'],...left}};left={{selectors:{{'&':left}}}};left={{params:['2n'],...left}};let right={{params:[],w:3}};right={{selectors:{{'&':right}}}};right={{params:['4n'],...right}};let forks={{selectors:{{'&':left,'.copy &':right}}}};\n{rule}" + ); + let column = rule.find("css(").ok_or("missing native css call")? + 1; + // When + let error = crate::extract_without_source_map( + "/cached-required.css.ts", + &source, + crate::ExtractOption { + single_css, + ..crate::ExtractOption::default() + }, + ) + .err() + .ok_or("cached pop discarded the remaining required target")? + .to_string(); + // Then + assert!( + error.starts_with(&format!("/cached-required.css.ts:3:{column}:")), + "{error}" + ); + assert!( + error.contains(&crate::utils::build_time_error( + "css", + "_nthChild -> selectors -> & & -> selectors -> .copy & -> selectors -> & -> params", + crate::extractor::selector_params::SELECTOR_PARAMS, + )), + "{error}" + ); + Ok(()) +} diff --git a/libs/extractor/src/unresolved_global_inputs_tests/fonts.rs b/libs/extractor/src/unresolved_global_inputs_tests/fonts.rs new file mode 100644 index 000000000..54d4b222f --- /dev/null +++ b/libs/extractor/src/unresolved_global_inputs_tests/fonts.rs @@ -0,0 +1,172 @@ +use super::rejects_at; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("{fontFamily: unknownFamily, src: 'face.woff2'}", "unknownFamily")] +#[case("{fontFamily: tick(), src: 'face.woff2'}", "tick()")] +#[case("{fontFamily: 'Face', src: unknownSrc}", "unknownSrc")] +#[case("{fontFamily: 'Face', src: tick()}", "tick()")] +#[case( + "{fontFamily: 'Face', src: 'face.woff2', fontWeight: unknownWeight}", + "unknownWeight" +)] +#[case("{fontFamily: 'Face', src: 'face.woff2', fontStyle: tick()}", "tick()")] +#[case( + "{...unknownDescriptors, fontFamily: 'Face', src: 'face.woff2'}", + "unknownDescriptors" +)] +#[case("{fontFamily: 'Face', ...tick(), src: 'face.woff2'}", "tick()")] +#[case( + "{fontFamily: 'Face', src: 'face.woff2', fontStyle: void tick()}", + "void tick()" +)] +#[serial] +fn rejects_unresolved_font_descriptors_when_present( + #[case] face: &str, + #[case] marker: &str, + #[values(false, true)] single_css: bool, +) { + let source = format!( + "import {{ globalCss }} from '@devup-ui/react';\nglobalCss({{ fontFaces: [{face}] }});" + ); + rejects_at(&source, marker, single_css); +} + +#[rstest] +#[case("font-family: Face; src: ${unknownSrc};", "unknownSrc")] +#[case("font-family: Face; src: ${tick()};", "tick()")] +#[case("font-family: Face; src: url(${unknownSrc});", "unknownSrc")] +#[case("font-family: Face; src: url(${tick()});", "tick()")] +#[case("font-family: Face; ${unknownProperty}: normal;", "unknownProperty")] +#[case("font-family: Face; ${tick()}: normal;", "tick()")] +#[case( + "font-family: Face; ${unknownStatement}; src: face.woff2;", + "unknownStatement" +)] +#[case("font-family: Face; ${tick()}; src: face.woff2;", "tick()")] +#[case("${unknownStatement}", "unknownStatement")] +#[case("${tick()}", "tick()")] +#[case("font-family: Face; src: ${void tick()};", "void tick()")] +#[serial] +fn rejects_unresolved_font_templates_before_css_to_style_discards_them( + #[case] text: &str, + #[case] marker: &str, + #[values(false, true)] single_css: bool, +) { + let source = format!( + "import {{ globalCss }} from '@devup-ui/react';\nglobalCss({{ fontFaces: [`{text}`] }});" + ); + rejects_at(&source, marker, single_css); +} + +#[rstest] +#[case("{fontFamily: 'Face', src: 'face.woff2', fontWeight: 400}", ("font-weight", "400"))] +#[case("{fontFamily: `Fa${'ce'}`, src: 'face.woff2', fontStyle: `nor${'mal'}`}", ("font-style", "normal"))] +#[case("{fontFamily: 'Face', src: 'face.woff2', fontStyle: false}", ("font-style", "false"))] +#[case("`font-family: Face; src: face.woff2; font-style: normal;`", ("font-style", "normal"))] +#[case("`font-family: ${`Fa${'ce'}`}; src: face.woff2;`", ("font-family", "Face"))] +#[case("`font-family: ${p => p.theme.fonts.body}; src: face.woff2;`", ("font-family", "var(--fonts-body)"))] +#[case("`${'font-family'}: Face; src: face.woff2;`", ("font-family", "Face"))] +#[case("`${'font-family: Face;'} src: face.woff2;`", ("font-family", "Face"))] +#[serial] +fn readable_font_faces_keep_literal_pure_and_theme_descriptors( + #[case] face: &str, + #[case] descriptor: (&str, &str), + #[values(false, true)] single_css: bool, +) { + let (property, expected) = descriptor; + let source = format!( + "import {{ globalCss }} from '@devup-ui/react';\nglobalCss({{ fontFaces: [{face}] }});" + ); + let output = + super::extract_fixture(&source, single_css).unwrap_or_else(|error| panic!("{error}")); + let faces: Vec<_> = output + .styles + .iter() + .map(|style| match style { + crate::ExtractStyleValue::FontFace(face) => { + face.properties.get(property).map(String::as_str) + } + other => panic!("expected font face, got {other:?}"), + }) + .collect(); + assert_eq!(faces, vec![Some(expected)]); +} + +#[rstest] +#[serial] +fn absent_font_descriptors_remain_absent( + #[values("undefined", "null")] absent: &str, + #[values(false, true)] single_css: bool, +) { + let source = format!( + "import {{ globalCss }} from '@devup-ui/react';\nglobalCss({{fontFaces: [{{fontFamily: {absent}, src: 'face.woff2', fontStyle: {absent}}}]}});" + ); + let output = + super::extract_fixture(&source, single_css).unwrap_or_else(|error| panic!("{error}")); + let faces: Vec<_> = output + .styles + .iter() + .map(|style| match style { + crate::ExtractStyleValue::FontFace(face) => &face.properties, + other => panic!("expected font face, got {other:?}"), + }) + .collect(); + assert_eq!( + faces, + vec![&std::collections::BTreeMap::from([( + "src".to_string(), + "url(face.woff2)".to_string(), + )])] + ); +} + +#[rstest] +#[case("${p => p.theme.fonts.body}: Face; src: face.woff2;")] +#[case("${p => p.theme.fonts.body}; font-family: Face; src: face.woff2;")] +#[serial] +fn theme_references_are_not_font_descriptor_names_or_statements( + #[case] text: &str, + #[values(false, true)] single_css: bool, +) { + let statement = format!("globalCss({{fontFaces:[`{text}`]}});"); + let column = crate::dead_properties_test_utils::position(&statement, "p =>") + 1; + let source = format!("import {{globalCss}} from '@devup-ui/react';\n{statement}"); + let error = + crate::dead_properties_test_utils::failure(super::extract_fixture(&source, single_css)); + let (location, _) = error + .split_once(": ") + .unwrap_or_else(|| panic!("unlocated error: {error}")); + assert_eq!(location, format!("structural.tsx:2:{column}")); + assert!(error.contains("p.theme.fonts.body")); + assert!(error.contains(" at build time:")); +} + +#[rstest] +#[case("${p => p.theme.fonts.body}: Face; src: url(face.woff2);")] +#[case("${p => p.theme.fonts.body}; font-family: Face; src: url(face.woff2);")] +#[serial] +fn stylesheet_font_placement_errors_keep_the_original_interpolation( + #[case] text: &str, + #[values(false, true)] single_css: bool, +) { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + let statement = format!("globalCss({{fontFaces:[`{text}`]}});"); + let column = crate::dead_properties_test_utils::position(&statement, "p =>") + 1; + let source = format!("import {{globalCss}} from '@devup-ui/react';\n{statement}"); + let error = crate::dead_properties_test_utils::failure(crate::extract( + "font-structural.css.ts", + &source, + crate::ExtractOption { + single_css, + ..crate::ExtractOption::default() + }, + )); + let (location, _) = error + .split_once(": ") + .unwrap_or_else(|| panic!("unlocated error: {error}")); + assert_eq!(location, format!("font-structural.css.ts:2:{column}")); + assert!(error.contains("p.theme.fonts.body")); +} diff --git a/libs/extractor/src/unresolved_global_inputs_tests/function_values.rs b/libs/extractor/src/unresolved_global_inputs_tests/function_values.rs new file mode 100644 index 000000000..82c913a5f --- /dev/null +++ b/libs/extractor/src/unresolved_global_inputs_tests/function_values.rs @@ -0,0 +1,56 @@ +use super::extract_fixture; +use crate::ExtractStyleValue; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case(("imports:[{url:() => \"face.css\"}]", "() => \"face.css\""))] +#[case(("imports:[{url:'face.css',query:() => \"screen\"}]", "() => \"screen\""))] +#[case(("fontFaces:[{fontFamily:() => \"Face\",src:'face.woff2'}]", "() => \"Face\""))] +#[case(("fontFaces:[`font-family:${() => \"Face\"};src:url(face.woff2);`]", "() => \"Face\""))] +#[case(("fontFaces:[`font-family:${() => \" at build time: \"};src:url(face.woff2);`]", "() => \" at build time: \""))] +#[case(("fontFaces:[`font-family:${class {}};src:url(face.woff2);`]", "class {}"))] +#[case(("fontFaces:[`font-family:${function() {}};src:url(face.woff2);`]", "function() {}"))] +#[case(("body:{_hover:{params:() => [],color:'red'}}", "() => []"))] +#[case(("body:{_nthChild:{params:[() => \"2\"],color:'red'}}", "() => \"2\""))] +#[case(("imports:[{url:class {}}]", "class {}"))] +#[case(("fontFaces:[{fontFamily:function() {},src:'face.woff2'}]", "function() {}"))] +#[serial] +fn structural_functions_cannot_disappear_during_value_evaluation( + #[case] fixture: (&str, &str), + #[values(false, true)] single_css: bool, +) { + let (properties, marker) = fixture; + let statement = format!("globalCss({{{properties}}});"); + let column = crate::dead_properties_test_utils::position(&statement, marker) + 1; + let source = format!("import {{globalCss}} from '@devup-ui/react';\n{statement}"); + let code = if matches!(marker, "class {}" | "function() {}") { + format!("({marker})") + } else { + marker.to_string() + }; + let error = crate::dead_properties_test_utils::failure(extract_fixture(&source, single_css)); + let (location, message) = error + .split_once(": ") + .unwrap_or_else(|| panic!("unlocated error: {error}")); + assert_eq!(location, format!("structural.tsx:2:{column}")); + assert!(message.contains(&format!("cannot use `{code}` at build time:"))); +} + +#[rstest] +#[serial] +fn a_pure_call_returning_text_still_uses_exact_value_evaluation( + #[values(false, true)] single_css: bool, +) { + let source = "import {globalCss} from '@devup-ui/react';\nglobalCss({imports:[{url:(() => 'pure.css')()}]});"; + let output = extract_fixture(source, single_css).unwrap_or_else(|error| panic!("{error}")); + let urls: Vec<_> = output + .styles + .iter() + .map(|style| match style { + ExtractStyleValue::Import(import) => import.url.as_str(), + other => panic!("expected import, got {other:?}"), + }) + .collect(); + assert_eq!(urls, vec!["\"pure.css\""]); +} diff --git a/libs/extractor/src/unresolved_global_inputs_tests/functional_empty.rs b/libs/extractor/src/unresolved_global_inputs_tests/functional_empty.rs new file mode 100644 index 000000000..d5d62c1da --- /dev/null +++ b/libs/extractor/src/unresolved_global_inputs_tests/functional_empty.rs @@ -0,0 +1,80 @@ +use super::{extract_fixture, rejects_at}; +use crate::ExtractStyleValue; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[serial] +fn functional_only_selectors_reject_explicit_empty_lists( + #[values("_is", "_where", "_not", "_has", "_lang", "_part", "_slotted")] selector: &str, + #[values("[]", "[\"\"]", "[\"\",\"\"]")] params: &str, + #[values(false, true)] single_css: bool, +) { + let source = format!( + "import {{Box}} from '@devup-ui/react';\nexport const view=;" + ); + rejects_at( + &source, + if params == "[]" { "[]" } else { "\"\"" }, + single_css, + ); +} + +#[rstest] +#[case(("_hover", "&:hover"))] +#[case(("_host", "&:host"))] +#[case(("_cue", "&::cue"))] +#[case(("_cueRegion", "&::cue-region"))] +#[serial] +fn optional_selectors_keep_the_bare_form_without_arguments( + #[case] fixture: (&str, &str), + #[values("[]", "[\"\"]", "null", "undefined")] params: &str, + #[values(false, true)] single_css: bool, +) { + let (selector, expected) = fixture; + let source = format!( + "import {{Box}} from '@devup-ui/react';\nexport const view=;" + ); + let output = extract_fixture(&source, single_css).unwrap_or_else(|error| panic!("{error}")); + let selectors: Vec<_> = output + .styles + .iter() + .map(|style| match style { + ExtractStyleValue::Static(style) => style + .selector + .as_ref() + .unwrap_or_else(|| panic!("missing selector")) + .to_string(), + other => panic!("expected static selector, got {other:?}"), + }) + .collect(); + assert_eq!(selectors, vec![expected]); +} + +#[rstest] +#[case(("_is", "p", "&:is(p)"))] +#[case(("_lang", "en", "&:lang(en)"))] +#[serial] +fn readable_nonempty_lists_keep_existing_empty_text_skips( + #[case] fixture: (&str, &str, &str), + #[values(false, true)] single_css: bool, +) { + let (selector, argument, expected) = fixture; + let source = format!( + "import {{Box}} from '@devup-ui/react';\nexport const view=;" + ); + let output = extract_fixture(&source, single_css).unwrap_or_else(|error| panic!("{error}")); + let selectors: Vec<_> = output + .styles + .iter() + .map(|style| match style { + ExtractStyleValue::Static(style) => style + .selector + .as_ref() + .unwrap_or_else(|| panic!("missing selector")) + .to_string(), + other => panic!("expected static selector, got {other:?}"), + }) + .collect(); + assert_eq!(selectors, vec![expected]); +} diff --git a/libs/extractor/src/unresolved_global_inputs_tests/imports.rs b/libs/extractor/src/unresolved_global_inputs_tests/imports.rs new file mode 100644 index 000000000..3cd46570e --- /dev/null +++ b/libs/extractor/src/unresolved_global_inputs_tests/imports.rs @@ -0,0 +1,154 @@ +use super::rejects_at; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("[{url: unknownUrl}]", "unknownUrl")] +#[case("[{url: tick()}]", "tick()")] +#[case("[{url: 'base.css', query: unknownQuery}]", "unknownQuery")] +#[case("[{url: 'base.css', query: tick()}]", "tick()")] +#[case("[unknownEntry]", "unknownEntry")] +#[case("[tick()]", "tick()")] +#[case("unknownImports", "unknownImports")] +#[case("tick()", "tick()")] +#[case("['first.css', unknownEntry, 'last.css']", "unknownEntry")] +#[case("[...unknownEntries]", "unknownEntries")] +#[case("[...tick()]", "tick()")] +#[case("[{...unknownEntry, url: 'base.css'}]", "unknownEntry")] +#[case("[{url: 'base.css', ...tick()}]", "tick()")] +#[case("[{url: 'base.css', query: void tick()}]", "void tick()")] +#[serial] +fn rejects_unresolved_imports_when_present( + #[case] value: &str, + #[case] marker: &str, + #[values(false, true)] single_css: bool, +) { + let source = format!( + "import {{ globalCss }} from '@devup-ui/react';\nglobalCss({{ imports: {value} }});" + ); + rejects_at(&source, marker, single_css); +} + +#[rstest] +#[case("unknownKey")] +#[case("tick()")] +#[serial] +fn rejects_unresolved_import_keys_at_the_key_expression( + #[case] key: &str, + #[values(false, true)] single_css: bool, +) { + let statement = format!("globalCss({{imports:[{{[{key}]:'base.css'}}]}});"); + let column = crate::dead_properties_test_utils::position(&statement, key) + 1; + let source = format!("import {{globalCss}} from '@devup-ui/react';\n{statement}"); + let error = + crate::dead_properties_test_utils::failure(super::extract_fixture(&source, single_css)); + assert_eq!( + error + .split_once(": ") + .unwrap_or_else(|| panic!("unlocated error: {error}")) + .0, + format!("structural.tsx:2:{column}") + ); + assert!(error.contains(&format!("cannot use `[{key}]` at build time:"))); +} + +#[rstest] +#[case::query("imports: [{url: 'base.css', query: undefined}]")] +#[case::descriptor("fontFaces: [{fontFamily: 'Face', src: 'face.woff2', fontStyle: undefined}]")] +#[case::params("body: {_nthChild: {params: [undefined], color: 'red'}}")] +#[serial] +fn rejects_shadowed_undefined_when_structural( + #[case] properties: &str, + #[values(false, true)] single_css: bool, +) { + let source = format!( + "import {{ globalCss }} from '@devup-ui/react';\nexport function render(undefined) {{\n globalCss({{ {properties} }});\n}}" + ); + // The parameter's spelling is deliberately identical to the unbound global. + let use_offset = source + .rfind("undefined") + .unwrap_or_else(|| panic!("missing fixture reference")); + let error = + crate::dead_properties_test_utils::failure(super::extract_fixture(&source, single_css)); + let line = &source[..use_offset]; + let column = line + .rsplit_once('\n') + .map_or(line, |(_, tail)| tail) + .chars() + .count() + + 1; + assert_eq!( + error + .split_once(": ") + .unwrap_or_else(|| panic!("unlocated error: {error}")) + .0, + format!("structural.tsx:3:{column}") + ); + assert!(error.contains("cannot use `undefined` at build time:")); +} + +#[rstest] +#[case("imports: [{url: 'base.css', query: unknownQuery}]", "unknownQuery")] +#[case("fontFaces: [{fontFamily: 'Face', src: tick()}]", "tick()")] +#[case("fontFaces: [`font-family: Face; src: ${unknownSrc};`]", "unknownSrc")] +#[case( + "body: {_nthChild: {params: ['2', unknownParam], color: 'red'}}", + "unknownParam" +)] +#[serial] +fn emotion_global_reports_the_marker_not_styles_container( + #[case] properties: &str, + #[case] marker: &str, + #[values(false, true)] single_css: bool, +) { + let source = format!( + "import {{ Global }} from '@emotion/react';\nexport const view = ;" + ); + rejects_at(&source, marker, single_css); +} + +#[rstest] +#[case("[{url: 'base.css'}]", "\"base.css\"")] +#[case( + "[{url: `base${'.css'}`, query: `scr${'een'}`}]", + "\"base.css\" screen" +)] +#[case("[{url: 'base.css', query: undefined}]", "\"base.css\"")] +#[case("[{url: 'base.css', query: null}]", "\"base.css\"")] +#[case("[{url: 'base.css', query: false}]", "\"base.css\" false")] +#[case("['base.css']", "base.css")] +#[serial] +fn readable_imports_preserve_text_and_genuine_optional_absence( + #[case] value: &str, + #[case] expected: &str, + #[values(false, true)] single_css: bool, +) { + let source = format!( + "import {{ globalCss }} from '@devup-ui/react';\nglobalCss({{ imports: {value} }});" + ); + let output = + super::extract_fixture(&source, single_css).unwrap_or_else(|error| panic!("{error}")); + let imports: Vec<_> = output + .styles + .iter() + .map(|style| match style { + crate::ExtractStyleValue::Import(import) => import.url.as_str(), + other => panic!("expected import, got {other:?}"), + }) + .collect(); + assert_eq!(imports, vec![expected]); +} + +#[rstest] +#[serial] +fn pure_invalid_imports_remain_skipped( + #[values("[1, 2, {query: 'print'}]", "{}", "null", "17")] value: &str, + #[values(false, true)] single_css: bool, +) { + let source = format!( + "import {{ globalCss }} from '@devup-ui/react';\nglobalCss({{ imports: {value} }});" + ); + let output = + super::extract_fixture(&source, single_css).unwrap_or_else(|error| panic!("{error}")); + assert_eq!(output.styles.len(), 0); +} diff --git a/libs/extractor/src/unresolved_global_inputs_tests/optional_params.rs b/libs/extractor/src/unresolved_global_inputs_tests/optional_params.rs new file mode 100644 index 000000000..a6dd0f6dd --- /dev/null +++ b/libs/extractor/src/unresolved_global_inputs_tests/optional_params.rs @@ -0,0 +1,234 @@ +use super::extract_fixture; +use crate::ExtractStyleValue; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[serial] +fn factory_values_compile_when_selector_params_are_proven_absent( + #[values(false, true)] single_css: bool, +) { + // Given a factory value that must compile inside a selector with absent params. + let source = "import {Box,css} from '@devup-ui/react';\nimport {jsx} from 'react/jsx-runtime';\nexport const view=jsx(Box,{_hover:{params:undefined,other:css({overflow:'hidden'})}});"; + // When the factory and its nested style call are extracted together. + let output = extract_fixture(source, single_css).unwrap_or_else(|error| panic!("{error}")); + // Then the nested call contributes its styles and the factory keeps its value slot. + assert!(output.styles.iter().any(|style| matches!(style, + ExtractStyleValue::Static(style) if style.property == "overflow" && style.value == "hidden" + ))); + assert_eq!( + output + .styles + .iter() + .filter(|style| matches!(style, ExtractStyleValue::Dynamic(_))) + .count(), + 1 + ); +} + +#[rstest] +#[case(("Box", "export const view=;"))] +#[case(("globalCss", "globalCss({body:{_hover:{params:ABSENT,color:'red'}}});"))] +#[case(("styled", "export const Card=styled('div',{_hover:{params:ABSENT,color:'red'}});"))] +#[case(("Box", "export const view=jsx(Box,{_hover:{params:ABSENT,color:'red'}});"))] +#[case(("css", "export const view=css({_hover:{params:ABSENT,color:'red'}});"))] +#[serial] +fn optional_selector_params_keep_proven_absence( + #[case] fixture: (&str, &str), + #[values("undefined", "null", "void 0", "void void 0", "void null")] absent: &str, + #[values(false, true)] single_css: bool, +) { + let (imports, statement) = fixture; + let source = format!( + "import {{{imports}}} from '@devup-ui/react';\nimport {{jsx}} from 'react/jsx-runtime';\n{}", + statement.replace("ABSENT", absent) + ); + let output = extract_fixture(&source, single_css).unwrap_or_else(|error| panic!("{error}")); + let declarations: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) if style.property == "color" => Some(( + style.value.as_str(), + style + .selector + .as_ref() + .unwrap_or_else(|| panic!("missing selector")) + .to_string(), + )), + _ => None, + }) + .collect(); + assert_eq!(declarations.len(), 1); + assert_eq!(declarations[0].0, "red"); + assert!(declarations[0].1.ends_with(":hover")); +} + +#[rstest] +#[serial] +fn optional_selector_params_reject_shadowed_undefined(#[values(false, true)] single_css: bool) { + let source = "import {Box} from '@devup-ui/react';\nexport function view(undefined) {return ;}"; + let offset = source + .rfind("undefined") + .unwrap_or_else(|| panic!("missing fixture reference")); + let before = &source[..offset]; + let column = before + .rsplit_once('\n') + .map_or(before, |(_, tail)| tail) + .chars() + .count() + + 1; + let error = crate::dead_properties_test_utils::failure(extract_fixture(source, single_css)); + assert_eq!( + error + .split_once(": ") + .unwrap_or_else(|| panic!("unlocated error: {error}")) + .0, + format!("structural.tsx:2:{column}") + ); + assert!(error.contains("cannot use `undefined` at build time:")); +} + +#[rstest] +#[serial] +fn absent_params_keep_authored_winners_when_known_css_is_composed_into_styled( + #[values(false, true)] single_css: bool, +) { + // Given competing aliases at responsive levels and in separately authored layers. + let source = "import {css,styled} from '@devup-ui/react';\nconst base=css({_hover:{params:undefined,padding:[3,5]}});\nconst Choice=styled.div(base,{_hover:{params:void 0,p:[4,6]},'@layer':{first:{_hover:{params:undefined,padding:7,p:8}},second:{_hover:{params:null,p:9}}}});"; + // When the complete generated renderer selects its composed class names. + let output = extract_fixture(source, single_css).unwrap_or_else(|error| panic!("{error}")); + let bucket = (!single_css).then(|| css::file_map::canonical("structural.tsx")); + let active = crate::styled_source_tests::captured_render::active( + ("structural.tsx", bucket.as_deref()), + &output, + "{}", + ); + // Then only later authored aliases win, without losing selector, breakpoint or layer. + let mut declarations: Vec<_> = active + .into_iter() + .map(|value| match value { + ExtractStyleValue::Static(style) => ( + style.layer, + style.level, + style.property, + style.value, + style.selector.map(|selector| selector.to_string()), + ), + other => panic!("expected static composed declaration: {other:?}"), + }) + .collect(); + declarations.sort(); + let expected = [ + (None, 0, "16px"), + (None, 1, "24px"), + (Some("first"), 0, "32px"), + (Some("second"), 0, "36px"), + ] + .map(|(layer, level, value)| { + ( + layer.map(str::to_string), + level, + "padding".to_string(), + value.to_string(), + Some("&:hover".to_string()), + ) + }); + assert_eq!(declarations, expected); + let layers: Vec<_> = output + .styles + .iter() + .filter_map(|value| match value { + ExtractStyleValue::LayerOrder(order) => Some(order.layers.clone()), + _ => None, + }) + .collect(); + assert_eq!( + layers, + vec![vec!["first".to_string(), "second".to_string()]] + ); + let repeated = extract_fixture(source, single_css).unwrap_or_else(|error| panic!("{error}")); + assert_eq!(output.code, repeated.code); + assert_eq!(output.styles, repeated.styles); +} + +#[rstest] +#[case("css(base,{'@layer':{base:{_hover:{params:undefined,padding:3,p:4}}}})")] +#[case("styled.div(base,{'@layer':{base:{_hover:{params:undefined,padding:3,p:4}}}})")] +#[case("styled.div(base,p=>p.on?{_hover:{params:undefined,padding:3,p:4}}:{color:'blue'})")] +#[serial] +fn composed_projection_rejects_shadowed_params_at_the_authored_leaf( + #[case] expression: &str, + #[values(false, true)] single_css: bool, +) { + // Given a lexical undefined binding inside a composition, not the absent global. + let source = format!( + "import {{css,styled}} from '@devup-ui/react';\nconst base=css({{color:'red'}});\nexport function view(undefined) {{return {expression};}}" + ); + let offset = source + .rfind("undefined") + .unwrap_or_else(|| panic!("missing params")); + let column = source[..offset] + .rsplit('\n') + .next() + .unwrap_or_default() + .chars() + .count() + + 1; + // When authored-order extraction traverses the layer or conditional callback. + let error = crate::dead_properties_test_utils::failure(extract_fixture(&source, single_css)); + // Then the original params leaf is rejected, never erased by composition or retry. + assert!( + error.starts_with(&format!("structural.tsx:3:{column}: ")), + "{error}" + ); + assert!( + error.contains("cannot use `undefined` at build time:"), + "{error}" + ); + assert!( + error.contains(crate::extractor::selector_params::SELECTOR_PARAMS), + "{error}" + ); +} + +#[rstest] +#[case(false, "12px")] +#[case(true, "16px")] +#[serial] +fn absent_params_keep_the_previous_atom_when_a_projected_callback_branch_omits_it( + #[case] on: bool, + #[case] expected: &str, + #[values(false, true)] single_css: bool, +) { + // Given a later callback branch that either replaces padding or keeps its earlier alias. + let source = "import {styled} from '@devup-ui/react';\nconst Choice=styled.div({_hover:{params:undefined,padding:3}},p=>p.on?{_hover:{params:undefined,p:4}}:{_hover:{params:void 0}});"; + // When the original generated renderer chooses a branch with runtime props. + let output = extract_fixture(source, single_css).unwrap_or_else(|error| panic!("{error}")); + let bucket = (!single_css).then(|| css::file_map::canonical("structural.tsx")); + let active = crate::styled_source_tests::captured_render::active( + ("structural.tsx", bucket.as_deref()), + &output, + &format!("{{on:{on}}}"), + ); + // Then the selected class carries the authored winner, never an empty replacement. + let declarations: Vec<_> = active + .into_iter() + .map(|value| match value { + ExtractStyleValue::Static(style) => ( + style.property, + style.value, + style.selector.map(|selector| selector.to_string()), + ), + other => panic!("expected static branch winner: {other:?}"), + }) + .collect(); + assert_eq!( + declarations, + vec![( + "padding".to_string(), + expected.to_string(), + Some("&:hover".to_string()) + )] + ); +} diff --git a/libs/extractor/src/unresolved_global_inputs_tests/params.rs b/libs/extractor/src/unresolved_global_inputs_tests/params.rs new file mode 100644 index 000000000..215ee1de4 --- /dev/null +++ b/libs/extractor/src/unresolved_global_inputs_tests/params.rs @@ -0,0 +1,180 @@ +use super::rejects_at; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("[unknownParam]", "unknownParam")] +#[case("[tick()]", "tick()")] +#[case("unknownParams", "unknownParams")] +#[case("tick()", "tick()")] +#[case("['2', unknownParam]", "unknownParam")] +#[case("['2', tick()]", "tick()")] +#[case("[...unknownParams]", "unknownParams")] +#[case("[...tick()]", "tick()")] +#[case("[]", "[]")] +#[case("[\"\"]", "\"\"")] +#[case("[\"\", \"\"]", "\"\"")] +#[case("[void tick()]", "void tick()")] +#[serial] +fn rejects_nth_child_params_without_partial_or_empty_arguments( + #[case] params: &str, + #[case] marker: &str, + #[values(false, true)] single_css: bool, +) { + let source = format!( + "import {{ Box }} from '@devup-ui/react';\nexport const view = ;" + ); + rejects_at(&source, marker, single_css); +} + +#[rstest] +#[case("export const c = css({_nthChild: {params: ['2', unknownParam], color: 'red'}});")] +#[case("globalCss({body: {_nthChild: {params: ['2', unknownParam], color: 'red'}}});")] +#[case("export const S = styled.div({_nthChild: {params: ['2', unknownParam], color: 'red'}});")] +#[case("export const view = jsx(Box, {_nthChild: {params: ['2', unknownParam], color: 'red'}});")] +#[case( + "export const view = jsxs(Box, {_nthChild: {params: ['2', unknownParam], color: 'red'}, children: ['a', 'b']});" +)] +#[case("globalCss('body', {_nthChild: {params: ['2', unknownParam], color: 'red'}});")] +#[serial] +fn every_params_caller_retains_the_original_entry_location( + #[case] statement: &str, + #[values(false, true)] single_css: bool, +) { + let source = format!( + "import {{ Box, css, globalCss, styled }} from '@devup-ui/react';\nimport {{ jsx, jsxs }} from 'react/jsx-runtime';\n{statement}" + ); + rejects_at(&source, "unknownParam", single_css); +} + +#[rstest] +#[serial] +fn advanced_selectors_reject_unresolved_params( + #[values("_is", "_where", "_not", "_has", "_nthOfType")] selector: &str, + #[values(false, true)] single_css: bool, +) { + let source = format!( + "import {{ Box }} from '@devup-ui/react';\nexport const view = ;" + ); + rejects_at(&source, "unknownParam", single_css); +} + +#[rstest] +#[case("['2']", "&:nth-child(2)")] +#[case("[`2${''}`]", "&:nth-child(2)")] +#[serial] +fn literal_and_pure_params_keep_the_complete_selector( + #[case] params: &str, + #[case] expected: &str, + #[values(false, true)] single_css: bool, +) { + let source = format!( + "import {{ Box }} from '@devup-ui/react';\nexport const view = ;" + ); + let output = + super::extract_fixture(&source, single_css).unwrap_or_else(|error| panic!("{error}")); + let styles: Vec<_> = output + .styles + .iter() + .map(|style| match style { + crate::ExtractStyleValue::Static(style) => ( + style.property.as_str(), + style.value.as_str(), + style + .selector + .as_ref() + .unwrap_or_else(|| panic!("missing selector")) + .to_string(), + ), + other => panic!("expected static selector style, got {other:?}"), + }) + .collect(); + assert_eq!(styles, vec![("color", "red", expected.to_string())]); +} + +#[rstest] +#[serial] +fn optional_params_on_simple_selectors_remain_optional(#[values(false, true)] single_css: bool) { + let source = "import {Box} from '@devup-ui/react';\nexport const view = ;"; + let output = + super::extract_fixture(source, single_css).unwrap_or_else(|error| panic!("{error}")); + let mut styles: Vec<_> = output + .styles + .iter() + .map(|style| match style { + crate::ExtractStyleValue::Static(style) => ( + style.level, + style.value.as_str(), + style + .selector + .as_ref() + .unwrap_or_else(|| panic!("missing selector")) + .to_string(), + ), + other => panic!("expected responsive hover style, got {other:?}"), + }) + .collect(); + styles.sort_unstable(); + assert_eq!( + styles, + vec![ + (0, "red", "&:hover".to_string()), + (2, "blue", "&:hover".to_string()), + ] + ); +} + +#[rstest] +#[serial] +fn quoted_punctuation_preserves_the_real_params_target(#[values(false, true)] single_css: bool) { + // Given: quoted text resembles closing syntax and another functional selector. + let source = r#"import {css} from '@devup-ui/react'; +export const cls=css({selectors:{'&:nth-child[title="]:not"]':{params:['2n'],color:'red'}}});"#; + // When: strict params resolution uses the quote-aware selector scanner. + let output = + super::extract_fixture(source, single_css).unwrap_or_else(|error| panic!("{error}")); + // Then: only the real pseudo receives parameters; literal selector bytes survive. + let styles: Vec<_> = output + .styles + .iter() + .map(|style| match style { + crate::ExtractStyleValue::Static(style) => ( + style.property.as_str(), + style.value.as_str(), + style + .selector + .as_ref() + .unwrap_or_else(|| panic!("missing selector")) + .to_string(), + ), + other => panic!("expected selector style, got {other:?}"), + }) + .collect(); + assert_eq!( + styles, + vec![( + "color", + "red", + r#"&:nth-child(2n)[title="]:not"]"#.to_string() + )] + ); +} + +#[rstest] +#[case("['2n',unknownParam]", "unknownParam")] +#[case("[]", "[]")] +#[serial] +fn quoted_punctuation_cannot_hide_invalid_required_params( + #[case] params: &str, + #[case] marker: &str, + #[values(false, true)] single_css: bool, +) { + // Given: a real required pseudo precedes fake quoted selector punctuation. + let source = format!( + r#"import {{css}} from '@devup-ui/react'; +export const cls=css({{selectors:{{'&:nth-child[title="]:not"]':{{params:{params},color:'red'}}}}}});"# + ); + // When: the invalid params list reaches strict extraction. + // Then: its authored leaf/list, not quoted text or the outer call, is refused. + rejects_at(&source, marker, single_css); +} diff --git a/libs/extractor/src/unresolved_global_inputs_tests/positives.rs b/libs/extractor/src/unresolved_global_inputs_tests/positives.rs new file mode 100644 index 000000000..2aa67cc7f --- /dev/null +++ b/libs/extractor/src/unresolved_global_inputs_tests/positives.rs @@ -0,0 +1,79 @@ +use super::extract_fixture; +use crate::ExtractStyleValue; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[serial] +fn successful_globals_keep_authored_order(#[values(false, true)] single_css: bool) { + // Given distinct siblings and successive calls whose order matters. + let source = "import { globalCss } from '@devup-ui/react';\nglobalCss({imports: ['first.css', 'second.css'], fontFaces: [{fontFamily: 'First', src: 'first.woff2'}], body: {color: 'red'}});\nglobalCss({imports: ['third.css'], fontFaces: [{fontFamily: 'Second', src: 'second.woff2'}], body: {color: 'blue'}});"; + // When both global calls are extracted into the real collector. + let output = extract_fixture(source, single_css).unwrap_or_else(|error| panic!("{error}")); + // Then source ordering and file ownership survive all three structural kinds. + let mut ordered: Vec<_> = output + .styles + .iter() + .map(|style| match style { + ExtractStyleValue::Import(import) => { + (import.order, import.url.as_str(), import.file.as_str()) + } + ExtractStyleValue::FontFace(face) => ( + face.order, + face.properties["font-family"].as_str(), + face.file.as_str(), + ), + ExtractStyleValue::Static(style) => match style + .selector + .as_ref() + .unwrap_or_else(|| panic!("missing selector")) + { + css::style_selector::StyleSelector::Global(selector, file) => { + assert_eq!(selector, "body"); + (style.order, style.value.as_str(), file.as_str()) + } + other => panic!("expected global selector, got {other:?}"), + }, + other => panic!("expected ordered global style, got {other:?}"), + }) + .collect(); + ordered.sort_unstable(); + assert_eq!( + ordered, + vec![ + (1, "first.css", "structural.tsx"), + (2, "second.css", "structural.tsx"), + (3, "First", "structural.tsx"), + (4, "red", "structural.tsx"), + (5, "third.css", "structural.tsx"), + (6, "Second", "structural.tsx"), + (7, "blue", "structural.tsx"), + ] + ); +} + +#[rstest] +#[case("export const view = ;")] +#[case("export const Card = styled('article', {color: runtimeColor});")] +#[serial] +fn ordinary_dynamic_values_still_use_element_css_variables( + #[case] statement: &str, + #[values(false, true)] single_css: bool, +) { + // Given a dynamic declaration value, not a structural input. + let source = format!("import {{Box, styled}} from '@devup-ui/react';\n{statement}"); + // When extraction processes the ordinary element value. + let output = extract_fixture(&source, single_css).unwrap_or_else(|error| panic!("{error}")); + // Then it retains the existing dynamic declaration rather than rejecting it. + let values: Vec<_> = output + .styles + .iter() + .map(|style| match style { + ExtractStyleValue::Dynamic(style) => style.property(), + other => panic!("expected dynamic declaration, got {other:?}"), + }) + .collect(); + assert_eq!(values, vec!["color"]); + assert!(output.code.contains("runtimeColor")); + assert!(output.code.contains("--")); +} diff --git a/libs/extractor/src/vanilla_extract_css.rs b/libs/extractor/src/vanilla_extract_css.rs index 7c501acd6..0f90028bf 100644 --- a/libs/extractor/src/vanilla_extract_css.rs +++ b/libs/extractor/src/vanilla_extract_css.rs @@ -106,12 +106,40 @@ fn source(values: &mut Values<'_>, args: &[JsValue], context: &mut Context) -> J .map(|text| js_str(text).ok_or_else(unsupported)) .collect::>()?, ); + let arena = oxc_allocator::Allocator::default(); + let ast = oxc_ast::builder::AstBuilder::new(&arena); + let quasis = raw + .iter() + .enumerate() + .map(|(index, text)| { + use oxc_allocator::FromIn; + let text = js_str(text).ok_or_else(unsupported)?; + Ok(oxc_ast::ast::TemplateElement::new( + oxc_span::SPAN, + oxc_ast::ast::TemplateElementValue { + raw: oxc_ast::ast::Str::from_in(text.as_str(), &arena), + cooked: None, + }, + index + 1 == raw.len(), + &ast, + )) + }) + .collect::>>()?; + let mut before = String::new(); let mut code = "css`".to_string(); for (index, text) in raw.iter().enumerate() { - code.push_str(&js_str(text).ok_or_else(unsupported)?); + let text = js_str(text).ok_or_else(unsupported)?; + code.push_str(&text); + before.push_str(&text); if let Some(value) = args.get(index + 1) { + let value_position = matches!( + crate::css_utils::interpolation_place(&before, &quasis[index + 1..]), + crate::css_utils::Place::Value + ); code.push_str("${"); - let root = if value.as_object().is_some_and(|object| object.is_array()) { + let root = if value_position { + values.capture_data(value, context)? + } else if value.as_object().is_some_and(|object| object.is_array()) { let start = values.plan.roots.len(); rule(values, value, context)?; let items = values.plan.roots.drain(start..).collect(); @@ -124,9 +152,18 @@ fn source(values: &mut Values<'_>, args: &[JsValue], context: &mut Context) -> J } else { values.capture(value, context)? }; - match &values.plan.nodes[root] { - crate::own_css_plan::Node::Object(_) => values.declarations(root), - crate::own_css_plan::Node::Scalar(_) | crate::own_css_plan::Node::Array(_) => {} + if value_position { + before.push_str("__EXPR__"); + } else { + if matches!( + &values.plan.nodes[root], + crate::own_css_plan::Node::Object(_) + ) { + values.declarations(root); + } + if let Some(text) = js_str(value) { + before.push_str(&text); + } } values.plan.roots.push(root); if !values.plan.deep { diff --git a/libs/extractor/src/vanilla_extract_css_capture.rs b/libs/extractor/src/vanilla_extract_css_capture.rs index 60e4f54bb..0f376b761 100644 --- a/libs/extractor/src/vanilla_extract_css_capture.rs +++ b/libs/extractor/src/vanilla_extract_css_capture.rs @@ -1,5 +1,5 @@ use super::super::super::{is_plain_object, string_code, to_text}; -use super::super::{array_items, js_str, unsupported}; +use super::super::{js_str, unsupported}; use super::Values; use crate::own_css_plan::{Node, Plan, Property}; use boa_engine::{Context, JsObject, JsResult, JsValue, property::PropertyKey}; @@ -8,29 +8,59 @@ use boa_engine::{Context, JsObject, JsResult, JsValue, property::PropertyKey}; #[path = "vanilla_extract_css_capture_tests.rs"] mod tests; +thread_local! { + // The evaluated module can replace Array.isArray; this realm never reaches it. + static ARRAY_IS_ARRAY: JsResult = { + let mut pristine = Context::default(); + pristine.intrinsics().constructors().array().constructor() + .get(boa_engine::js_string!("isArray"), &mut pristine) + .and_then(|value| value.as_callable().ok_or_else(unsupported)) + }; +} + #[derive(Clone, Copy)] enum Arrays { Data, + Ignored, + Params, + ParamEntry, Responsive, Entry, } enum Step { Value(JsValue, Arrays, usize, usize), - Property(JsObject, PropertyKey, usize, usize), + Property(JsObject, PropertyKey, usize, usize, bool), Exit(JsObject), } impl Values<'_> { pub(crate) fn capture(&mut self, value: &JsValue, context: &mut Context) -> JsResult { + self.capture_with(value, context, Arrays::Data) + } + + pub(crate) fn capture_data( + &mut self, + value: &JsValue, + context: &mut Context, + ) -> JsResult { + self.capture_with(value, context, Arrays::Ignored) + } + + fn capture_with( + &mut self, + value: &JsValue, + context: &mut Context, + arrays: Arrays, + ) -> JsResult { let root = self.plan.nodes.len(); self.plan.nodes.push(Node::Array(Vec::new())); - let mut pending = vec![Step::Value(value.clone(), Arrays::Data, root, 0)]; + let mut pending = vec![Step::Value(value.clone(), arrays, root, 0)]; while let Some(step) = pending.pop() { match step { Step::Exit(object) => { self.active.remove(&object); } - Step::Property(object, key, parent, depth) => { + Step::Property(object, key, parent, depth, metadata) => { let name = match &key { PropertyKey::String(name) => name.to_std_string_escaped(), PropertyKey::Index(index) => index.get().to_string(), @@ -47,8 +77,13 @@ impl Values<'_> { }; let value = self.plan.nodes.len(); self.plan.nodes.push(Node::Array(Vec::new())); - let arrays = if data_slot(&name) { - Arrays::Data + let arrays = if !metadata { + Arrays::Ignored + } else if name == "params" { + self.plan.has_params = true; + Arrays::Params + } else if data_slot(&name) { + Arrays::Ignored } else { Arrays::Responsive }; @@ -60,7 +95,9 @@ impl Values<'_> { Step::Value(value, arrays, id, depth) => { // This bounds generated syntax, not accepted input depth; deeper values use the plan bridge. self.plan.deep |= depth > 12; - let code = if let Some(text) = js_str(&value) { + let code = if value.is_undefined() && matches!(arrays, Arrays::Params) { + Some("void 0".to_string()) + } else if let Some(text) = js_str(&value) { Some(string_code(&text, self.names)) } else if value.is_null_or_undefined() || value.as_boolean().is_some() @@ -72,18 +109,48 @@ impl Values<'_> { }; if let Some(code) = code { let slot = self.plan.slots.len(); + if matches!(arrays, Arrays::Params | Arrays::ParamEntry) { + use crate::own_css_lower::params::Scalar; + let scalar = if value.is_null_or_undefined() { + Scalar::Absent + } else { + Scalar::Text(js_str(&value).unwrap_or_else(|| code.clone())) + }; + self.plan.param_values.insert(slot, scalar); + } self.plan.slots.push(code); self.plan.nodes[id] = Node::Scalar(slot); continue; } - let object = value.as_object().ok_or_else(unsupported)?; + if matches!(arrays, Arrays::ParamEntry) { + return Err(self.params_error()); + } + let object = value.as_object().ok_or_else(|| { + if matches!(arrays, Arrays::Params) { + self.params_error() + } else { + unsupported() + } + })?; + let array = object.is_array() + || (!object.is_ordinary() && { + ARRAY_IS_ARRAY + .with(Clone::clone)? + .call(&JsValue::undefined(), std::slice::from_ref(&value), context)? + .to_boolean() + }); + if matches!(arrays, Arrays::Params) && !array { + return Err(self.params_error()); + } if object.is_callable() || !self.active.insert(object.clone()) { return Err(unsupported()); } pending.push(Step::Exit(object.clone())); - if let Some(items) = array_items(&value, context)? { + if array { let children = match arrays { Arrays::Data => Arrays::Data, + Arrays::Ignored | Arrays::ParamEntry => Arrays::Ignored, + Arrays::Params => Arrays::ParamEntry, Arrays::Responsive => Arrays::Entry, Arrays::Entry => { return Err(boa_engine::JsNativeError::typ() @@ -91,9 +158,25 @@ impl Values<'_> { .into()); } }; - let mut ids = Vec::with_capacity(items.len()); - for _ in &items { - ids.push(self.plan.nodes.len()); + let length = object + .get(boa_engine::js_string!("length"), context)? + .to_u32(context)?; + let mut items = Vec::new(); + let mut ids = Vec::new(); + for index in 0..length { + let child = self.plan.nodes.len(); + let item = object.get(index, context)?; + if matches!(arrays, Arrays::Params) { + // Refuse before queuing descendants or reading any later index. + if item.is_object() { + return Err(self.params_error()); + } + if item.is_undefined() && !object.has_property(index, context)? { + self.plan.holes.insert(child); + } + } + items.push(item); + ids.push(child); self.plan.nodes.push(Node::Array(Vec::new())); } pending.extend( @@ -106,13 +189,20 @@ impl Values<'_> { self.plan.nodes[id] = Node::Array(ids); } else if is_plain_object(&object, context) { self.plan.nodes[id] = Node::Object(Vec::new()); - pending.extend( - object - .own_property_keys(context)? - .into_iter() - .rev() - .map(|key| Step::Property(object.clone(), key, id, depth + 1)), - ); + pending.extend(object.own_property_keys(context)?.into_iter().rev().map( + |key| { + Step::Property( + object.clone(), + key, + id, + depth + 1, + !matches!( + arrays, + Arrays::Ignored | Arrays::Params | Arrays::ParamEntry + ), + ) + }, + )); } else { return Err(unsupported()); } @@ -121,6 +211,20 @@ impl Values<'_> { } Ok(root) } + + fn params_error(&self) -> boa_engine::JsError { + if let Some((location, api)) = &self.plan.origin { + self.collector.borrow_mut().declaration_errors.push(format!( + "{location}: {}", + crate::utils::build_time_error( + api, + "params", + crate::extractor::selector_params::SELECTOR_PARAMS + ) + )); + } + unsupported() + } } fn data_slot(name: &str) -> bool { @@ -133,14 +237,17 @@ pub(super) fn validate(plan: &Plan, root: usize) -> Vec<(String, &'static str)> enum ObjectKind { Styles, Selectors, + At(css::style_selector::AtRuleKind), + Layers, } enum Step<'a> { - Value(usize, ObjectKind, bool), - Key(&'a Property, ObjectKind, bool), + Value(usize, ObjectKind, bool, Option), + Key(&'a Property, ObjectKind, bool, Option), Push(String), Pop, } - let mut pending = vec![Step::Value(root, ObjectKind::Styles, false)]; + let mut pending = vec![Step::Value(root, ObjectKind::Styles, false, None)]; + let mut contexts = crate::own_css_context::Contexts::default(); let mut path = Vec::new(); let mut errors = Vec::new(); while let Some(step) = pending.pop() { @@ -149,9 +256,22 @@ pub(super) fn validate(plan: &Plan, root: usize) -> Vec<(String, &'static str)> Step::Pop => { path.pop(); } - Step::Key(property, kind, entry) => { + Step::Key(property, kind, entry, mut context_path) => { path.push(property.name.clone()); pending.push(Step::Pop); + let mut push = |frame| { + if let Some(frame) = frame + && plan.has_params + { + context_path = Some(contexts.push(context_path, frame)); + } + pending.push(Step::Value( + property.value, + ObjectKind::Styles, + entry, + context_path, + )); + }; match kind { ObjectKind::Styles => { if let Some(requirement) = @@ -159,33 +279,85 @@ pub(super) fn validate(plan: &Plan, root: usize) -> Vec<(String, &'static str)> { errors.push((path.join(" -> "), requirement)); } else if !data_slot(&property.name) { - let kind = if matches!( - property.name.as_str(), - "selectors" - | "@media" - | "@supports" - | "@container" - | "_media" - | "_supports" - | "_container" - | "@layer" - ) { + let name = property.name.as_str(); + let kind = if name == "selectors" { ObjectKind::Selectors + } else if name == "@layer" { + ObjectKind::Layers + } else if let Some(kind) = + crate::extractor::extract_style_from_expression::at_rule_record_kind( + name, + ) + { + ObjectKind::At(kind) } else { + if plan.has_params + && (name + .starts_with([':', '.', '#', '[', '*', '>', '+', '~', '_']) + || name.contains('&') + || css::at_rule::split_at_rule_key(name).is_some()) + && let Some(frame) = crate::own_css_context::under(name) + { + context_path = Some(contexts.push(context_path, frame)); + } ObjectKind::Styles }; - pending.push(Step::Value(property.value, kind, entry)); + pending.push(Step::Value(property.value, kind, entry, context_path)); } } - ObjectKind::Selectors => { - pending.push(Step::Value(property.value, ObjectKind::Styles, entry)); - } + ObjectKind::Selectors => push(crate::own_css_context::under(&property.name)), + ObjectKind::At(kind) => push(Some(crate::own_css_context::Frame::At( + kind, + property.name.clone(), + ))), + ObjectKind::Layers => push(Some(crate::own_css_context::Frame::Layer( + property.name.clone(), + ))), } } - Step::Value(id, kind, entry) => match &plan.nodes[id] { + Step::Value(id, kind, entry, mut context_path) => match &plan.nodes[id] { Node::Scalar(_) => {} Node::Object(properties) => { - pending.extend(properties.iter().rev().map(|p| Step::Key(p, kind, entry))); + if matches!(kind, ObjectKind::Styles) + && contexts.selected(context_path) + && let Some(property) = properties.iter().find(|p| p.name == "params") + { + use crate::own_css_lower::params::{Params, Scalar}; + match (Params { + plan, + value: property.value, + }) + .resolve( + |slot| { + plan.param_values + .get(&slot) + .cloned() + .unwrap_or(Scalar::Invalid) + }, + || contexts.requires_argument(context_path), + ) { + Ok(Some(params)) => { + context_path = Some(contexts.push( + context_path, + crate::own_css_context::Frame::Params(params), + )); + } + Ok(None) => {} + Err(()) => { + errors.push(( + format!("{} -> params", path.join(" -> ")), + crate::extractor::selector_params::SELECTOR_PARAMS, + )); + continue; + } + } + } + pending.extend( + properties + .iter() + .rev() + .map(|p| Step::Key(p, kind, entry, context_path)), + ); } Node::Array(items) => { if entry { @@ -194,7 +366,7 @@ pub(super) fn validate(plan: &Plan, root: usize) -> Vec<(String, &'static str)> } for (index, &item) in items.iter().enumerate().rev() { pending.push(Step::Pop); - pending.push(Step::Value(item, kind, true)); + pending.push(Step::Value(item, kind, true, context_path)); pending.push(Step::Push(index.to_string())); } } diff --git a/libs/extractor/src/vanilla_extract_css_capture_tests.rs b/libs/extractor/src/vanilla_extract_css_capture_tests.rs index 5874a3731..99e51e7fd 100644 --- a/libs/extractor/src/vanilla_extract_css_capture_tests.rs +++ b/libs/extractor/src/vanilla_extract_css_capture_tests.rs @@ -1,7 +1,9 @@ use super::{Values, validate}; +use crate::{ExtractOption, extract_without_source_map}; use crate::{own_css_plan::Plan, vanilla_extract::Collector}; use boa_engine::{Context, Source}; use rustc_hash::{FxHashMap, FxHashSet}; +use serial_test::serial; use std::{cell::RefCell, rc::Rc}; #[test] @@ -89,3 +91,699 @@ fn capture_validation_rejects_responsive_arrays_across_entry_objects( assert_eq!(errors, vec![(path.into(), crate::utils::RESPONSIVE_ARRAY)]); Ok(()) } + +#[rstest::rstest] +#[case(("_hover", "'not-a-list'"))] +#[case(("_hover", "5"))] +#[case(("_hover", "true"))] +#[case(("_hover", "{}"))] +#[case(("_hover", "()=>0"))] +#[case(("_is", "['a',()=>0]"))] +#[case(("_is", "null"))] +#[case(("_is", "undefined"))] +#[case(("_is", "[]"))] +#[case(("_is", "['']"))] +#[case(("_is", "['a',{}]"))] +#[case(("_is", "['a',[]]"))] +#[case(("_is", "['a',undefined]"))] +#[case(("_is", "['a',null]"))] +#[case(("_is", "['a',1n]"))] +#[case(("_is", "['a',Infinity]"))] +#[case(("_is", "['a',Object.defineProperty({},'payload',{enumerable:true,get(){throw Error('descendant')}})]"))] +#[case(("_is", "['a',Object.defineProperty([],0,{enumerable:true,get(){throw Error('descendant')}})]"))] +#[case(("_is", "['a',(()=>{const entry={};entry.self=entry;return entry})()]"))] +#[case(("_is", "['a',(()=>{const entry=[];entry.push(entry);return entry})()]"))] +#[serial] +fn bridge_computed_invalid_params_are_terminal_at_the_call( + #[case] fixture: (&str, &str), + #[values(false, true)] caught: bool, + #[values(false, true)] single_css: bool, +) -> Result<(), String> { + // Given: neither a reference nor a computed call has authored leaf provenance. + let _state = crate::test_state::TestStateGuard::new(); + let (selector, value) = fixture; + let expression = if caught { "computed()" } else { "value" }; + let body = format!("return css({{props:data,{selector}:{{params:{expression},p:2}}}})"); + let helper = if caught { + format!("function make(){{try{{{body}}}catch{{return css({{p:9}})}}}}") + } else { + format!("function make(){{{body}}}") + }; + let source = format!( + "import{{css}}from'@devup-ui/react';\nlet data=0;for(let i=0;i<16;i++)data={{payload:data}};const value={value};function computed(){{return value}}\n{helper}\nexport const card=make();" + ); + let column = helper + .find("css({props") + .ok_or("missing real styling call")? + + 1; + // When + let error = extract_without_source_map( + "/computed-params.css.ts", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .err() + .ok_or("computed invalid params accepted")? + .to_string(); + // Then: a surrounding catch cannot consume the sticky metadata refusal. + assert!( + error.starts_with(&format!("/computed-params.css.ts:3:{column}:")), + "{error}" + ); + assert!( + error.contains(crate::extractor::selector_params::SELECTOR_PARAMS), + "{error}" + ); + Ok(()) +} + +fn compile_params(body: &str) -> Result { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + extract_without_source_map( + "/capture-params.css.ts", + &format!("import{{css}}from'@devup-ui/react';{body}"), + ExtractOption::default(), + ) + .map_err(|error| error.to_string()) +} + +#[rstest::rstest] +#[case("_groupNthChild", "[]", "[]")] +#[case("_groupNthChild", "['']", "''")] +#[case("selectors:{\"&:nth-child[title='옳]:not']\"", "[]", "[]")] +#[case("selectors:{\"&:nth-child[title='옳]:not']\"", "['']", "''")] +#[serial] +fn bridge_nested_targets_keep_authored_empty_locations( + #[case] target: &str, + #[case] params: &str, + #[case] marker: &str, +) -> Result<(), String> { + // Given + let closing = if target.starts_with("selectors:") { + "}" + } else { + "" + }; + let body = format!( + "let data=0;for(let i=0;i<16;i++)data={{payload:data}};export const card=css({{props:data,_hover:{{{target}:{{params:{params},p:2}}{closing}}}}});" + ); + let source = format!("import{{css}}from'@devup-ui/react';{body}"); + let offset = source + .find(marker) + .ok_or("missing authored empty witness")?; + let location = crate::locate("/capture-params.css.ts", &source, offset); + // When + let error = compile_params(&body) + .err() + .ok_or("nested required empty params accepted")?; + // Then + assert!(error.starts_with(&format!("{location}:")), "{error}"); + assert!( + error.contains(crate::extractor::selector_params::SELECTOR_PARAMS), + "{error}" + ); + Ok(()) +} + +#[test] +#[serial] +fn bridge_refuses_missing_params_bindings() -> Result<(), String> { + // Given + let _state = crate::test_state::TestStateGuard::new(); + let body = concat!( + "let data=0;for(let i=0;i<16;i++)data={", + "payload:data};export const card=css({props:data,_hover:{params:MISSING,p:2}});" + ); + // When + let error = compile_params(body) + .err() + .ok_or("MISSING normalized to absent params")?; + // Then + assert!(error.starts_with("/capture-params.css.ts:"), "{error}"); + assert!(error.contains("MISSING"), "{error}"); + Ok(()) +} + +#[rstest::rstest] +#[case("_hover", "[]", ":hover:hover")] +#[case("_nthChild", "['2n']", ":nth-child(2n):hover")] +#[serial] +fn bridge_deep_optional_empty_params_match_flat_selector( + #[case] outer: &str, + #[case] params: &str, + #[case] repeated: &str, +) -> Result<(), String> { + // Given + let _state = crate::test_state::TestStateGuard::new(); + let selector = format!("&{}", repeated.repeat(4096)); + let expected = compile_params(&format!( + "export const card=css({{selectors:{{'{selector}':{{p:2}}}}}});" + ))?; + let body = format!( + "let rules={{p:2}};for(let i=0;i<4096;i++)rules={{{outer}:{{params:{params},_hover:{{params:[],...rules}}}}}};export const card=css(rules);" + ); + // When + let actual = compile_params(&body)?; + // Then + assert_eq!(actual.styles, expected.styles); + assert_eq!(actual.code, expected.code); + Ok(()) +} + +#[rstest::rstest] +#[case( + "({params:undefined,props:undefined})", + r#"{"params":void 0,"props":undefined}"#, + true +)] +#[case( + "({props:{params:undefined,array:[,]}})", + r#"{"props":{"params":undefined,"array":[undefined]}}"#, + true +)] +#[case( + "({props:{params:new Proxy([,],{has(){throw Error('unexpected has')}})}})", + r#"{"props":{"params":[undefined]}}"#, + true +)] +#[case( + "({_is:{params:Object.setPrototypeOf(['a',,'b'],{1:'x'})}})", + r#"{"_is":{"params":["a","x","b"]}}"#, + true +)] +#[case( + concat!("({_is:{params:Object.setPrototypeOf(['a',,'b'],{", "1:undefined})}})"), + r#"{"_is":{"params":["a",undefined,"b"]}}"#, + false +)] +#[case( + "({_is:{params:Object.setPrototypeOf(['a',,'b'],{})}})", + r#"{"_is":{"params":["a",,"b"]}}"#, + true +)] +#[case( + "({_is:{params:new Proxy(['a'],{has(){throw Error('unexpected has')}})}})", + r#"{"_is":{"params":["a"]}}"#, + true +)] +#[case( + "({_is:{params:['a',,'b']}})", + r#"{"_is":{"params":["a",,"b"]}}"#, + true +)] +#[case("({_is:{params:['a',,]}})", r#"{"_is":{"params":["a",,]}}"#, true)] +#[case( + "({_is:{params:['a',undefined,'b']}})", + r#"{"_is":{"params":["a",undefined,"b"]}}"#, + false +)] +#[case( + "(Array.isArray=()=>false,({_is:{params:new Proxy(['a'],{})}}))", + r#"{"_is":{"params":["a"]}}"#, + true +)] +#[case( + "({_is:{params:new Proxy(new Proxy(['a'],{}),{})}})", + r#"{"_is":{"params":["a"]}}"#, + true +)] +fn capture_preserves_sparse_presence_and_only_normalizes_params_absence( + #[case] source: &str, + #[case] code: &str, + #[case] accepted: bool, +) -> Result<(), String> { + // Given + let names = FxHashMap::default(); + let collector = Rc::new(RefCell::new(Collector::default())); + let mut values = Values { + names: &names, + collector: &collector, + active: FxHashSet::default(), + key_slots: FxHashMap::default(), + plan: Plan::default(), + }; + let mut context = Context::default(); + let value = context + .eval(Source::from_bytes(source)) + .map_err(|error| error.to_string())?; + // When + let root = values + .capture(&value, &mut context) + .map_err(|error| error.to_string())?; + // Then + assert_eq!(values.plan.code(root), code); + let expected = if accepted { + vec![] + } else { + vec![( + "_is -> params".to_string(), + crate::extractor::selector_params::SELECTOR_PARAMS, + )] + }; + assert_eq!(validate(&values.plan, root), expected); + Ok(()) +} + +#[rstest::rstest] +#[serial] +fn revoked_proxy_array_is_a_located_build_error( + #[values(false, true)] single_css: bool, +) -> Result<(), String> { + let _state = crate::test_state::TestStateGuard::new(); + let body = "const handle=Proxy.revocable(['a'],{});handle.revoke();export const card=css({_is:{params:handle.proxy,p:2}});"; + let source = format!("import{{css}}from'@devup-ui/react';\n{body}"); + let column = body.find("css(").ok_or("missing styling call")? + 1; + let error = extract_without_source_map( + "/revoked-proxy.css.ts", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .err() + .ok_or("revoked Proxy accepted")? + .to_string(); + assert!( + error.starts_with(&format!("/revoked-proxy.css.ts:2:{column}:")), + "{error}" + ); + assert!(error.contains("cannot use `"), "{error}"); + Ok(()) +} + +#[rstest::rstest] +#[case(crate::own_css_plan::Node::Object(Vec::new()))] +#[case(crate::own_css_plan::Node::Array(Vec::new()))] +fn bridge_lowering_refuses_containers_without_scheduling_their_declarations( + #[case] container: crate::own_css_plan::Node, +) -> Result<(), String> { + // Given: exercise the Plan adapter independently of the capture-time refusal. + use crate::own_css_plan::{Node, Property}; + let plan = Plan { + nodes: vec![ + Node::Object(vec![Property { + name: "_nthChild".into(), + slot: 0, + value: 1, + }]), + Node::Object(vec![ + Property { + name: "params".into(), + slot: 1, + value: 2, + }, + Property { + name: "p".into(), + slot: 2, + value: 5, + }, + ]), + Node::Array(vec![3, 4]), + Node::Scalar(3), + container, + Node::Scalar(4), + ], + slots: ["'_nthChild'", "'params'", "'p'", "'2n'", "2"] + .map(String::from) + .into(), + roots: vec![0], + ..Plan::default() + }; + let arena = oxc_allocator::Allocator::default(); + let ast = oxc_ast::builder::AstBuilder::new(&arena); + let slots = plan + .slots + .iter() + .map(|code| { + oxc_parser::Parser::new(&arena, code, oxc_span::SourceType::mjs()) + .parse_expression() + .map_err(|error| format!("{error:?}")) + }) + .collect::, _>>()?; + // When + let result = crate::own_css_lower::RuleCompiler { + ast: &ast, + plan: &plan, + slots: &slots, + } + .lower(0); + // Then + assert_eq!(result.styles.len(), 1); + let mut errors = Vec::new(); + crate::utils::unreadable_styles(&result.styles, false, &mut errors); + assert_eq!( + errors, + vec![( + 0, + "params".to_string(), + Some(crate::extractor::selector_params::SELECTOR_PARAMS) + )] + ); + Ok(()) +} + +#[rstest::rstest] +#[case( + "Object.defineProperty({},'payload',{enumerable:true,get(){reads++;throw Error('descendant')}})" +)] +#[case("Object.defineProperty([],0,{enumerable:true,get(){reads++;throw Error('descendant')}})")] +#[case("(()=>{const entry={};entry.self=entry;return entry})()")] +#[case("(()=>{const entry=[];entry.push(entry);return entry})()")] +fn capture_refuses_params_containers_before_descendants_or_later_indices( + #[case] entry: &str, +) -> Result<(), String> { + // Given: both the rejected entry and its following index can expose a read. + let names = FxHashMap::default(); + let collector = Rc::new(RefCell::new(Collector::default())); + let location = "/early-params.css.ts:3:7"; + let mut values = Values { + names: &names, + collector: &collector, + active: FxHashSet::default(), + key_slots: FxHashMap::default(), + plan: Plan { + origin: Some((location.into(), "css".into())), + ..Plan::default() + }, + }; + let mut context = Context::default(); + let source = format!( + "let reads=0;const params=[{entry}];Object.defineProperty(params,1,{{get(){{reads++;throw Error('later')}}}});({{_is:{{params,p:2}}}})" + ); + let value = context + .eval(Source::from_bytes(&source)) + .map_err(|error| error.to_string())?; + // When + let result = values.capture(&value, &mut context); + // Then: the params refusal wins without entering either container or the next index. + assert!(result.is_err()); + let reads = context + .eval(Source::from_bytes("reads")) + .map_err(|error| error.to_string())?; + assert_eq!(reads.as_number(), Some(0.0)); + assert_eq!( + collector.borrow().declaration_errors, + vec![format!( + "{location}: {}", + crate::utils::build_time_error( + "css", + "params", + crate::extractor::selector_params::SELECTOR_PARAMS + ) + )] + ); + Ok(()) +} + +#[rstest::rstest] +#[case((":is([role=group],[data-group]):hover &", ":is([role=group],[data-group]):hover ", ""))] +#[case(("&[title='옳]:not']:host", "", "[title='옳]:not']:host"))] +#[serial] +fn bridge_complex_empty_contexts_match_independent_flat_selectors( + #[case] shape: (&str, &str, &str), + #[values(false, true)] list: bool, + #[values(false, true)] single_css: bool, +) -> Result<(), String> { + // Given: distinct grouped or quoted prefixes, with a separately branched list parent. + let _state = crate::test_state::TestStateGuard::new(); + let (template, before, after) = shape; + let before = before.repeat(4096); + let after = after.repeat(4096); + let selectors = if list { + format!("{before}&:hover{after},{before}&:host{after}") + } else { + format!("{before}&{after}") + }; + let flat = format!( + "export const card=css({{selectors:{{{}:{{p:2}}}}}});", + crate::vanilla_extract::json_string(&selectors) + ); + let compile = |body: &str| { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + extract_without_source_map( + "/complex-params.css.ts", + &format!("import{{css}}from'@devup-ui/react';{body}"), + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .map_err(|error| error.to_string()) + }; + let expected = compile(&flat)?; + let wrap = if list { + "rules={selectors:{'&:hover,&:host':rules}};" + } else { + "" + }; + let source = format!( + "let rules={{p:2}};const template={};for(let i=0;i<4096;i++)rules={{selectors:{{[template]:{{params:[],...rules}}}}}};{wrap}export const card=css(rules);", + crate::vanilla_extract::json_string(template) + ); + // When + let actual = compile(&source)?; + // Then + assert_eq!(actual.styles, expected.styles); + assert_eq!(actual.code, expected.code); + Ok(()) +} + +#[rstest::rstest] +#[case("{1:'x'}", Some("&:is(a,x,b)"))] +#[case(concat!("{", "1:undefined}"), None)] +#[case("{}", Some("&:is(a,b)"))] +#[serial] +fn bridge_inherited_params_presence_uses_native_reads_and_call_provenance( + #[case] prototype: &str, + #[case] expected: Option<&str>, + #[values(false, true)] single_css: bool, +) -> Result<(), String> { + // Given: only the inherited index's presence or value changes. + let _state = crate::test_state::TestStateGuard::new(); + let rule = "export const card=css({props:data,_is:{params:entries,p:2}});"; + let source = format!( + "import{{css}}from'@devup-ui/react';\nlet data=0;for(let i=0;i<16;i++)data={{payload:data}};const entries=Object.setPrototypeOf(['a',,'b'],{prototype});\n{rule}" + ); + // When + let result = extract_without_source_map( + "/presence-params.css.ts", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .map_err(|error| error.to_string()); + // Then + if let Some(expected) = expected { + let output = result?; + let selectors: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + crate::ExtractStyleValue::Static(style) => { + style.selector.as_ref().map(ToString::to_string) + } + _ => None, + }) + .collect(); + assert_eq!(selectors, vec![expected]); + } else { + let error = result.err().ok_or("inherited present undefined accepted")?; + let column = rule.find("css(").ok_or("missing styling call")? + 1; + assert!( + error.starts_with(&format!("/presence-params.css.ts:3:{column}:")), + "{error}" + ); + assert!( + error.contains(crate::extractor::selector_params::SELECTOR_PARAMS), + "{error}" + ); + } + Ok(()) +} + +#[rstest::rstest] +#[case(&["&:nth-child", "&:hover"], true)] +#[case(&["&:nth-child", "=2n", "&:hover"], false)] +#[case(&["&:nth-child:nth-of-type", "=2n"], true)] +#[case(&["&:nth-child:nth-of-type", "=2n", "=odd"], false)] +#[case(&["&:nth-child", ":is(&)"], false)] +#[case(&["&:hover", ":is([role=group],[data-group]):nth-child &"], true)] +#[case(&["&:nth-child", "&[title='옳]:not']:host"], false)] +#[case(&["&:nth", "&-child > a"], true)] +#[case(&["&:nth-child,&:host", "&:hover"], true)] +fn params_target_summary_matches_canonical_nesting( + #[case] operations: &[&str], + #[case] required: bool, +) { + let mut contexts = crate::own_css_context::Contexts::default(); + let mut path = None; + for operation in operations { + let frame = operation.strip_prefix('=').map_or_else( + || crate::own_css_context::Frame::Selector((*operation).to_string()), + |params| crate::own_css_context::Frame::Params(params.to_string()), + ); + path = Some(contexts.push(path, frame)); + } + assert_eq!(contexts.requires_argument(path), required); +} + +#[test] +fn fresh_target_restores_authored_requiredness_after_unknown_params() { + let mut contexts = crate::own_css_context::Contexts::default(); + let outer = contexts.push( + None, + crate::own_css_context::Frame::Selector("&:nth-child".into()), + ); + let unknown = contexts.uncertain(Some(outer)); + assert!(!contexts.requires_argument(Some(unknown))); + let child = contexts.push( + Some(unknown), + crate::own_css_context::Frame::Selector("&:nth-child".into()), + ); + assert!(contexts.requires_argument(Some(child))); +} + +#[rstest::rstest] +#[case("['color:', ';']", false)] +#[case("['', '']", true)] +fn evaluated_template_placement_owns_params_validation( + #[case] raw: &str, + #[case] statement: bool, +) -> Result<(), String> { + let collector = Rc::new(RefCell::new(Collector { + origin: Some(("/placement.css.ts:2:5".into(), "css".into())), + ..Collector::default() + })); + let mut context = Context::default(); + let parts = context + .eval(Source::from_bytes(&format!( + "Object.assign({raw},{{raw:{raw}}})" + ))) + .map_err(|error| error.to_string())?; + let value = context + .eval(Source::from_bytes(concat!( + "let data=0;for(let i=0;i<16;i++)data={", + "payload:data};({props:data,_is:{params:[],p:2}})" + ))) + .map_err(|error| error.to_string())?; + super::super::super::call(&collector, &[parts, value], &mut context) + .map_err(|error| error.to_string())?; + let collector = collector.borrow(); + assert_eq!(collector.styles.styles.len(), 1); + if statement { + assert!( + collector + .declaration_errors + .iter() + .any(|error| error.contains(crate::extractor::selector_params::SELECTOR_PARAMS)) + ); + } else { + assert_eq!(collector.declaration_errors, Vec::::new()); + } + Ok(()) +} + +#[test] +fn capture_preserves_root_and_nested_array_order_with_once_only_getters() -> Result<(), String> { + // Given: native array getters expose both repeated reads and traversal order. + let names = FxHashMap::default(); + let collector = Rc::new(RefCell::new(Collector::default())); + let mut values = Values { + names: &names, + collector: &collector, + active: FxHashSet::default(), + key_slots: FxHashMap::default(), + plan: Plan::default(), + }; + let mut context = Context::default(); + let value = context + .eval(Source::from_bytes(concat!( + "let reads=[];const nested=Object.defineProperties([],{", + "0:{get(){reads.push('nested:0');return 2}},", + "1:{get(){reads.push('nested:1');return 'last'}}});", + "Object.defineProperties([],{", + "0:{get(){reads.push('root:0');return 'first'}},", + "1:{get(){reads.push('root:1');return nested}},", + "2:{get(){reads.push('root:2');return null}}})" + ))) + .map_err(|error| error.to_string())?; + // When + let root = values + .capture(&value, &mut context) + .map_err(|error| error.to_string())?; + // Then: the array graph and values survive without duplicate getter evaluation. + assert_eq!(values.plan.code(root), r#"["first",[2,"last"],null]"#); + let reads = context + .eval(Source::from_bytes("reads.join(',')")) + .map_err(|error| error.to_string())?; + assert_eq!( + reads.as_string().map(|text| text.to_std_string_escaped()), + Some("root:0,root:1,root:2,nested:0,nested:1".into()) + ); + Ok(()) +} + +#[rstest::rstest] +#[case("{}", false)] +#[case("'not-a-list'", false)] +#[case("['2n']", true)] +#[serial] +fn bridge_rejects_invalid_params_roots_inside_data_named_layers( + #[case] value: &str, + #[case] accepted: bool, + #[values(false, true)] single_css: bool, +) -> Result<(), String> { + // Given: props is a layer name, and its captured descendants retain their real node shapes. + let _state = crate::test_state::TestStateGuard::new(); + let rule = "export const card=css(rules);"; + let source = format!( + "import{{css}}from'@devup-ui/react';\nlet data=0;for(let i=0;i<16;i++)data={{payload:data}};const rules={{props:data,'@layer':{{props:{{_nthChild:{{params:{value},p:2}}}}}}}};\n{rule}" + ); + let column = rule.find("css(").ok_or("missing real css call")? + 1; + // When + let result = extract_without_source_map( + "/layer-params.css.ts", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ); + // Then + if accepted { + let output = result.map_err(|error| error.to_string())?; + assert_eq!(output.styles.len(), 2); + assert!( + output.styles.iter().any(|entry| matches!(entry, crate::ExtractStyleValue::Static(style) + if style.property() == "padding" && style.value() == "8px" + && style.layer.as_deref() == Some("props") + && style.selector.as_ref().is_some_and(|selector| selector.to_string() == "&:nth-child(2n)"))) + ); + assert!(output.styles.iter().any(|entry| matches!(entry, + crate::ExtractStyleValue::LayerOrder(order) + if order.file == "/layer-params.css.ts" && order.layers == vec!["props".to_string()] + ))); + } else { + let error = result + .err() + .ok_or("invalid params root scheduled a declaration")? + .to_string(); + assert!( + error.starts_with(&format!("/layer-params.css.ts:3:{column}:")), + "{error}" + ); + assert!( + error.contains(crate::extractor::selector_params::SELECTOR_PARAMS), + "{error}" + ); + } + Ok(()) +} diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 3420566af..f91d29475 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -91,11 +91,12 @@ use oxc_syntax::number::NumberBase; use oxc_syntax::operator::LogicalOperator; use oxc_syntax::symbol::SymbolId; +use crate::extractor::style_inputs::StyleInputs; use crate::utils::{ CLASS_NAMES_CALL, CLASS_NAMES_CHILD, CLASS_NAMES_CLASS_MAP, CLASS_NAMES_PART, CSS_PROP_VALUE, LOCAL_STYLES, ParsedStyleOrder, RUNTIME_VALUE, STYLE_OBJECT, StyleArguments, Suspends, build_time_error, call_with_values, css_prop_error, css_prop_override_error, element_error, - expression_to_style_order, fixed_value, get_key_by_literal_expression, get_str_by_property_key, + expression_to_style_order, get_key_by_literal_expression, get_str_by_property_key, get_string_by_literal_expression, get_string_by_property_key, is_pure, jsx_expression_to_style_order, key_error, readable_argument, readable_code, reads_directly, reads_spreads_once, reads_unknown, runtime_classes, runtime_value, runtime_value_error, @@ -1098,7 +1099,7 @@ impl<'a> DevupVisitor<'a> { self.style_values.read_in(&self.ast, &mut rules); let (result, authored) = crate::extractor::style_order_projection::extract_with_order_policy::( - &self.ast, + StyleInputs::with_values(&self.ast, &self.style_values), None, &mut rules, 0, @@ -3182,6 +3183,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { self.style_values .import(std::mem::take(&mut self.imported_css)); } + self.style_values.record_absence(it); self.stylex_captures.reserve(it); if self.style_values.has_scoping() { for (name, component) in &self.imported_styled { @@ -3434,7 +3436,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { errors, definition, } = extract_style_from_styled( - &self.ast, + StyleInputs::with_values(&self.ast, &self.style_values), it, Naming { split_filename: self.split_filename.as_deref(), @@ -3873,7 +3875,10 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { runtime_value, }, runtime_offset, - ) = extract_keyframes_with_location(&self.ast, arg); + ) = extract_keyframes_with_location( + StyleInputs::with_values(&self.ast, &self.style_values), + arg, + ); if let Some(value) = runtime_value { self.errors.push(( runtime_offset @@ -4068,7 +4073,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { *it = if r.is_css() { let (result, authored) = crate::extractor::style_order_projection::extract_with_order( - &self.ast, + StyleInputs::with_values(&self.ast, &self.style_values), None, if let Argument::SpreadElement(spread) = &mut call.arguments[0] { &mut spread.argument @@ -4152,7 +4157,10 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { runtime_value, }, runtime_offset, - ) = extract_keyframes_with_location(&self.ast, frames); + ) = extract_keyframes_with_location( + StyleInputs::with_values(&self.ast, &self.style_values), + frames, + ); if let Some(value) = runtime_value { self.errors.push(( runtime_offset @@ -4184,7 +4192,10 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } else { call.arguments[0].to_expression_mut() }, - &self.filename, + &crate::extractor::global_inputs::GlobalInputs { + file: &self.filename, + values: &self.style_values, + }, ); if let Some((offset, value)) = crate::dead_properties::located_value(&styles, offset, true) @@ -4242,7 +4253,10 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } = extract_global_style_from_expression( &self.ast, &mut folded, - &self.filename, + &crate::extractor::global_inputs::GlobalInputs { + file: &self.filename, + values: &self.style_values, + }, ); if let Some((offset, value)) = crate::dead_properties::located_value(&styles, offset, true) @@ -4517,6 +4531,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { ); let mut props_styles = vec![]; let ast = AstBuilder::new(self.ast.allocator()); + let absence = self.style_values.absence_proof(); let ExtractResult { styles, tag: _tag, @@ -4524,7 +4539,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { style_vars, props, } = extract_with_policy::( - &ast, + StyleInputs::with_values(&ast, &absence), None, it.arguments[1].to_expression_mut(), 0, @@ -4536,6 +4551,21 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { _ => self.visit_expression(value), }, ); + let mut unreadable = Vec::new(); + unreadable_styles(&styles, false, &mut unreadable); + for (offset, code, requirement) in unreadable { + if requirement != Some(crate::extractor::selector_params::SELECTOR_PARAMS) { + continue; + } + self.errors.push(( + if offset == 0 { it.span.start } else { offset }, + element_error( + &kind.to_string(), + &code, + requirement.unwrap_or(STYLE_OBJECT), + ), + )); + } props_styles.extend(styles); if let Some(t) = _tag { @@ -4980,8 +5010,17 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { let GlobalExtractResult { styles, style_order, - } = extract_global_style_from_expression(&self.ast, expression, &self.filename); - if let Some(value) = fixed_value(&styles) { + } = extract_global_style_from_expression( + &self.ast, + expression, + &crate::extractor::global_inputs::GlobalInputs { + file: &self.filename, + values: &self.style_values, + }, + ); + if let Some((offset, value)) = + crate::dead_properties::located_value(&styles, offset, true) + { self.errors.push(( offset, element_error(&name, &value.0, value.1.unwrap_or(RUNTIME_VALUE)), @@ -5093,8 +5132,11 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { { self.style_values.read_in(&self.ast, expression); } - let ExtractResult { styles, tag, .. } = - extract_style_from_jsx(&self.ast, &disassembled, at); + let ExtractResult { styles, tag, .. } = extract_style_from_jsx( + StyleInputs::with_values(&self.ast, &self.style_values), + &disassembled, + at, + ); props_styles.extend(styles.into_iter().rev()); tag_name = tag.unwrap_or(tag_name); } @@ -5137,7 +5179,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { &mut self.errors, ); let ExtractResult { styles, .. } = extract_style_from_expression( - &self.ast, + StyleInputs::with_values(&self.ast, &self.style_values), None, &mut spread.argument, 0, @@ -5409,6 +5451,10 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } } +#[cfg(test)] +#[path = "visit_factory_params_tests.rs"] +mod factory_params_tests; + #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { diff --git a/libs/extractor/src/visit_factory_params_tests.rs b/libs/extractor/src/visit_factory_params_tests.rs new file mode 100644 index 000000000..e99a5f32f --- /dev/null +++ b/libs/extractor/src/visit_factory_params_tests.rs @@ -0,0 +1,61 @@ +use super::*; +use oxc_allocator::Allocator; +use oxc_parser::Parser; +use oxc_span::SourceType; + +#[rstest::rstest] +#[case::factory_fallback(true)] +#[case::authored_leaf(false)] +#[serial_test::serial] +fn factory_params_use_the_call_location_when_the_leaf_has_no_offset(#[case] clear_offset: bool) { + // Given a factory call with either a generated zero-span or an authored empty params list. + let _state = crate::test_state::TestStateGuard::new(); + let arena = Allocator::default(); + let source = "import {Box} from '@devup-ui/react';import {jsx} from 'react/jsx-runtime';\njsx(Box,{_nthChild:{params:[],color:'red'}});"; + let mut parsed = Parser::new(&arena, source, SourceType::tsx()).parse(); + let Some(Statement::ExpressionStatement(statement)) = parsed.program.body.last_mut() else { + panic!("expected factory statement"); + }; + let Expression::CallExpression(call) = &mut statement.expression else { + panic!("expected factory call"); + }; + let call_offset = call.span.start; + let Expression::ObjectExpression(props) = call.arguments[1].to_expression_mut() else { + panic!("expected props"); + }; + let ObjectPropertyKind::ObjectProperty(selector) = &mut props.properties[0] else { + panic!("expected selector"); + }; + let Expression::ObjectExpression(rules) = &mut selector.value else { + panic!("expected selector rules"); + }; + let ObjectPropertyKind::ObjectProperty(params) = &mut rules.properties[0] else { + panic!("expected params"); + }; + let Expression::ArrayExpression(array) = &mut params.value else { + panic!("expected params array"); + }; + let leaf_offset = array.span.start; + if clear_offset { + array.span = oxc_span::SPAN; + } + let mut visitor = DevupVisitor::new(&arena, "factory.tsx", "@devup-ui/react", Vec::new(), None); + // When the actual factory visitor extracts its structural params. + visitor.visit_program(&mut parsed.program); + // Then zero-span errors use the call, while authored leaves keep their own location. + assert_eq!( + visitor.errors, + vec![( + if clear_offset { + call_offset + } else { + leaf_offset + }, + element_error( + "Box", + "[]", + crate::extractor::selector_params::SELECTOR_PARAMS + ), + )] + ); +}