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 ccb989a0c..4d554f5c7 100644 --- a/libs/css/src/lib.rs +++ b/libs/css/src/lib.rs @@ -14,6 +14,7 @@ pub mod optimize_multi_css_value; pub mod optimize_value; pub mod quoted; pub mod rm_css_comment; +pub mod selector_params; mod selector_separator; pub mod slot_reservation; pub mod style_selector; 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_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 9d38fd88e..aade89d6c 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,10 +28,10 @@ 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 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, @@ -63,7 +56,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>, ) { @@ -91,7 +84,7 @@ fn collect_global_styles<'a>( collect_global_styles( ast_builder, &mut entry.value, - file, + inputs, &nested, styles, ); @@ -115,7 +108,7 @@ fn collect_global_styles<'a>( collect_global_styles( ast_builder, &mut o.value, - file, + inputs, at_rules, &mut layered, ); @@ -124,168 +117,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 @@ -317,7 +159,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| { @@ -326,7 +168,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, @@ -356,7 +201,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 fe1af3863..bff7e5564 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}, @@ -15,7 +16,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, @@ -222,7 +222,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, @@ -230,7 +230,7 @@ pub fn extract_style_from_expression<'a>( literal_handling: LiteralHandling, ) -> ExtractResult<'a> { extract_with_policy::( - ast_builder, + inputs, name, expression, level, @@ -242,7 +242,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,6 +250,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( literal_handling: LiteralHandling, 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; @@ -320,7 +321,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( let ExtractResult { styles, tag: _tag, .. } = extract_with_policy::( - ast_builder, + inputs, Some(disassembled), sibling.as_mut().unwrap_or(&mut prop.value), 0, @@ -341,7 +342,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( let ExtractResult { styles, tag: _tag, .. } = extract_with_policy::( - ast_builder, + inputs, None, &mut prop.argument, 0, @@ -371,7 +372,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( condition: conditional.test.clone_in(ast_builder.allocator()), consequent: Some(Box::new(ExtractStyleProp::StaticArray( extract_with_policy::( - ast_builder, + inputs, None, &mut conditional.consequent, level, @@ -383,7 +384,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( ))), alternate: Some(Box::new(ExtractStyleProp::StaticArray( extract_with_policy::( - ast_builder, + inputs, None, &mut conditional.alternate, level, @@ -423,7 +424,7 @@ pub(crate) fn extract_with_policy<'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, @@ -468,7 +469,7 @@ pub(crate) fn extract_with_policy<'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, @@ -495,7 +496,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( && let Some(layer) = get_string_by_property_key(&o.key) { let mut styles = extract_with_policy::( - ast_builder, + inputs, None, &mut o.value, level, @@ -526,7 +527,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( { props.extend( extract_with_policy::( - ast_builder, + inputs, None, &mut o.value, level, @@ -548,7 +549,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( let offset = expression.span().start; return ExtractResult { styles: extract_under_key::( - ast_builder, + inputs, (name, offset), expression, level, @@ -675,7 +676,7 @@ pub(crate) fn extract_with_policy<'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 { @@ -828,7 +829,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( LogicalOperator::Coalesce => nullish, }; extract_with_policy::( - ast_builder, + inputs, name, if takes_right { &mut logical.right @@ -846,7 +847,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( { let consequent = literal_truthiness(&conditional.test) == Some(true); extract_with_policy::( - ast_builder, + inputs, name, if consequent { &mut conditional.consequent @@ -862,7 +863,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( Expression::LogicalExpression(logical) => { let res = Some(Box::new(ExtractStyleProp::StaticArray( extract_with_policy::( - ast_builder, + inputs, name, &mut logical.right, level, @@ -912,7 +913,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( ), consequent: Some(Box::new(ExtractStyleProp::StaticArray( extract_with_policy::( - ast_builder, + inputs, name, &mut logical.left, level, @@ -945,7 +946,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( if let Some(element) = element.as_expression_mut() { props.extend( extract_with_policy::( - ast_builder, + inputs, name, element, idx as u8, @@ -968,7 +969,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( Expression::ConditionalExpression(conditional) => { if is_same_expression(&conditional.consequent, &conditional.alternate) { extract_with_policy::( - ast_builder, + inputs, name, &mut conditional.consequent, level, @@ -982,7 +983,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( condition: conditional.test.clone_in(ast_builder.allocator()), consequent: Some(Box::new(ExtractStyleProp::StaticArray( extract_with_policy::( - ast_builder, + inputs, name, &mut conditional.consequent, level, @@ -994,7 +995,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( ))), alternate: Some(Box::new(ExtractStyleProp::StaticArray( extract_with_policy::( - ast_builder, + inputs, name, &mut conditional.alternate, level, @@ -1012,48 +1013,15 @@ pub(crate) fn extract_with_policy<'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 { @@ -1061,7 +1029,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( ObjectPropertyKind::SpreadProperty(spread) => { props.extend( extract_with_policy::( - ast_builder, + inputs, None, &mut spread.argument, level, @@ -1095,7 +1063,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( .then(|| o.value.clone_in_with_semantic_ids(ast_builder.allocator())); props.extend( extract_with_policy::( - ast_builder, + inputs, Some(name), sibling.as_mut().unwrap_or(&mut o.value), level, @@ -1370,7 +1338,7 @@ impl From> for Nested { } fn extract_under_key<'a, const LOCATE: bool>( - ast_builder: &AstBuilder<'a>, + inputs: StyleInputs<'_, 'a>, located_key: (&str, u32), expression: &mut Expression<'a>, level: u8, @@ -1382,7 +1350,7 @@ fn extract_under_key<'a, const LOCATE: bool>( match nest_selectors_key(selector.as_ref(), key) { Nested::Under(nested) => { extract_with_policy::( - ast_builder, + inputs, None, expression, level, @@ -1572,7 +1540,7 @@ mod tests { }; let result = extract_style_from_expression( - &AstBuilder::new(&allocator), + (&AstBuilder::new(&allocator)).into(), Some("selectors"), &mut statement.expression, 0, @@ -1606,7 +1574,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 853908060..7cd8064c8 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -793,7 +793,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<'_>, imports: Kinds<'_>, @@ -801,6 +801,7 @@ pub fn extract_style_from_styled<'a>( inherited: Option<&StyledDefinition<'a>>, context: StyledContext<'_>, ) -> StyledExtraction<'a> { + let ast_builder = inputs.ast; let Naming { split_filename, markers, @@ -1027,7 +1028,7 @@ pub fn extract_style_from_styled<'a>( props, .. } = extract_style_from_expression( - 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..f0c1c991b --- /dev/null +++ b/libs/extractor/src/extractor/global_inputs/fonts.rs @@ -0,0 +1,206 @@ +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())]) + ); + } +} 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 e661a4e3c..ddf1679a3 100644 --- a/libs/extractor/src/extractor/mod.rs +++ b/libs/extractor/src/extractor/mod.rs @@ -9,6 +9,9 @@ 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(crate) mod selector_params; +pub(crate) mod style_inputs; pub(super) mod styled_capability; pub(super) mod styled_css_slot; pub(super) mod styled_jsx; diff --git a/libs/extractor/src/extractor/selector_params.rs b/libs/extractor/src/extractor/selector_params.rs new file mode 100644 index 000000000..730361936 --- /dev/null +++ b/libs/extractor/src/extractor/selector_params.rs @@ -0,0 +1,81 @@ +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"; + +fn reject<'a>(expression: &Expression<'a>) -> Box> { + Box::new(super::extract_style_from_expression::misplaced( + expression.span().start, + readable_code(expression), + SELECTOR_PARAMS, + )) +} + +fn requires_argument(selector: &StyleSelector) -> bool { + css::selector_params::parameter_name(selector) + .is_some_and(|name| !matches!(name, "host" | "cue" | "cue-region")) +} + +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 required = requires_argument(selector); + let array = match unwrap_syntax_only(value) { + Expression::ArrayExpression(array) => array, + _ if !required && inputs.absent(value) => return Ok(Some(selector.clone())), + _ => return Err(reject(value)), + }; + let mut params = String::new(); + let mut empty_argument = None; + for element in &array.elements { + let expression = match element { + ArrayExpressionElement::SpreadElement(spread) => return Err(reject(&spread.argument)), + ArrayExpressionElement::Elision(_) => continue, + _ => element.to_expression(), + }; + let Some(text) = get_string_by_literal_expression(expression) else { + return Err(reject(expression)); + }; + if text.is_empty() { + empty_argument = empty_argument.or(Some(expression)); + continue; + } + if !params.is_empty() { + params.push(','); + } + params.push_str(&text); + } + if params.is_empty() { + return if required { + Err(reject(empty_argument.unwrap_or(value))) + } else { + Ok(Some(selector.clone())) + }; + } + Ok(Some(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..e56882ba7 --- /dev/null +++ b/libs/extractor/src/extractor/style_inputs.rs @@ -0,0 +1,52 @@ +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<'a>>, +} + +impl<'s, 'a> StyleInputs<'s, 'a> { + pub const fn with_values(ast: &'s AstBuilder<'a>, values: &'s StyleValues<'a>) -> 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(super) 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, + } +} diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index 89fc2d516..482ddabe3 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -1350,7 +1350,7 @@ impl<'p, 'a> ModuleScope<'p, 'a> { style_order, .. } = extract_style_from_expression( - &builder, + (&builder).into(), None, &mut rules, 0, diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 81881f1b2..f284e35b5 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -62,6 +62,8 @@ mod tailwind_motion; mod tailwind_theme; #[doc(hidden)] pub mod test_state; +#[cfg(test)] +mod unresolved_global_inputs_tests; mod util_type; mod utils; mod vanilla_extract; @@ -660,10 +662,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( @@ -10351,79 +10357,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], @@ -10458,23 +10408,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"], @@ -10482,16 +10427,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] @@ -21472,7 +21418,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/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 421302220..2aa8a35b5 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}; @@ -41,6 +42,7 @@ pub struct StyleValues<'a> { texts: FxHashMap, rules: FxHashMap>, calls: FxHashMap>, + absent_references: FxHashSet, } impl<'a> StyleValues<'a> { @@ -54,6 +56,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..aa6b0b2d1 --- /dev/null +++ b/libs/extractor/src/style_values/absence.rs @@ -0,0 +1,157 @@ +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)); + } +} 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..60f779735 --- /dev/null +++ b/libs/extractor/src/unresolved_global_inputs_tests/coverage_edges.rs @@ -0,0 +1,63 @@ +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]); +} 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..85cf34517 --- /dev/null +++ b/libs/extractor/src/unresolved_global_inputs_tests/optional_params.rs @@ -0,0 +1,90 @@ +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:")); +} 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/visit.rs b/libs/extractor/src/visit.rs index 4e0eb49a4..31ce9e2a6 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -75,11 +75,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, @@ -860,7 +861,7 @@ impl<'a> DevupVisitor<'a> { style_order, .. } = extract_with_policy::( - &self.ast, + StyleInputs::with_values(&self.ast, &self.style_values), None, &mut rules, 0, @@ -3003,6 +3004,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() { self.selected_components = crate::style_values::selected(it, &self.style_values); @@ -3205,7 +3207,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(), @@ -3625,7 +3627,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 @@ -3805,7 +3810,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { style_order, .. } = extract_style_from_expression( - &self.ast, + StyleInputs::with_values(&self.ast, &self.style_values), None, if let Argument::SpreadElement(spread) = &mut call.arguments[0] { &mut spread.argument @@ -3867,7 +3872,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { keyframes, runtime_value, }, runtime_offset) = crate::extractor::extract_keyframes_from_expression::extract_keyframes_with_location( - &self.ast, + StyleInputs::with_values(&self.ast, &self.style_values), if let Argument::SpreadElement(spread) = &mut call.arguments[0] { &mut spread.argument } else { @@ -3905,7 +3910,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) @@ -3963,7 +3971,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) @@ -4230,6 +4241,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, @@ -4237,7 +4249,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, @@ -4249,6 +4261,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 { @@ -4679,8 +4706,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)), @@ -4782,8 +4818,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); } @@ -4821,7 +4860,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } // Extract styles from spread attributes (e.g., {...{"@media": {...}}}) let ExtractResult { styles, .. } = extract_style_from_expression( - &self.ast, + StyleInputs::with_values(&self.ast, &self.style_values), None, &mut spread.argument, 0,