diff --git a/.changepacks/changepack_log_css_output_edges.json b/.changepacks/changepack_log_css_output_edges.json new file mode 100644 index 00000000..5d5645a5 --- /dev/null +++ b/.changepacks/changepack_log_css_output_edges.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "apps/landing/package.json": "Patch" + }, + "note": "Preserve unitless legacy vendor numbers, classify vendor time values as milliseconds and emit the Opera vendor prefix correctly; reject nested responsive arrays and known dead unprefixed CSS property names with source-located build errors, while preserving supported experimental CSS, unknown properties and documented shorthands.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_selectors.json b/.changepacks/changepack_log_selectors.json new file mode 100644 index 00000000..4b6510b6 --- /dev/null +++ b/.changepacks/changepack_log_selectors.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Selectors: a nested rule without `&` in a styled or css template is scoped to the component instead of becoming a global rule (`.child {}` and `> p {}` select descendants, `:hover {}` and `::before {}` the component), and a rule nested in a selector list applies under each of its selectors; `content` keeps the quotes of its strings (`content: 'x'`, `content: \"\"`); `params` go on the pseudo-class that takes them, so `_groupNthChild` gives `:is(...):nth-child(2n) .a` also when nested; a bare `selectors` key is a pseudo-class name, an attribute of the element or a descendant selector (`'div p'`, `'> p'`), under a parent too, and keeps the case of class names; `::part()`, `::slotted()`, `::highlight()`, `::picker()` and the view-transition pseudo-elements get two colons. New build errors: a `_name` prop or selector key that names nothing (`_notASelector`, `selectors={{ div: ... }}`), and a string under a selector that is not CSS text (`_hover=\"external-class\"`)", + "date": "2026-09-30T00:00:00.000Z" +} diff --git a/apps/landing/src/app/(detail)/docs/api/box/page.mdx b/apps/landing/src/app/(detail)/docs/api/box/page.mdx index a81f55be..b5c25c83 100644 --- a/apps/landing/src/app/(detail)/docs/api/box/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/box/page.mdx @@ -64,12 +64,12 @@ However, the following properties are exceptions and **are not multiplied by 4** - `grid-column`, `grid-column-start`, `grid-column-end` - `grid-row`, `grid-row-start`, `grid-row-end` - `animation-iteration-count` -- `tab-size`, `moz-tab-size`, `-webkit-line-clamp` +- `tab-size`, `-moz-tab-size`, `-webkit-line-clamp` - `grid-area` - `column-count`, `columns`, `orphans`, `widows`, `zoom` - `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width` - `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width` -- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold` +- `initial-letter`, `max-lines`, `shape-image-threshold` - `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit` ### Rendering as Another Element diff --git a/apps/landing/src/app/(detail)/docs/api/button/page.mdx b/apps/landing/src/app/(detail)/docs/api/button/page.mdx index a79e01c5..a17d1504 100644 --- a/apps/landing/src/app/(detail)/docs/api/button/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/button/page.mdx @@ -67,10 +67,10 @@ However, the following properties are exceptions and **are not multiplied by 4** - `grid-column`, `grid-column-start`, `grid-column-end` - `grid-row`, `grid-row-start`, `grid-row-end` - `animation-iteration-count` -- `tab-size`, `moz-tab-size`, `-webkit-line-clamp` +- `tab-size`, `-moz-tab-size`, `-webkit-line-clamp` - `grid-area` - `column-count`, `columns`, `orphans`, `widows`, `zoom` - `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width` - `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width` -- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold` +- `initial-letter`, `max-lines`, `shape-image-threshold` - `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit` diff --git a/apps/landing/src/app/(detail)/docs/api/center/page.mdx b/apps/landing/src/app/(detail)/docs/api/center/page.mdx index 0ac65ae8..ca80b77b 100644 --- a/apps/landing/src/app/(detail)/docs/api/center/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/center/page.mdx @@ -84,12 +84,12 @@ However, the following properties are exceptions and **are not multiplied by 4** - `grid-column`, `grid-column-start`, `grid-column-end` - `grid-row`, `grid-row-start`, `grid-row-end` - `animation-iteration-count` -- `tab-size`, `moz-tab-size`, `-webkit-line-clamp` +- `tab-size`, `-moz-tab-size`, `-webkit-line-clamp` - `grid-area` - `column-count`, `columns`, `orphans`, `widows`, `zoom` - `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width` - `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width` -- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold` +- `initial-letter`, `max-lines`, `shape-image-threshold` - `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit`
diff --git a/apps/landing/src/app/(detail)/docs/api/grid/page.mdx b/apps/landing/src/app/(detail)/docs/api/grid/page.mdx index 01c06c30..4f9a5b44 100644 --- a/apps/landing/src/app/(detail)/docs/api/grid/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/grid/page.mdx @@ -76,12 +76,12 @@ However, the following properties are exceptions and **are not multiplied by 4** - `grid-column`, `grid-column-start`, `grid-column-end` - `grid-row`, `grid-row-start`, `grid-row-end` - `animation-iteration-count` -- `tab-size`, `moz-tab-size`, `-webkit-line-clamp` +- `tab-size`, `-moz-tab-size`, `-webkit-line-clamp` - `grid-area` - `column-count`, `columns`, `orphans`, `widows`, `zoom` - `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width` - `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width` -- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold` +- `initial-letter`, `max-lines`, `shape-image-threshold` - `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit`
diff --git a/apps/landing/src/app/(detail)/docs/api/selector/page.mdx b/apps/landing/src/app/(detail)/docs/api/selector/page.mdx index ce48b544..cf21bfc8 100644 --- a/apps/landing/src/app/(detail)/docs/api/selector/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/selector/page.mdx @@ -183,6 +183,49 @@ The attributes defined in Pseudo-Class correspond to those defined in Selector, +Any other pseudo-class or pseudo-element works the same way, as `_nthChild` for `:nth-child` or `_placeholder` for `::placeholder`. A name that selects nothing, such as `_notASelector`, is a build error rather than CSS that matches nothing. + +Pseudo-classes that take arguments get them from `params`, also in their group form: + +```tsx +const example = ( + +) +// :is([role=group],[data-group]):nth-child(odd) .a{color:red} +// .b:nth-child(2n){background:gray} +``` + +A selector takes styles: an object, or CSS text such as `_hover="color: red"`. Any other string, such as a class name, is a build error. + +## `selectors` + +`selectors` takes any selector. A key with `&` says where the element goes; a key without `&` is relative to the element: + +- a pseudo-class name, as `hover` or `focusVisible`, is that pseudo-class of the element; a name that selects nothing, such as `div`, is a build error +- a key starting with `:`, `::` or `[` applies to the element itself, as `':hover'` or `'[aria-busy]'` +- any other selector selects descendants, as `'.child'`, `'> p'` or `'div p'` + +```tsx +const example = ( + p': { m: 0 }, + }} + /> +) +// .a:hover{color:red} +// .parent .b{color:red} +// .c[aria-busy]{opacity:.5} +// .d > p{margin:0} +``` + +Nested rules in `styled` and `css` templates follow styled-components and Emotion: `.child { }` and `> p { }` select descendants, `:hover { }` and `::before { }` apply to the component, and a rule nested in a selector list applies under each of its selectors. + ## Media Shorthands These props wrap their styles in a fixed media query. They accept responsive arrays and nest in either direction with selectors and other at-rules. diff --git a/apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx b/apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx index fb7b8b52..63a83381 100644 --- a/apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx @@ -82,12 +82,12 @@ However, the following properties are exceptions and **are not multiplied by 4** - `grid-column`, `grid-column-start`, `grid-column-end` - `grid-row`, `grid-row-start`, `grid-row-end` - `animation-iteration-count` -- `tab-size`, `moz-tab-size`, `-webkit-line-clamp` +- `tab-size`, `-moz-tab-size`, `-webkit-line-clamp` - `grid-area` - `column-count`, `columns`, `orphans`, `widows`, `zoom` - `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width` - `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width` -- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold` +- `initial-letter`, `max-lines`, `shape-image-threshold` - `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit`
diff --git a/bindings/devup-ui-wasm/src/css_output_edge_tests.rs b/bindings/devup-ui-wasm/src/css_output_edge_tests.rs new file mode 100644 index 00000000..4d667635 --- /dev/null +++ b/bindings/devup-ui-wasm/src/css_output_edge_tests.rs @@ -0,0 +1,290 @@ +use super::{ + ImportAlias, Output, code_extract_without_source_map_internal, set_debug, with_style_sheet_mut, +}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; +use sheet::StyleSheet; +use std::collections::BTreeMap; +use std::collections::HashMap; + +fn compile(source: &str) -> Result { + compile_file("css-edges.tsx", source) +} + +fn compile_file(filename: &str, source: &str) -> Result { + with_style_sheet_mut(|sheet| *sheet = StyleSheet::default()); + reset_class_map(); + reset_file_map(); + set_debug(true); + let output = code_extract_without_source_map_internal( + filename, + source, + "@devup-ui/react", + "@devup-ui/react".to_string(), + true, + false, + false, + HashMap::from([( + "@vanilla-extract/css".to_string(), + ImportAlias::NamedToNamed, + )]), + ); + set_debug(false); + output +} + +#[rstest] +#[case("flex", "flex", "3")] +#[case("flexGrow", "flex-grow", "3")] +#[case("flexShrink", "flex-shrink", "3")] +#[case("order", "order", "3")] +#[case("msFlex", "-ms-flex", "3")] +#[case("msFlexOrder", "-ms-flex-order", "3")] +#[case("msFlexPositive", "-ms-flex-positive", "3")] +#[case("msFlexNegative", "-ms-flex-negative", "3")] +#[case("WebkitBoxFlex", "-webkit-box-flex", "3")] +#[case("WebkitBoxOrdinalGroup", "-webkit-box-ordinal-group", "3")] +#[case("WebkitLineClamp", "-webkit-line-clamp", "3")] +#[case("MozTabSize", "-moz-tab-size", "3")] +#[case("msGridColumnSpan", "-ms-grid-column-span", "3")] +#[case("WebkitAnimationDuration", "-webkit-animation-duration", "3ms")] +#[case("WebkitTransitionDelay", "-webkit-transition-delay", "3ms")] +#[case("MozAnimationDelay", "-moz-animation-delay", "3ms")] +#[case("msTransitionDuration", "-ms-transition-duration", "3ms")] +#[case("OAnimationDuration", "-o-animation-duration", "3ms")] +#[case("OTransitionDelay", "-o-transition-delay", "3ms")] +#[case("p", "padding", "12px")] +#[case("WebkitBorderRadius", "-webkit-border-radius", "12px")] +#[serial] +fn numeric_edges_emit_css( + #[case] key: &str, + #[case] property: &str, + #[case] value: &str, +) -> Result<(), String> { + let source = + format!("import {{ Box }} from '@devup-ui/react';\nconst e = ;"); + let output = compile(&source)?; + let css = output.css().ok_or("expected emitted CSS")?; + assert!(css.contains(&format!("{{{property}:{value}}}")), "{css}"); + Ok(()) +} + +#[test] +#[serial] +fn flat_responsive_values_emit_css_after_base() -> Result<(), String> { + let source = "import { Box } from '@devup-ui/react';\nconst e = ;"; + let output = compile(source)?; + let css = output.css().ok_or("expected emitted CSS")?; + let expected = format!( + ".padding-0-4px--255{{padding:{base}}}@media(min-width:768px){{.padding-2-8px--255{{padding:{responsive}}}}}", + base = "4px", + responsive = "8px" + ); + assert!(css.ends_with(&expected), "{css}"); + Ok(()) +} + +#[test] +#[serial] +fn selector_escape_hatches_emit_scoped_css() -> Result<(), String> { + let source = "import { Box } from '@devup-ui/react';\nconst e = p': { color: 'blue' } }} />;"; + let output = compile(source)?; + let css = output.css().ok_or("expected emitted CSS")?; + let hover = format!(":hover{{color:{color}}}", color = "red"); + let child = format!(" > p{{color:{color}}}", color = "blue"); + assert!(css.contains(&hover), "{css}"); + assert!(css.contains(&child), "{css}"); + assert!(!css.contains(":body{"), "{css}"); + Ok(()) +} + +#[test] +#[serial] +fn supported_experimental_and_unknown_properties_still_emit_css() -> Result<(), String> { + let source = "import { Box } from '@devup-ui/react';\nconst e = ;"; + let output = compile(source)?; + let css = output.css().ok_or("expected emitted CSS")?; + for declaration in [ + "stroke-color:red", + "ime-mode:active", + "future-property:custom", + "-webkit-box-orient:vertical", + "padding:8px", + ] { + assert!(css.contains(&format!("{{{declaration}}}")), "{css}"); + } + Ok(()) +} + +#[rstest] +#[case("import { Box } from '@devup-ui/react';\nconst e = ;")] +#[case("import { css } from '@devup-ui/react';\nconst c = css({ boxAlign: 'center' });")] +#[case("import { styled } from '@devup-ui/react';\nconst S = styled.div`box-align: center;`;")] +#[case( + "import { globalCss } from '@devup-ui/react';\nglobalCss({ body: { boxAlign: 'center' } });" +)] +#[case( + "import { keyframes } from '@devup-ui/react';\nconst k = keyframes({ from: { boxAlign: 'center' } });" +)] +#[serial] +fn dead_properties_fail_before_css_emission(#[case] source: &str) -> Result<(), String> { + let error = compile(source) + .err() + .ok_or("expected a declaration error")?; + assert!(error.starts_with("css-edges.tsx:2:"), "{error}"); + assert!(error.contains("align-items"), "{error}"); + Ok(()) +} + +#[test] +#[serial] +fn nested_responsive_arrays_fail_before_css_emission() -> Result<(), String> { + let source = "import { Box } from '@devup-ui/react';\nconst e = ;"; + let error = compile(source) + .err() + .ok_or("expected a responsive array error")?; + assert!(error.starts_with("css-edges.tsx:2:24:"), "{error}"); + assert!(error.contains("responsive arrays must be flat"), "{error}"); + Ok(()) +} + +#[rstest] +#[case(r"const c = css('color:red;\nbox-align:center');")] +#[case(r"const c = css('box\u002dalign:center');")] +#[serial] +fn escaped_string_css_dead_names_fail_before_emission( + #[case] statement: &str, +) -> Result<(), String> { + let source = format!("import {{ css }} from '@devup-ui/react';\n{statement}"); + let error = compile(&source) + .err() + .ok_or("expected a declaration error")?; + assert!(error.starts_with("css-edges.tsx:2:"), "{error}"); + assert!(error.contains("align-items"), "{error}"); + Ok(()) +} + +#[test] +#[serial] +fn configured_shorthand_names_keep_their_valid_targets() -> Result<(), String> { + css::set_custom_shorthands(BTreeMap::from([( + "boxAlign".to_string(), + vec!["align-items".to_string()], + )])); + let source = "import { Box } from '@devup-ui/react';\nconst e = ;"; + let output = compile(source); + css::set_custom_shorthands(BTreeMap::new()); + let css = output?.css().ok_or("expected emitted CSS")?; + assert!(css.contains("{align-items:center}"), "{css}"); + assert!(!css.contains("{box-align:"), "{css}"); + Ok(()) +} + +#[rstest] +#[case( + "import { style } from '@vanilla-extract/css';\nconst makeStyle = style;\nexport const invalid = makeStyle({ selectors: { '&:hover': { boxOrient: 'vertical' } } });", + 3 +)] +#[case( + "import * as ve from '@vanilla-extract/css';\nexport const invalid = ve['style']({ boxAlign: 'center' });", + 2 +)] +#[case( + "import { style } from '@vanilla-extract/css';\nconst property = 'boxAlign';\nexport const invalid = style({ [property]: 'center' });", + 3 +)] +#[serial] +fn evaluated_declarations_report_authored_calls( + #[case] source: &str, + #[case] line: usize, +) -> Result<(), String> { + let error = compile_file("css-edges.css.ts", source) + .err() + .ok_or("expected a declaration error")?; + assert!( + error.starts_with(&format!("css-edges.css.ts:{line}:")), + "{error}" + ); + assert!( + error.contains("boxOrient") || error.contains("boxAlign"), + "{error}" + ); + Ok(()) +} + +#[rstest] +#[case( + "import { style } from '@vanilla-extract/css'; const p = 'boxAlign'; export const invalid = style({ [p]: 'center' });", + "style({" +)] +#[case( + "import { style } from '@vanilla-extract/css'; export const invalid = style({ boxAlign: 'center' });", + "boxAlign:" +)] +#[serial] +fn same_line_import_aliases_keep_authored_columns( + #[case] source: &str, + #[case] token: &str, +) -> Result<(), String> { + let column = source.find(token).ok_or("fixture has no expected token")? + 1; + let error = compile_file("css-edges.css.ts", source) + .err() + .ok_or("expected a declaration error")?; + assert!( + error.starts_with(&format!("css-edges.css.ts:1:{column}:")), + "{error}" + ); + Ok(()) +} + +#[test] +#[serial] +fn computed_css_text_reports_its_authored_call() -> Result<(), String> { + let source = "import { css } from '@devup-ui/react';\nconst c = css('box-' + 'align:center');"; + let error = compile(source) + .err() + .ok_or("expected a declaration error")?; + assert!(error.starts_with("css-edges.tsx:2:11:"), "{error}"); + assert!(error.contains("align-items"), "{error}"); + Ok(()) +} + +#[test] +#[serial] +fn stylex_dead_declarations_fail_before_css_emission() -> Result<(), String> { + let statement = "export const c = stylex.create({ x: { boxAlign: 'center' } });"; + let source = format!("import * as stylex from '@stylexjs/stylex';\n{statement}"); + let column = statement.find("boxAlign").ok_or("fixture lacks property")? + 1; + let error = compile(&source) + .err() + .ok_or("expected a declaration error")?; + assert!( + error.starts_with(&format!("css-edges.tsx:2:{column}:")), + "{error}" + ); + assert!(error.contains("align-items"), "{error}"); + Ok(()) +} + +#[rstest] +#[case( + "import { style } from '@vanilla-extract/css';\nconst values = [[1,2],3];\n\n\nexport const bad = style({ p: values });", + "css-edges.css.ts:5:20:" +)] +#[case( + "import { style } from '@vanilla-extract/css';\n\n\nexport const bad = style({ p: [[1,2],3] });", + "css-edges.css.ts:4:32:" +)] +#[serial] +fn stylesheet_nested_arrays_report_authored_locations( + #[case] source: &str, + #[case] location: &str, +) -> Result<(), String> { + let error = compile_file("css-edges.css.ts", source) + .err() + .ok_or("expected a responsive array error")?; + assert!(error.starts_with(location), "{error}"); + assert!(error.contains("responsive arrays must be flat"), "{error}"); + Ok(()) +} diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 4560410f..a272e77b 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -15,6 +15,9 @@ use std::fmt::Display; use std::sync::{LazyLock, Mutex}; use wasm_bindgen::prelude::*; +#[cfg(test)] +mod css_output_edge_tests; + static GLOBAL_STYLE_SHEET: LazyLock> = LazyLock::new(|| Mutex::new(StyleSheet::default())); @@ -1551,7 +1554,7 @@ mod tests { ); // Test getters - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert_eq!(output.css_file(), Some("devup-ui-0.css".to_string())); assert_eq!(output.map(), Some("//# sourceMappingURL=test".to_string())); assert!(output.css().is_some()); @@ -1951,7 +1954,7 @@ mod tests { assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert!(output.map().is_some()); } @@ -1975,7 +1978,7 @@ mod tests { assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert!(output.map().is_none()); } @@ -2000,7 +2003,7 @@ mod tests { assert!(result.is_err()); if let Err(error) = result { - assert!(!error.is_empty()); + assert_ne!(error, ""); } } diff --git a/libs/css/src/constant.rs b/libs/css/src/constant.rs index 370ad77c..521cff71 100644 --- a/libs/css/src/constant.rs +++ b/libs/css/src/constant.rs @@ -99,7 +99,6 @@ pub(super) static GLOBAL_ENUM_STYLE_PROPERTY: phf::Map<&str, phf::Map<&str, phf: pub(super) static OPTIMIZE_MULTI_CSS_VALUE_PROPERTY: phf::Set<&str> = phf_set! { "font-family", "src", - "content", "animation-name", }; @@ -115,15 +114,130 @@ pub(super) static DOUBLE_SEPARATOR: phf::Set<&str> = phf_set! { "first-letter", "first-line", "grammar-error", + "highlight", "marker", + "part", + "picker", "picker-icon", "placeholder", "scroll-marker", "scroll-marker-group", "selection", + "slotted", "spelling-error", "target-text", - "view-transition" + "view-transition", + "view-transition-group", + "view-transition-image-pair", + "view-transition-new", + "view-transition-old" +}; + +/// The pseudo-classes and pseudo-elements that take arguments, as `params` +pub(super) static FUNCTIONAL_PSEUDOS: phf::Set<&str> = phf_set! { + "active-view-transition-type", + "cue", + "cue-region", + "dir", + "has", + "highlight", + "host", + "host-context", + "is", + "lang", + "not", + "nth-child", + "nth-last-child", + "nth-last-of-type", + "nth-of-type", + "part", + "picker", + "slotted", + "state", + "view-transition-group", + "view-transition-image-pair", + "view-transition-new", + "view-transition-old", + "where" +}; + +/// The pseudo-classes a `_name` key or a bare `selectors` key can name +pub(super) static PSEUDO_CLASSES: phf::Set<&str> = phf_set! { + "active", + "active-view-transition", + "active-view-transition-type", + "any-link", + "autofill", + "blank", + "buffering", + "checked", + "current", + "default", + "defined", + "dir", + "disabled", + "empty", + "enabled", + "first", + "first-child", + "first-of-type", + "focus", + "focus-visible", + "focus-within", + "fullscreen", + "future", + "has", + "has-slotted", + "host", + "host-context", + "hover", + "in-range", + "indeterminate", + "invalid", + "is", + "lang", + "last-child", + "last-of-type", + "left", + "link", + "local-link", + "modal", + "muted", + "not", + "nth-child", + "nth-last-child", + "nth-last-of-type", + "nth-of-type", + "only-child", + "only-of-type", + "open", + "optional", + "out-of-range", + "past", + "paused", + "picture-in-picture", + "placeholder-shown", + "playing", + "popover-open", + "read-only", + "read-write", + "required", + "right", + "root", + "scope", + "seeking", + "stalled", + "state", + "target", + "target-current", + "target-within", + "user-invalid", + "user-valid", + "valid", + "visited", + "volume-locked", + "where", + "xr-overlay" }; pub(super) static ZERO_PERCENT_FUNCTION: phf::Set<&str> = phf_set! { diff --git a/libs/css/src/is_special_property.rs b/libs/css/src/is_special_property.rs index ea75b98b..17bb6972 100644 --- a/libs/css/src/is_special_property.rs +++ b/libs/css/src/is_special_property.rs @@ -551,15 +551,7 @@ mod tests { "border-top-width", "border-width", "bottom", - "box-align", "box-decoration-break", - "box-direction", - "box-flex", - "box-flex-group", - "box-lines", - "box-ordinal-group", - "box-orient", - "box-pack", "box-shadow", "box-sizing", "break-after", @@ -854,14 +846,8 @@ mod tests { "scroll-padding-right", "scroll-padding-top", "scroll-snap-align", - "scroll-snap-coordinate", - "scroll-snap-destination", - "scroll-snap-points-x", - "scroll-snap-points-y", "scroll-snap-stop", "scroll-snap-type", - "scroll-snap-type-x", - "scroll-snap-type-y", "scroll-timeline", "scroll-timeline-axis", "scroll-timeline-name", diff --git a/libs/css/src/lib.rs b/libs/css/src/lib.rs index 576ba53b..d9810a36 100644 --- a/libs/css/src/lib.rs +++ b/libs/css/src/lib.rs @@ -20,7 +20,7 @@ use std::collections::BTreeMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{LazyLock, RwLock}; -use crate::constant::{GLOBAL_ENUM_STYLE_PROPERTY, GLOBAL_STYLE_PROPERTY}; +use crate::constant::{FUNCTIONAL_PSEUDOS, GLOBAL_ENUM_STYLE_PROPERTY, GLOBAL_STYLE_PROPERTY}; use crate::debug::is_debug; use crate::file_map::get_file_num_by_filename; use crate::num_to_nm_base::num_to_nm_base; @@ -187,9 +187,9 @@ impl ExactSizeIterator for DisassembleProperty {} #[must_use] pub fn disassemble_property(property: &str) -> DisassembleProperty { - // Nested selector keys (`&:hover`, `:focus`, `.parent &`) are not properties; - // keep them verbatim so class names and case survive. - if property.starts_with(':') || property.contains('&') { + // Nested selector keys (`&:hover`, `:focus`, `.parent &`, `_hover`) are not + // properties; keep them verbatim so class names and case survive. + if property.starts_with([':', '_']) || property.contains('&') { return DisassembleProperty::Fallback(Some(property.to_string())); } if let Some(properties) = HAS_CUSTOM_SHORTHANDS @@ -208,10 +208,9 @@ pub fn disassemble_property(property: &str) -> DisassembleProperty { GLOBAL_STYLE_PROPERTY.get(property).map_or_else( || { DisassembleProperty::Fallback(Some( - // Gate the three vendor-prefix `starts_with` scans behind a - // single first-byte check: only `W`/`M`/`m` can begin - // `Webkit`/`Moz`/`ms`, so every other property skips all three. - if matches!(property.as_bytes().first(), Some(b'W' | b'M' | b'm')) + // Gate vendor-prefix scans behind a single first-byte check: + // only `W`/`M`/`m`/`O` can begin `Webkit`/`Moz`/`ms`/`O`. + if matches!(property.as_bytes().first(), Some(b'W' | b'M' | b'm' | b'O')) && ((property.starts_with("Webkit") && property.len() > 6 && property.as_bytes()[6].is_ascii_uppercase()) @@ -220,7 +219,10 @@ pub fn disassemble_property(property: &str) -> DisassembleProperty { && property.as_bytes()[3].is_ascii_uppercase()) || (property.starts_with("ms") && property.len() > 2 - && property.as_bytes()[2].is_ascii_uppercase())) + && property.as_bytes()[2].is_ascii_uppercase()) + || (property.starts_with('O') + && property.len() > 1 + && property.as_bytes()[1].is_ascii_uppercase())) { // Build `-` directly into ONE buffer instead of allocating // a `to_kebab_case(property)` String and copying it into a second @@ -228,8 +230,8 @@ pub fn disassemble_property(property: &str) -> DisassembleProperty { // (ASCII-uppercase char → `-` before it when not first, then its // lowercase; other chars copied verbatim) after the leading `-`. // The `i != 0` guard matches `to_kebab_case`, so the vendor - // prefix's uppercase first char (`W`/`M`/`m`→lowercase) gets no - // extra `-`. Output byte-identical, one fewer allocation. + // prefix's first char (`W`/`M`/`m`/`O`→lowercase) gets no + // extra `-`, keeping the conversion in one allocation. let mut s = String::with_capacity(property.len() + 5); s.push('-'); for (i, c) in property.chars().enumerate() { @@ -287,12 +289,49 @@ pub fn get_custom_shorthand_names() -> Vec { ) } +/// `selector` with `params` on the last pseudo-class or pseudo-element that +/// takes them and has none yet, wherever nesting put it: a group form reads +/// `:is(...):nth-child(2n) &`, and a parent's selector may follow it +fn with_params(selector: &str, params: &str) -> String { + let at = params_position(selector).unwrap_or(selector.len()); + format!("{}({params}){}", &selector[..at], &selector[at..]) +} + +fn params_position(selector: &str) -> Option { + let bytes = selector.as_bytes(); + let mut depth = 0usize; + let mut found = None; + let mut index = 0; + while let Some(&byte) = bytes.get(index) { + match byte { + b'(' | b'[' => depth += 1, + b')' | b']' => depth = depth.saturating_sub(1), + b':' if depth == 0 => { + let start = index + 1 + usize::from(bytes.get(index + 1) == Some(&b':')); + let end = selector[start..] + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '-')) + .map_or(selector.len(), |length| start + length); + if FUNCTIONAL_PSEUDOS.contains(&selector[start..end]) + && bytes.get(end) != Some(&b'(') + { + found = Some(end); + } + index = end; + continue; + } + _ => {} + } + index += 1; + } + found +} + #[must_use] pub fn add_selector_params(selector: StyleSelector, params: &str) -> StyleSelector { match selector { - StyleSelector::Selector(value) => StyleSelector::Selector(format!("{value}({params})")), + StyleSelector::Selector(value) => StyleSelector::Selector(with_params(&value, params)), StyleSelector::Global(value, file) => { - StyleSelector::Global(format!("{value}({params})"), file) + StyleSelector::Global(with_params(&value, params), file) } StyleSelector::At { kind, @@ -303,7 +342,7 @@ pub fn add_selector_params(selector: StyleSelector, params: &str) -> StyleSelect } => StyleSelector::At { kind, query, - selector: selector.map(|s| format!("{s}({params})")), + selector: selector.map(|s| with_params(&s, params)), outer, file, }, @@ -1199,6 +1238,25 @@ mod tests { add_selector_params(StyleSelector::Selector("hover:is".to_string()), "test"), StyleSelector::Selector("hover:is(test)".to_string()) ); + for (selector, expected) in [ + ( + ":is([role=group],[data-group]):nth-child &", + ":is([role=group],[data-group]):nth-child(2n) &", + ), + ( + ":is([role=group],[data-group]):nth-child &:hover", + ":is([role=group],[data-group]):nth-child(2n) &:hover", + ), + ("&:not(.x):nth-child", "&:not(.x):nth-child(2n)"), + ("&[title=\":not\"]::part", "&[title=\":not\"]::part(2n)"), + ("&:hover", "&:hover(2n)"), + ] { + assert_eq!( + add_selector_params(StyleSelector::Selector(selector.to_string()), "2n"), + StyleSelector::Selector(expected.to_string()), + "{selector}" + ); + } assert_eq!( add_selector_params( StyleSelector::Global("&:is".to_string(), "file.ts".to_string()), @@ -1342,6 +1400,28 @@ mod tests { assert_ne!(class1, class3); } + #[rstest] + #[case("OAnimationDuration", "-o-animation-duration")] + #[case("OTransitionDelay", "-o-transition-delay")] + #[case("OTransform", "-o-transform")] + #[case("Order", "order")] + #[case("ObjectFit", "object-fit")] + #[case("O", "o")] + #[case("order", "order")] + #[case("objectFit", "object-fit")] + #[case("WebkitAnimationDuration", "-webkit-animation-duration")] + #[case("MozTransitionDelay", "-moz-transition-delay")] + #[case("msTransform", "-ms-transform")] + #[serial] + fn disassemble_property_when_vendor_or_ordinary_name( + #[case] property: &str, + #[case] expected: &str, + ) { + let properties = disassemble_property(property).collect::>(); + + assert_eq!(properties, [expected]); + } + #[test] #[serial] fn test_disassemble_property_size_hint() { diff --git a/libs/css/src/optimize_multi_css_value.rs b/libs/css/src/optimize_multi_css_value.rs index c9d8959d..14081e2d 100644 --- a/libs/css/src/optimize_multi_css_value.rs +++ b/libs/css/src/optimize_multi_css_value.rs @@ -173,7 +173,7 @@ mod tests { #[rstest] #[case("font-family", true)] #[case("src", true)] - #[case("content", true)] + #[case("content", false)] #[case("animation-name", true)] #[case("background", false)] #[case("color", false)] diff --git a/libs/css/src/selector_separator.rs b/libs/css/src/selector_separator.rs index db164cbf..112e9804 100644 --- a/libs/css/src/selector_separator.rs +++ b/libs/css/src/selector_separator.rs @@ -62,12 +62,17 @@ mod tests { "[aria-disabled='true']".into(), SelectorSeparator::None )); + + assert!(matches!(".child".into(), SelectorSeparator::Space)); + assert!(matches!("#id".into(), SelectorSeparator::Space)); + assert!(matches!("*".into(), SelectorSeparator::Space)); } #[test] fn test_display() { assert_eq!(SelectorSeparator::Double.to_string(), "::"); assert_eq!(SelectorSeparator::Single.to_string(), ":"); + assert_eq!(SelectorSeparator::Space.to_string(), " "); assert_eq!(SelectorSeparator::None.to_string(), ""); } } diff --git a/libs/css/src/style_selector.rs b/libs/css/src/style_selector.rs index a36bdc0f..fab4dca4 100644 --- a/libs/css/src/style_selector.rs +++ b/libs/css/src/style_selector.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; use crate::{ at_rule::{MediaCombination, combine_media_queries, media_shorthand_query, normalize_query}, - constant::SELECTOR_ORDER, + constant::{DOUBLE_SEPARATOR, PSEUDO_CLASSES, SELECTOR_ORDER}, selector_separator::SelectorSeparator, to_kebab_case, utils::{collapse_whitespace, to_camel_case}, @@ -91,6 +91,57 @@ fn collapse_owned_selector(s: String) -> String { } } +/// Whether `name`, a style key without its `_` in kebab-case, names what a +/// key can select: a pseudo-class or pseudo-element, its `group-` form, a +/// `theme-` or a media shorthand +#[must_use] +pub fn is_selector_name(name: &str) -> bool { + let pseudo = name.strip_prefix("group-").unwrap_or(name); + name.starts_with("theme-") + || media_shorthand_query(name).is_some() + || PSEUDO_CLASSES.contains(pseudo) + || DOUBLE_SEPARATOR.contains(pseudo) +} + +/// The selectors of a selector list, leaving the commas inside `:is(a, b)` +/// and `[title="a,b"]` alone +#[must_use] +pub fn split_selector_list(selector: &str) -> Vec<&str> { + let mut parts = vec![]; + let mut depth = 0usize; + let mut start = 0; + for (index, c) in selector.char_indices() { + match c { + '(' | '[' => depth += 1, + ')' | ']' => depth = depth.saturating_sub(1), + ',' if depth == 0 => { + parts.push(selector[start..index].trim()); + start = index + 1; + } + _ => {} + } + } + if !selector.is_empty() { + parts.push(selector[start..].trim()); + } + parts +} + +/// `template` with each selector of `parent` in place of its `&`, a list +/// parent giving every combination as CSS nesting does +fn substitute(template: &str, parent: &str) -> String { + let parents = split_selector_list(parent); + if parents.len() > 1 { + parents + .iter() + .map(|parent| template.replace('&', parent)) + .collect::>() + .join(",") + } else { + template.replace('&', parent) + } +} + #[must_use] pub fn optimize_selector(selector: StyleSelector) -> StyleSelector { match selector { @@ -299,9 +350,9 @@ impl StyleSelector { pub fn nest_selector(parent: Option<&Self>, template: &str) -> Self { match parent { None => Self::Selector(template.to_string()), - Some(Self::Selector(selector)) => Self::Selector(template.replace('&', selector)), + Some(Self::Selector(selector)) => Self::Selector(substitute(template, selector)), Some(Self::Global(selector, file)) => { - Self::Global(template.replace('&', selector), file.clone()) + Self::Global(substitute(template, selector), file.clone()) } Some(Self::At { kind, @@ -315,7 +366,7 @@ impl StyleSelector { selector: Some( selector .as_deref() - .map_or_else(|| template.to_string(), |s| template.replace('&', s)), + .map_or_else(|| template.to_string(), |s| substitute(template, s)), ), outer: outer.clone(), file: file.clone(), @@ -733,6 +784,34 @@ mod tests { ); } + #[rstest] + #[case("", vec![])] + #[case("&:hover", vec!["&:hover"])] + #[case(" a > b , i ", vec!["a > b", "i"])] + #[case(":is(a, b), [title=\"a,b\"]", vec![":is(a, b)", "[title=\"a,b\"]"])] + fn test_split_selector_list(#[case] selector: &str, #[case] expected: Vec<&str>) { + assert_eq!(split_selector_list(selector), expected); + } + + #[rstest] + #[case("hover", true)] + #[case("focus-visible", true)] + #[case("nth-child", true)] + #[case("before", true)] + #[case("part", true)] + #[case("group-hover", true)] + #[case("group-first-letter", true)] + #[case("theme-dark", true)] + #[case("motion-reduce", true)] + #[case("print", true)] + #[case("not-a-selector", false)] + #[case("div", false)] + #[case("group-print", false)] + #[case("webkit-autofill", false)] + fn test_is_selector_name(#[case] name: &str, #[case] expected: bool) { + assert_eq!(is_selector_name(name), expected); + } + fn at(kind: AtRuleKind, query: &str, selector: Option<&str>) -> StyleSelector { StyleSelector::At { kind, @@ -765,6 +844,16 @@ mod tests { "&:hover", at(AtRuleKind::Media, "print", Some("&:focus:hover")) )] + #[case( + Some(StyleSelector::Selector("& h1,&:is(a, b)".to_string())), + "& span,&:hover", + StyleSelector::Selector("& h1 span,& h1:hover,&:is(a, b) span,&:is(a, b):hover".to_string()) + )] + #[case( + Some(StyleSelector::Global("a, b".to_string(), "a.tsx".to_string())), + "& i", + StyleSelector::Global("a i,b i".to_string(), "a.tsx".to_string()) + )] fn test_nest_selector( #[case] parent: Option, #[case] template: &str, diff --git a/libs/css/src/theme_tokens.rs b/libs/css/src/theme_tokens.rs index d8d8fb02..3b2346ae 100644 --- a/libs/css/src/theme_tokens.rs +++ b/libs/css/src/theme_tokens.rs @@ -119,7 +119,7 @@ mod tests { set_typography_keys(vec!["body".to_string(), "title".to_string()]); assert_eq!(get_typography_keys(), vec!["body", "title"]); set_typography_keys(vec![]); - assert!(get_typography_keys().is_empty()); + assert_eq!(get_typography_keys(), Vec::::new()); } #[test] diff --git a/libs/extractor/src/css_utils.rs b/libs/extractor/src/css_utils.rs index f0d67200..6bf1f9b5 100644 --- a/libs/extractor/src/css_utils.rs +++ b/libs/extractor/src/css_utils.rs @@ -10,7 +10,7 @@ use css::{ at_rule::split_at_rule_key, optimize_multi_css_value::{check_multi_css_optimize, optimize_multi_css_value}, rm_css_comment::rm_css_comment, - style_selector::StyleSelector, + style_selector::{StyleSelector, split_selector_list}, }; use oxc_allocator::Allocator; use oxc_span::{GetSpan, SPAN}; @@ -579,29 +579,36 @@ fn collect_css_block( } /// The selector a nested block applies to: at-rule preludes wrap `parent`, -/// `&`-selectors substitute it, and a bare selector under a selector nests as -/// a descendant. `None` drops the block (an unknown at-rule, or conditions -/// that can never match together). +/// `&`-selectors substitute it, and a bare selector is made relative to it +/// (the component's own class at the top). `None` drops the block (an unknown +/// at-rule, or conditions that can never match together). fn nest_prelude(parent: Option<&StyleSelector>, prelude: &str) -> Option { if prelude.starts_with('@') { let (kind, query) = split_at_rule_key(prelude)?; return StyleSelector::nest_at_rule(parent, kind, query); } - let parent_selector = match parent { - Some(StyleSelector::Selector(selector) | StyleSelector::Global(selector, _)) => { - Some(selector) - } - Some(StyleSelector::At { selector, .. }) => selector.as_ref(), - None => None, - }; - let template = if prelude.contains('&') || parent_selector.is_none() { - Cow::Borrowed(prelude) - } else { - Cow::Owned(format!("& {prelude}")) - }; + let template = split_selector_list(prelude) + .into_iter() + .map(relative_selector) + .collect::>() + .join(","); Some(StyleSelector::nest_selector(parent, &template)) } +/// A selector of a nested rule made relative to the rule it is in, as +/// styled-components and Emotion read it: one with `&` says where that rule +/// goes, a pseudo-class or pseudo-element applies to that rule's element, and +/// any other selector to its descendants (`> p` to its children) +fn relative_selector(part: &str) -> Cow<'_, str> { + if part.contains('&') { + Cow::Borrowed(part) + } else if part.starts_with(':') { + Cow::Owned(format!("&{part}")) + } else { + Cow::Owned(format!("& {part}")) + } +} + /// Optimize a declaration's value only when its property warrants multi-value /// optimization, borrowing otherwise. Shared by `css_to_style_block` and /// `optimize_css_block`, which both made this identical decision inline. @@ -1171,7 +1178,7 @@ mod tests { #[case( "`ul { font-family: 'Roboto Hello', sans-serif; }`", vec![ - ("font-family", "\"Roboto Hello\",sans-serif", Some(StyleSelector::Selector("ul".to_string()))), + ("font-family", "\"Roboto Hello\",sans-serif", Some(StyleSelector::Selector("& ul".to_string()))), ] )] #[case( @@ -1610,14 +1617,14 @@ mod tests { #[case( "ul { font-family: 'Roboto Hello', sans-serif; }", vec![ - ("font-family", "\"Roboto Hello\",sans-serif", Some(StyleSelector::Selector("ul".to_string()))), + ("font-family", "\"Roboto Hello\",sans-serif", Some(StyleSelector::Selector("& ul".to_string()))), ] )] #[case( "div { color: red; ; { background: blue; } }", vec![ - ("color", "red", Some(StyleSelector::Selector("div".to_string()))), - ("background", "blue", Some(StyleSelector::Selector("div".to_string()))), + ("color", "red", Some(StyleSelector::Selector("& div".to_string()))), + ("background", "blue", Some(StyleSelector::Selector("& div".to_string()))), ] )] // As in CSS nesting, only the text after the last `;` is the nested rule's @@ -1626,7 +1633,7 @@ mod tests { "color:red;background:blue { width: 1px; }", vec![ ("color", "red", None), - ("width", "1px", Some(StyleSelector::Selector("background:blue".to_string()))), + ("width", "1px", Some(StyleSelector::Selector("& background:blue".to_string()))), ] )] #[case( @@ -1634,7 +1641,7 @@ mod tests { vec![( "width", "1px", - Some(StyleSelector::Selector("color:red".to_string())) + Some(StyleSelector::Selector("& color:red".to_string())) )] )] #[case( diff --git a/libs/extractor/src/dead_properties.rs b/libs/extractor/src/dead_properties.rs new file mode 100644 index 00000000..c93a8e17 --- /dev/null +++ b/libs/extractor/src/dead_properties.rs @@ -0,0 +1,143 @@ +//! Closed compatibility policy for declarations with no unprefixed implementation. + +use crate::{ + ExtractStyleProp, + utils::{Unused, fixed_value, runtime_value, unreadable_styles}, +}; +use css::utils::to_kebab_case; + +mod calls; +mod objects; +mod origin; +mod strings; +pub(crate) use origin::{evaluated_calls, map_evaluated}; +mod text; +pub(crate) use calls::instrument; +pub(crate) use objects::authored_errors; +pub(crate) use objects::{ObjectKind, evaluated_errors}; +pub(crate) const ERROR_CHANNEL: &str = "DEVUP_DEAD_DECLARATION\n"; + +pub(crate) fn terminal_error(message: &str) -> bool { + message + .split_once(" at build time: ") + .is_some_and(|(_, requirement)| { + requirement == crate::utils::RESPONSIVE_ARRAY + || requirement.starts_with("unprefixed ") + || requirement.starts_with("obsolete scroll-snap-") + }) +} +pub(crate) use text::{expression_errors, template_errors}; + +/// The replacement is advice, never an automatic alias or rewrite. +pub(crate) fn requirement(name: &str) -> Option<&'static str> { + let canonical = if name.contains('-') { + std::borrow::Cow::Owned(name.to_ascii_lowercase()) + } else { + to_kebab_case(name) + }; + Some(match canonical.as_ref() { + "box-align" => { + "unprefixed box-align has no browser implementation; use display: flex and align-items" + } + "box-pack" => { + "unprefixed box-pack has no browser implementation; use display: flex and justify-content" + } + "box-flex" => "unprefixed box-flex has no browser implementation; use flex-grow", + "box-flex-group" => { + "unprefixed box-flex-group has no browser implementation; use flex-grow on modern flex items" + } + "box-orient" => "unprefixed box-orient has no browser implementation; use flex-direction", + "box-ordinal-group" => { + "unprefixed box-ordinal-group has no browser implementation; use order" + } + "box-direction" => { + "unprefixed box-direction has no browser implementation; use flex-direction with row-reverse or column-reverse" + } + "box-lines" => "unprefixed box-lines has no browser implementation; use flex-wrap", + "flex-order" => "unprefixed flex-order is an obsolete flexbox draft property; use order", + "flex-positive" => { + "unprefixed flex-positive is an obsolete flexbox draft property; use flex-grow" + } + "flex-negative" => { + "unprefixed flex-negative is an obsolete flexbox draft property; use flex-shrink" + } + "flex-preferred-size" => { + "unprefixed flex-preferred-size is an obsolete flexbox draft property; use flex-basis" + } + "scroll-snap-points-x" => { + "obsolete scroll-snap-points-x is no longer implemented; use scroll-snap-type on the container and scroll-snap-align on its children" + } + "scroll-snap-points-y" => { + "obsolete scroll-snap-points-y is no longer implemented; use scroll-snap-type on the container and scroll-snap-align on its children" + } + "scroll-snap-coordinate" => { + "obsolete scroll-snap-coordinate is no longer implemented; use scroll-snap-align on the snap target" + } + "scroll-snap-destination" => { + "obsolete scroll-snap-destination is no longer implemented; use scroll-padding on the snap container" + } + "scroll-snap-type-x" => { + "obsolete scroll-snap-type-x is no longer implemented; use scroll-snap-type with an x axis" + } + "scroll-snap-type-y" => { + "obsolete scroll-snap-type-y is no longer implemented; use scroll-snap-type with a y axis" + } + _ => return None, + }) +} + +/// Reject a declaration at its authored key, not at its value or containing call. +pub(crate) fn declaration_error<'a>(name: &str, offset: u32) -> Option> { + Some(ExtractStyleProp::Unreadable { + offset, + code: name.to_string(), + prop: false, + requirement: Some(requirement(name)?), + }) +} + +pub(crate) fn authored_requirement(name: &str) -> Option<&'static str> { + match css::disassemble_property(name) { + css::DisassembleProperty::Custom(mut properties) => { + properties.find_map(|property| requirement(&property)) + } + css::DisassembleProperty::Mapped(mut properties) => { + properties.find_map(|property| requirement(property)) + } + css::DisassembleProperty::Fallback(_) => requirement(name), + } +} + +pub(crate) fn authored_declaration_error<'a>( + name: &str, + offset: u32, +) -> Option> { + Some(ExtractStyleProp::Unreadable { + offset, + code: name.to_string(), + prop: false, + requirement: Some(authored_requirement(name)?), + }) +} + +/// Retain the stored location while preserving the existing runtime/fixed-value fallback. +pub(crate) fn located_value( + styles: &[ExtractStyleProp<'_>], + fallback: u32, + fixed: bool, +) -> Option<(u32, Unused)> { + let mut found = Vec::new(); + unreadable_styles(styles, true, &mut found); + match found.into_iter().next() { + Some((offset, code, requirement)) => Some(( + if offset == 0 { fallback } else { offset }, + (code, requirement), + )), + None => if fixed { + fixed_value(styles) + } else { + runtime_value(styles) + } + .map(|value| (fallback, value)), + } +} diff --git a/libs/extractor/src/dead_properties/calls.rs b/libs/extractor/src/dead_properties/calls.rs new file mode 100644 index 00000000..36c146bf --- /dev/null +++ b/libs/extractor/src/dead_properties/calls.rs @@ -0,0 +1,208 @@ +//! Authored styling-call metadata carried through stylesheet execution. + +use super::objects::{ObjectKind, authored_errors}; +use oxc_ast::ast::{CallExpression, Expression, ImportDeclarationSpecifier, Statement}; +use oxc_ast_visit::{Visit, walk::walk_call_expression}; +use rustc_hash::FxHashMap; + +pub(crate) struct Instrumented { + pub code: String, + pub calls: Vec<(u32, u32)>, +} + +pub(crate) fn instrument( + code: &str, + filename: &str, + package: &str, +) -> Result { + let allocator = oxc_allocator::Allocator::default(); + let parsed = oxc_parser::Parser::new( + &allocator, + code, + oxc_span::SourceType::from_path(filename).unwrap_or_else(|_| oxc_span::SourceType::ts()), + ) + .parse(); + let semantic = oxc_semantic::SemanticBuilder::new() + .build(&parsed.program) + .semantic; + let mut imports = FxHashMap::default(); + for statement in &parsed.program.body { + if let Statement::ImportDeclaration(import) = statement + && (import.source.value == package || import.source.value == "@vanilla-extract/css") + { + for specifier in import.specifiers.iter().flatten() { + let export = match specifier { + ImportDeclarationSpecifier::ImportSpecifier(named) => { + named.imported.name().to_string() + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(_) => "*".to_string(), + ImportDeclarationSpecifier::ImportDefaultSpecifier(_) => continue, + }; + if let Some(symbol) = specifier.local().symbol_id.get() { + imports.insert(symbol, export); + } + } + } + } + let mut visitor = Calls { + code, + filename, + imports, + scoping: semantic.scoping(), + insertions: Vec::new(), + errors: Vec::new(), + ranges: Vec::new(), + }; + loop { + let previous = visitor.imports.len(); + Aliases(&mut visitor).visit_program(&parsed.program); + if visitor.imports.len() == previous { + break; + } + } + visitor.visit_program(&parsed.program); + if !visitor.errors.is_empty() { + return Err(visitor.errors.join("\n")); + } + visitor.insertions.sort_by_key(|(offset, _)| *offset); + let mut output = String::new(); + let mut copied = 0; + for (offset, insertion) in visitor.insertions { + let offset = usize::try_from(offset).map_err(|error| error.to_string())?; + output.push_str(&code[copied..offset]); + output.push_str(&insertion); + copied = offset; + } + output.push_str(&code[copied..]); + Ok(Instrumented { + code: output, + calls: visitor.ranges, + }) +} + +struct Calls<'s> { + code: &'s str, + filename: &'s str, + imports: FxHashMap, + scoping: &'s oxc_semantic::Scoping, + insertions: Vec<(u32, String)>, + errors: Vec, + ranges: Vec<(u32, u32)>, +} + +struct Aliases<'v, 's>(&'v mut Calls<'s>); + +impl<'a> Visit<'a> for Aliases<'_, '_> { + fn visit_variable_declaration(&mut self, declaration: &oxc_ast::ast::VariableDeclaration<'a>) { + if declaration.kind == oxc_ast::ast::VariableDeclarationKind::Const { + for declarator in &declaration.declarations { + if let Some(identifier) = declarator.id.get_binding_identifier() + && let Some(symbol) = identifier.symbol_id.get() + && let Some(initializer) = &declarator.init + && let Some(api) = self.0.api(initializer) + { + self.0.imports.insert(symbol, api); + } + } + } + oxc_ast_visit::walk::walk_variable_declaration(self, declaration); + } +} + +impl Calls<'_> { + fn api(&self, expression: &Expression<'_>) -> Option { + let (identifier, member) = match expression { + Expression::Identifier(identifier) => (identifier.as_ref(), None), + Expression::StaticMemberExpression(member) => match &member.object { + Expression::Identifier(identifier) => { + (identifier.as_ref(), Some(member.property.name.as_str())) + } + _ => return None, + }, + _ => return None, + }; + let symbol = self + .scoping + .get_reference(identifier.reference_id.get()?) + .symbol_id()?; + let imported = self.imports.get(&symbol)?; + match (imported.as_str(), member) { + ("*", Some(member)) => Some(member.to_string()), + ("styled", Some(_)) => Some("styled".to_string()), + (_, None) => Some(imported.clone()), + _ => None, + } + } + + fn location(&self, offset: u32) -> String { + let prefix = &self.code[..usize::try_from(offset).unwrap_or(0)]; + let line = prefix.bytes().filter(|byte| *byte == b'\n').count() + 1; + let column = prefix.rsplit('\n').next().unwrap_or("").chars().count() + 1; + format!("{}:{line}:{column}", self.filename) + } +} + +impl<'a> Visit<'a> for Calls<'_> { + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + if let Some(api) = self.api(&call.callee) { + let shape = match api.as_str() { + "style" | "css" | "fontFace" => Some((0, ObjectKind::Styles)), + "globalStyle" | "globalFontFace" => Some((1, ObjectKind::Styles)), + "keyframes" | "styleVariants" => Some((0, ObjectKind::Records)), + "globalCss" => Some((0, ObjectKind::Globals)), + "styled" => Some((call.arguments.len().saturating_sub(1), ObjectKind::Styles)), + _ => None, + }; + if let Some((argument, kind)) = shape { + self.ranges.push((call.span.start, call.span.end)); + if !(api == "styleVariants" && call.arguments.len() > 1) + && let Some(expression) = call + .arguments + .get(argument) + .and_then(|argument| argument.as_expression()) + { + for (offset, path, requirement) in authored_errors(expression, kind) { + self.errors.push(format!( + "{}: {}", + self.location(offset), + crate::utils::build_time_error(&api, &path, requirement) + )); + } + } + self.insertions.push(( + call.span.start, + format!( + "__vanilla_extract__.__at({:?}, {:?}, () => (", + self.location(call.span.start), + api + ), + )); + walk_call_expression(self, call); + self.insertions.push((call.span.end, "))".to_string())); + return; + } + } + let mut suspends = crate::utils::Suspends::default(); + for argument in &call.arguments { + if let Some(expression) = argument.as_expression() { + Visit::visit_expression(&mut suspends, expression); + } + } + let direct_eval = + matches!(&call.callee, Expression::Identifier(identifier) if identifier.name == "eval"); + if !direct_eval && !suspends.found { + self.insertions.push(( + call.span.start, + format!( + "__vanilla_extract__.__at({:?}, {:?}, () => (", + self.location(call.span.start), + crate::utils::readable_code(&call.callee), + ), + )); + walk_call_expression(self, call); + self.insertions.push((call.span.end, "))".to_string())); + return; + } + walk_call_expression(self, call); + } +} diff --git a/libs/extractor/src/dead_properties/objects.rs b/libs/extractor/src/dead_properties/objects.rs new file mode 100644 index 00000000..2974e475 --- /dev/null +++ b/libs/extractor/src/dead_properties/objects.rs @@ -0,0 +1,168 @@ +use super::authored_requirement; +use crate::utils::{ + RESPONSIVE_ARRAY, get_string_by_property_key, readable_code, unwrap_syntax_only, +}; +use oxc_ast::ast::{Expression, ObjectPropertyKind}; +use oxc_span::GetSpan; + +#[derive(Clone, Copy)] +pub(crate) enum ObjectKind { + Styles, + Records, + Selectors, + Globals, +} + +pub(crate) fn authored_errors( + expression: &Expression<'_>, + kind: ObjectKind, +) -> Vec<(u32, String, &'static str)> { + let mut declarations = Declarations::default(); + declarations.collect(expression, kind); + declarations.errors +} + +#[derive(Default)] +struct Declarations { + path: Vec, + errors: Vec<(u32, String, &'static str)>, + evaluated: bool, + responsive_entry: bool, +} + +impl Declarations { + fn collect(&mut self, expression: &Expression<'_>, kind: ObjectKind) { + match unwrap_syntax_only(expression) { + Expression::ObjectExpression(object) => { + for property in &object.properties { + match property { + ObjectPropertyKind::ObjectProperty(property) => { + let Some(name) = get_string_by_property_key(&property.key) else { + continue; + }; + self.path.push(name.clone()); + match kind { + ObjectKind::Styles => { + if let Some(requirement) = authored_requirement(&name) { + self.errors.push(( + property.key.span().start, + self.path.join(" -> "), + requirement, + )); + } else if !matches!( + name.as_str(), + "vars" | "props" | "styleVars" | "params" | "as" + ) && !css::is_special_property::is_special_property( + &name, + ) { + let nested = if matches!( + name.as_str(), + "selectors" + | "@media" + | "@supports" + | "@container" + | "_media" + | "_supports" + | "_container" + | "@layer" + ) { + ObjectKind::Selectors + } else { + ObjectKind::Styles + }; + self.responsive(&property.value, nested); + } + } + ObjectKind::Records => { + self.collect(&property.value, ObjectKind::Styles); + } + ObjectKind::Selectors => { + self.responsive(&property.value, ObjectKind::Styles); + } + ObjectKind::Globals => { + if name != "imports" { + self.collect( + &property.value, + if name.starts_with('@') || name.starts_with('_') { + ObjectKind::Globals + } else { + ObjectKind::Styles + }, + ); + } + } + } + self.path.pop(); + } + ObjectPropertyKind::SpreadProperty(spread) => { + self.collect(&spread.argument, kind); + } + } + } + } + Expression::ArrayExpression(array) => { + if self.responsive_entry { + self.responsive(expression, kind); + return; + } + for (index, item) in array.elements.iter().enumerate() { + if let Some(expression) = item.as_expression() { + self.path.push(index.to_string()); + self.collect(expression, kind); + self.path.pop(); + } + } + } + _ => {} + } + } + + fn responsive(&mut self, expression: &Expression<'_>, kind: ObjectKind) { + match unwrap_syntax_only(expression) { + Expression::ArrayExpression(array) if self.responsive_entry => { + self.errors.push(( + array.span.start, + if self.evaluated { + self.path.join(" -> ") + } else { + readable_code(unwrap_syntax_only(expression)) + }, + RESPONSIVE_ARRAY, + )); + } + Expression::ArrayExpression(array) => { + self.responsive_entry = true; + for (index, item) in array.elements.iter().enumerate() { + if let Some(expression) = item.as_expression() { + self.path.push(index.to_string()); + self.responsive(expression, kind); + self.path.pop(); + } + } + self.responsive_entry = false; + } + _ => self.collect(expression, kind), + } + } +} + +pub(crate) fn evaluated_errors(json: &str, kind: ObjectKind) -> Vec<(String, &'static str)> { + let allocator = oxc_allocator::Allocator::default(); + // The evaluator serializes a value, not a program. Parse that expression + // directly instead of checking for an impossible non-expression statement. + oxc_parser::Parser::new(&allocator, json, oxc_span::SourceType::mjs()) + .parse_expression() + .into_iter() + .flat_map(|expression| { + let mut declarations = Declarations { + evaluated: true, + ..Declarations::default() + }; + declarations.collect(&expression, kind); + declarations + .errors + .into_iter() + .map(|(_, path, requirement)| (path, requirement)) + }) + .collect() +} diff --git a/libs/extractor/src/dead_properties/origin.rs b/libs/extractor/src/dead_properties/origin.rs new file mode 100644 index 00000000..5da5637a --- /dev/null +++ b/libs/extractor/src/dead_properties/origin.rs @@ -0,0 +1,55 @@ +use crate::{ + ExtractOption, + import_alias_visit::{source_offset, transform_import_aliases_with_edits}, +}; + +pub(crate) fn map_evaluated( + errors: &mut [(u32, String)], + edits: &[&[crate::import_alias_visit::Edit]], + calls: &[(usize, usize)], +) -> Result<(), std::num::TryFromIntError> { + for (offset, message) in errors { + let authored = edits + .iter() + .fold(usize::try_from(*offset)?, |offset, edits| { + source_offset(edits, offset) + }); + let authored = if super::terminal_error(message) { + calls + .iter() + .filter(|(start, end)| *start <= authored && authored <= *end) + .min_by_key(|(start, end)| end - start) + .map_or(authored, |(start, _)| *start) + } else { + authored + }; + *offset = u32::try_from(authored)?; + } + Ok(()) +} + +pub(crate) fn evaluated_calls( + source: &str, + filename: &str, + option: &ExtractOption, +) -> Vec<(usize, usize)> { + let (aliased, edits) = transform_import_aliases_with_edits( + source, + filename, + &option.package, + &option.import_aliases, + ); + match super::instrument(&aliased, filename, &option.package) { + Ok(instrumented) => instrumented + .calls + .into_iter() + .filter_map(|(start, end)| { + Some(( + source_offset(&edits, usize::try_from(start).ok()?), + source_offset(&edits, usize::try_from(end).ok()?), + )) + }) + .collect(), + Err(_) => Vec::new(), + } +} diff --git a/libs/extractor/src/dead_properties/strings.rs b/libs/extractor/src/dead_properties/strings.rs new file mode 100644 index 00000000..1e42d827 --- /dev/null +++ b/libs/extractor/src/dead_properties/strings.rs @@ -0,0 +1,60 @@ +use oxc_ast::ast::StringLiteral; + +pub(super) fn origins(literal: &StringLiteral<'_>) -> Vec { + let Some(raw) = literal + .raw + .as_ref() + .and_then(|raw| raw.as_str().get(1..raw.len().saturating_sub(1))) + else { + return std::iter::repeat_n(literal.span.start, literal.value.len()).collect(); + }; + let mut origins = Vec::new(); + let mut characters = raw.char_indices().peekable(); + while let Some((start, character)) = characters.next() { + let mut index = start + character.len_utf8(); + let length = if character == '\\' { + characters.next().map_or(0, |(_, escape)| { + index += escape.len_utf8(); + match escape { + '\n' => 0, + '\r' => { + if raw.as_bytes().get(index) == Some(&b'\n') { + index += 1; + } + 0 + } + 'x' => { + index = (index + 2).min(raw.len()); + 1 + } + 'u' => { + let (end, digits) = if raw.as_bytes().get(index) == Some(&b'{') { + let end = raw[index..].find('}').map_or(raw.len(), |end| index + end); + (end.saturating_add(1).min(raw.len()), &raw[index + 1..end]) + } else { + let end = (index + 4).min(raw.len()); + (end, &raw[index..end]) + }; + index = end; + let codepoint = u32::from_str_radix(digits, 16).unwrap_or(0xfffd); + if (0xd800..=0xdbff).contains(&codepoint) && raw[index..].starts_with("\\u") + { + index = (index + 6).min(raw.len()); + 4 + } else { + char::from_u32(codepoint).unwrap_or('\u{fffd}').len_utf8() + } + } + _ => escape.len_utf8(), + } + }) + } else { + character.len_utf8() + }; + while characters.next_if(|(offset, _)| *offset < index).is_some() {} + if let Ok(offset) = u32::try_from(start) { + origins.extend(std::iter::repeat_n(literal.span.start + 1 + offset, length)); + } + } + origins +} diff --git a/libs/extractor/src/dead_properties/text.rs b/libs/extractor/src/dead_properties/text.rs new file mode 100644 index 00000000..02c57acc --- /dev/null +++ b/libs/extractor/src/dead_properties/text.rs @@ -0,0 +1,150 @@ +//! Declaration-aware CSS scanning with offsets into the authored text. + +use super::declaration_error; +use crate::{ExtractStyleProp, utils::get_string_by_literal_expression}; +use oxc_ast::ast::{Expression, StringLiteral, TemplateLiteral}; +use oxc_span::GetSpan; + +/// Find declaration names, ignoring selector preludes and balanced value tokens. +fn declarations(text: &str) -> Vec<(usize, std::borrow::Cow<'_, str>)> { + let bytes = text.as_bytes(); + let mut found = Vec::new(); + let mut start = 0; + let mut index = 0; + let mut colon = None; + let mut quote = None; + let mut parentheses = 0_u32; + let mut brackets = 0_u32; + let mut value_braces = 0_u32; + while index < bytes.len() { + let byte = bytes[index]; + if let Some(delimiter) = quote { + if byte == b'\\' { + index += 2; + continue; + } + if byte == delimiter { + quote = None; + } + } else if byte == b'/' && bytes.get(index + 1) == Some(&b'*') { + index += 2; + while index < bytes.len() + && !(bytes[index] == b'*' && bytes.get(index + 1) == Some(&b'/')) + { + index += 1; + } + index += 2; + if colon.is_none() + && text[start..index.min(bytes.len())] + .trim_start() + .starts_with("/*") + { + start = index.min(bytes.len()); + } + continue; + } else { + match byte { + b'\\' => { + index += 2; + continue; + } + b'\'' | b'"' => quote = Some(byte), + b'(' => parentheses += 1, + b')' => parentheses = parentheses.saturating_sub(1), + b'[' => brackets += 1, + b']' => brackets = brackets.saturating_sub(1), + b':' if parentheses == 0 && brackets == 0 && colon.is_none() => colon = Some(index), + b'{' if parentheses == 0 && brackets == 0 => { + if colon.is_some() && text[start..].trim_start().starts_with("--") { + value_braces += 1; + } else { + start = index + 1; + colon = None; + } + } + b'}' if value_braces > 0 => value_braces -= 1, + b';' | b'}' if parentheses == 0 && brackets == 0 && value_braces == 0 => { + record(text, start, colon, &mut found); + start = index + 1; + colon = None; + } + _ => {} + } + } + index += 1; + } + record(text, start, colon, &mut found); + found +} + +fn record<'a>( + text: &'a str, + start: usize, + colon: Option, + found: &mut Vec<(usize, std::borrow::Cow<'a, str>)>, +) { + if let Some(colon) = colon { + let head = &text[start..colon]; + let name = if head.contains("/*") { + std::borrow::Cow::Owned(css::rm_css_comment::rm_css_comment(head).trim().to_string()) + } else { + std::borrow::Cow::Borrowed(head.trim()) + }; + if name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + found.push((start + head.len() - head.trim_start().len(), name)); + } + } +} + +/// Scan decoded string text while retaining authored offsets across JS escapes. +pub(crate) fn literal_errors<'a>(literal: &StringLiteral<'_>) -> Vec> { + let origins = super::strings::origins(literal); + declarations(&literal.value) + .into_iter() + .filter_map(|(index, name)| declaration_error(&name, *origins.get(index)?)) + .collect() +} + +/// Join raw quasis with interpolation origins rather than inventing contiguous offsets. +pub(crate) fn template_errors<'a>(template: &TemplateLiteral<'_>) -> Vec> { + let mut text = String::new(); + let mut origins = Vec::new(); + for (index, quasi) in template.quasis.iter().enumerate() { + text.push_str(&quasi.value.raw); + origins.extend((0..quasi.value.raw.len()).filter_map(|offset| { + u32::try_from(offset) + .ok() + .map(|offset| quasi.span.start + offset) + })); + if let Some(expression) = template.expressions.get(index) { + if let Some(value) = get_string_by_literal_expression(expression) { + text.push_str(&value); + match expression { + Expression::StringLiteral(literal) => { + origins.extend(super::strings::origins(literal)); + } + _ => origins.extend(std::iter::repeat_n(expression.span().start, value.len())), + } + } else { + text.push_str("__value__"); + origins.extend(std::iter::repeat_n(expression.span().start, 9)); + } + } + } + declarations(&text) + .into_iter() + .filter_map(|(index, name)| declaration_error(&name, *origins.get(index)?)) + .collect() +} + +/// Validate CSS text only where an expression declares styles, not inside property values. +pub(crate) fn expression_errors<'a>(expression: &Expression<'_>) -> Vec> { + match expression { + Expression::StringLiteral(literal) => literal_errors(literal), + Expression::TemplateLiteral(template) => template_errors(template), + _ => Vec::new(), + } +} diff --git a/libs/extractor/src/dead_properties_call_boundary_tests.rs b/libs/extractor/src/dead_properties_call_boundary_tests.rs new file mode 100644 index 00000000..3aa2bbe4 --- /dev/null +++ b/libs/extractor/src/dead_properties_call_boundary_tests.rs @@ -0,0 +1,153 @@ +use crate::dead_properties_test_utils::{failure, position}; +use crate::{ExtractOption, ImportAlias, dead_properties, extract}; +use boa_engine::{Context, JsValue, Source}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; +use std::{error::Error, io}; + +fn evaluate_instrumented(code: &str) -> Result> { + let source = format!( + "const __vanilla_extract__ = {{ __at: (location, api, thunk) => thunk() }};\n{code}" + ); + Ok(Context::default() + .eval(Source::from_bytes(&source)) + .map_err(|error| io::Error::other(error.to_string()))?) +} + +#[rstest] +#[case( + "import fallback from '@devup-ui/react';", + "fallback({ boxOrient: 'vertical' })" +)] +#[case( + "import { css } from '@devup-ui/react';", + "css.call(null, { boxOrient: 'vertical' })" +)] +fn ordinary_import_calls_keep_their_behavior_when_not_styling_apis( + #[case] import: &str, + #[case] call: &str, +) -> Result<(), Box> { + // Given: default imports and function members do not declare styling arguments. + let source = format!("{import}\n{call};"); + + // When: instrument actual parsed source, then execute its call with ordinary functions. + let instrumented = dead_properties::instrument(&source, "ordinary.ts", "@devup-ui/react") + .map_err(io::Error::other)?; + let (_, body) = instrumented + .code + .split_once('\n') + .ok_or_else(|| io::Error::other("fixture import must end with a newline"))?; + let result = evaluate_instrumented(&format!( + "const fallback = rules => rules.boxOrient === 'vertical' ? 7 : 0; const css = fallback;\n{body}" + ))?; + + // Then: no styling-call metadata or declaration rejection, and the original result survives. + assert_eq!(instrumented.calls, vec![]); + assert_eq!(result.as_number(), Some(7.0)); + Ok(()) +} + +#[test] +fn direct_eval_keeps_lexical_scope_when_nested_calls_are_instrumented() -> Result<(), Box> +{ + // Given: direct eval declares a variable in the calling function's scope. + let source = "function inspect() { eval(String('var local = 42')); return local; } inspect();"; + + // When: execute the real instrumented program, including the nested String call. + let instrumented = dead_properties::instrument(source, "eval.ts", "@devup-ui/react") + .map_err(io::Error::other)?; + let result = evaluate_instrumented(&instrumented.code)?; + + // Then: the declaration belongs to inspect, not an instrumentation thunk. + assert_eq!(result.as_number(), Some(42.0)); + Ok(()) +} + +#[test] +fn suspending_call_keeps_generator_resume_value() -> Result<(), Box> { + // Given: the outer echo argument yields, but its inner echo call is synchronous. + let source = "const echo = value => value; function* values() { return echo(yield echo(7)); } const iter = values(); iter.next(); iter.next(13).value;"; + + // When: instrument and execute valid generator source. + let instrumented = dead_properties::instrument(source, "yield.ts", "@devup-ui/react") + .map_err(io::Error::other)?; + let result = evaluate_instrumented(&instrumented.code)?; + + // Then: yield remains in the generator body and the resumed argument reaches echo. + assert_eq!(result.as_number(), Some(13.0)); + Ok(()) +} + +#[test] +#[serial] +fn evaluated_call_mapping_has_no_ranges_when_authored_declarations_are_rejected() { + // Given: an aliased, valid source declaration rejected before evaluation. + reset_class_map(); + reset_file_map(); + let source = "import { css } from '@emotion/react';\nconst good = css({ color: 'red' }); const c = css({ boxOrient: 'vertical' });"; + let option = ExtractOption { + import_aliases: std::collections::HashMap::from([( + "@emotion/react".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }; + + // When: request evaluated origins for a source that cannot be evaluated. + let calls = dead_properties::evaluated_calls(source, "rejected.tsx", &option); + + // Then: there are no partial ranges to misattribute the authored diagnostic. + assert_eq!(calls, vec![]); +} + +#[rstest] +#[case(r"const c = css('box-align\\:center; color:red');")] +#[case(r"const c = css('content:box-align\\;center; color:red');")] +#[case(r"const c = css('content:box-align\\}center; color:red');")] +#[case(r"const c = css('');")] +#[serial] +fn css_escaped_delimiters_are_not_dead_declarations(#[case] statement: &str) { + // Given: escaped CSS punctuation is not a declaration boundary. + reset_class_map(); + reset_file_map(); + let source = format!("import {{ css }} from '@devup-ui/react';\n{statement}"); + + // When: extract through the normal public source entrypoint. + let result = extract("escapes.tsx", &source, ExtractOption::default()); + + // Then: text that merely resembles a dead declaration is accepted. + assert!(result.is_ok(), "{result:?}"); +} + +#[rstest] +#[case(r"const c = css('content:é😀; \x62ox-align:center');", r"\x62ox-align")] +#[case( + r"const c = css('content:\u{1F600}é; \u0062ox-align:center');", + r"\u0062ox-align" +)] +#[case( + r"const c = css('content:\uD83D\uDE00é; \u{62}ox-align:center');", + r"\u{62}ox-align" +)] +#[serial] +fn escaped_declaration_names_keep_authored_columns_after_unicode( + #[case] statement: &str, + #[case] key: &str, +) { + // Given: decoded Unicode occupies different byte lengths from the authored escapes. + reset_class_map(); + reset_file_map(); + let source = format!("import {{ css }} from '@devup-ui/react';\n{statement}"); + let column = statement[..position(statement, key)].chars().count() + 1; + + // When: the escaped name decodes to a dead declaration. + let error = failure(extract("unicode.tsx", &source, ExtractOption::default())); + + // Then: report the authored escape, not a byte position in the decoded string. + assert!( + error.starts_with(&format!("unicode.tsx:2:{column}:")), + "{error}" + ); + assert!(error.contains("box-align"), "{error}"); +} diff --git a/libs/extractor/src/dead_properties_declaration_boundary_tests.rs b/libs/extractor/src/dead_properties_declaration_boundary_tests.rs new file mode 100644 index 00000000..069bccd7 --- /dev/null +++ b/libs/extractor/src/dead_properties_declaration_boundary_tests.rs @@ -0,0 +1,233 @@ +use crate::dead_properties_test_utils::{failure, position}; +use crate::utils::{RESPONSIVE_ARRAY, build_time_error}; +use crate::{ExtractOption, ExtractStyleValue, ImportAlias, extract}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("box-align", "boxAlign")] +#[case("box-pack", "boxPack")] +#[case("box-flex", "boxFlex")] +#[case("box-flex-group", "boxFlexGroup")] +#[case("box-orient", "boxOrient")] +#[case("box-ordinal-group", "boxOrdinalGroup")] +#[case("box-direction", "boxDirection")] +#[case("box-lines", "boxLines")] +#[case("flex-order", "flexOrder")] +#[case("flex-positive", "flexPositive")] +#[case("flex-negative", "flexNegative")] +#[case("flex-preferred-size", "flexPreferredSize")] +#[case("scroll-snap-points-x", "scrollSnapPointsX")] +#[case("scroll-snap-points-y", "scrollSnapPointsY")] +#[case("scroll-snap-coordinate", "scrollSnapCoordinate")] +#[case("scroll-snap-destination", "scrollSnapDestination")] +#[case("scroll-snap-type-x", "scrollSnapTypeX")] +#[case("scroll-snap-type-y", "scrollSnapTypeY")] +#[serial] +fn rejects_dead_declarations_when_entering_public_expression_apis( + #[case] canonical: &str, + #[case] camel: &str, + #[values( + "globalString", + "globalTemplate", + "keyframesString", + "hover", + "valueObject", + "positionTry", + "viewTransitionClass" + )] + boundary: &str, +) { + // Given declaration bodies, rather than namespace names or token values. + reset_class_map(); + reset_file_map(); + let statement = match boundary { + "globalString" => format!("globalCss('body {{ color:red; {canonical}:1 }}');"), + "globalTemplate" => format!("globalCss(`body {{ color:red; {canonical}:1 }}`);"), + "keyframesString" => format!("const k = keyframes('from {{ {canonical}:1 }}');"), + "hover" => format!("const e = ;"), + "valueObject" => format!("const e = ;"), + "positionTry" => format!("const p = stylex.positionTry({{ {camel}:'1' }});"), + "viewTransitionClass" => { + format!("const v = stylex.viewTransitionClass({{ {camel}:'1' }});") + } + _ => unreachable!(), + }; + let source = format!( + "import {{ Box, globalCss, keyframes }} from '@devup-ui/react';\nimport * as stylex from '@stylexjs/stylex';\n{statement}" + ); + let name = if statement.contains(camel) { + camel + } else { + canonical + }; + let column = position(&statement, name) + 1; + + // When the public extractor reaches the expression or declaration boundary. + let error = failure(extract("boundary.tsx", &source, ExtractOption::default())); + + // Then the authored key owns the error, including the original API name. + let api = match boundary { + "globalString" | "globalTemplate" => "`globalCss()`", + "keyframesString" => "`keyframes()`", + "hover" | "valueObject" => "``", + "positionTry" => "`stylex.positionTry()`", + "viewTransitionClass" => "`stylex.viewTransitionClass()`", + _ => unreachable!(), + }; + assert!( + error.starts_with(&format!("boundary.tsx:3:{column}:")), + "{error}" + ); + assert!(error.contains(api), "{error}"); + assert!(error.contains(canonical), "{error}"); + let requirement = match crate::dead_properties::requirement(canonical) { + Some(requirement) => requirement, + None => panic!("fixture has no policy for {canonical}"), + }; + assert!(error.ends_with(requirement), "{error}"); +} + +#[rstest] +#[case("'@media print'", "@media print")] +#[case("_print", "_print")] +#[serial] +fn rejects_global_declarations_when_authored_inside_at_rule_maps( + #[case] key: &str, + #[case] path: &str, +) { + // Given a stylesheet with an authored global selector map inside an at-rule. + reset_class_map(); + reset_file_map(); + let statement = format!("globalCss({{ {key}: {{ body: {{ boxAlign:'center' }} }} }});"); + let source = format!("import {{ globalCss }} from '@devup-ui/react';\n{statement}"); + let column = position(&statement, "boxAlign") + 1; + + // When authored stylesheet calls are validated before their evaluation. + let error = failure(extract("global.css.ts", &source, ExtractOption::default())); + + // Then recursion keeps the selector path and the key's source location. + assert_eq!( + error, + format!( + "global.css.ts:2:{column}: {}", + build_time_error( + "globalCss", + &format!("{path} -> body -> boxAlign"), + "unprefixed box-align has no browser implementation; use display: flex and align-items", + ) + ) + ); +} + +#[test] +#[serial] +fn keeps_import_metadata_when_validating_global_selector_maps() +-> Result<(), Box> { + // Given import metadata in a deferred global call alongside an emitted style. + reset_class_map(); + reset_file_map(); + let source = "import { style, globalCss } from '@devup-ui/react';\nfunction loadFonts() { globalCss({ imports:[{url:'box-align.css',boxAlign:'metadata'}] }); }\nexport const card=style({color:'red'});"; + + // When the stylesheet's authored global map and evaluated style are checked. + let output = extract("imports.css.ts", source, ExtractOption::default())?; + + // Then metadata is not mistaken for a declaration, and the style still emits. + assert!(output.styles.iter().any(|style| matches!( + style, + ExtractStyleValue::Static(style) if style.property == "color" && style.value == "red" + ))); + Ok(()) +} + +#[test] +#[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(); + 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(()) +} + +#[rstest] +#[case("{_hover:[{...([1,2] as const)},null]}")] +#[case("{selectors:{'&:hover':[{...[1,2]},null]}}")] +#[case("{'@media':{'(min-width:600px)':[{...[1,2]},null]}}")] +#[serial] +fn rejects_nested_responsive_arrays_when_spread_into_selector_entries(#[case] argument: &str) { + // Given an array spread nested inside a responsive selector entry. + reset_class_map(); + reset_file_map(); + let statement = format!("export const card=style({argument});"); + let source = format!("import {{style}} from '@vanilla-extract/css';\n{statement}"); + let column = position(&statement, "[1,2]") + 1; + let option = ExtractOption { + import_aliases: std::collections::HashMap::from([( + "@vanilla-extract/css".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }; + + // When the authored selector walk follows the object spread. + let error = failure(extract("spread.css.ts", &source, option)); + + // Then the nested array, not its containing object or call, owns the error. + assert_eq!( + error, + format!( + "spread.css.ts:2:{column}: {}", + build_time_error("style", "[1, 2]", RESPONSIVE_ARRAY) + ) + ); +} + +#[rstest] +#[case("{boxAlign:'center',color:'red'}")] +#[case("{...{boxAlign:'center'},boxAlign:'end',color:'red'}")] +#[serial] +fn rejects_root_dead_keys_when_literal_spreads_are_overridden(#[case] argument: &str) { + // Given a root declaration, including a spread key shadowed by a later key. + reset_class_map(); + reset_file_map(); + let statement = format!("const c=css({argument});"); + let source = format!("import {{css}} from '@devup-ui/react';\n{statement}"); + let column = position(&statement, "boxAlign") + 1; + + // When root validation runs before flattening or consuming properties. + let error = failure(extract("root.tsx", &source, ExtractOption::default())); + + // Then the original authored key still owns the policy failure. + assert!( + error.starts_with(&format!("root.tsx:2:{column}:")), + "{error}" + ); + assert!(error.contains("box-align"), "{error}"); + assert!( + error.contains("use display: flex and align-items"), + "{error}" + ); +} diff --git a/libs/extractor/src/dead_properties_origin_tests.rs b/libs/extractor/src/dead_properties_origin_tests.rs new file mode 100644 index 00000000..f268e25f --- /dev/null +++ b/libs/extractor/src/dead_properties_origin_tests.rs @@ -0,0 +1,264 @@ +use crate::dead_properties_test_utils::{failure, position}; +use crate::{ExtractOption, ImportAlias, ResolvedModule, extract, extract_with_modules}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; + +fn options() -> ExtractOption { + ExtractOption { + import_aliases: std::collections::HashMap::from([( + "@vanilla-extract/css".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + } +} + +#[rstest] +#[case( + "export const c = style(rules);", + "style", + "selectors -> &:hover -> boxOrient" +)] +#[case( + "globalStyle('body', rules);", + "globalStyle", + "selectors -> &:hover -> boxOrient" +)] +#[case( + "export const k = keyframes({ from: rules });", + "keyframes", + "from -> selectors -> &:hover -> boxOrient" +)] +#[case( + "export const v = styleVariants({ a: 1 }, () => rules);", + "styleVariants", + "selectors -> &:hover -> boxOrient" +)] +#[case( + "export const f = fontFace(rules);", + "fontFace", + "selectors -> &:hover -> boxOrient" +)] +#[case( + "globalFontFace('Inter', rules);", + "globalFontFace", + "selectors -> &:hover -> boxOrient" +)] +#[serial] +fn evaluated_rules_report_authored_call_and_path( + #[case] statement: &str, + #[case] api: &str, + #[case] path: &str, +) { + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{ style, globalStyle, keyframes, styleVariants, fontFace, globalFontFace }} from '@vanilla-extract/css';\nconst key = 'box' + 'Orient';\nconst rules = {{ selectors: {{ '&:hover': {{ [key]: 'vertical' }} }} }};\n{statement}" + ); + let column = position(statement, &format!("{api}(")) + 1; + + let error = failure(extract("origin.css.ts", &source, options())); + + assert!( + error.starts_with(&format!("origin.css.ts:4:{column}:")), + "{error}" + ); + assert!(error.contains(path), "{error}"); + assert!(error.contains("flex-direction"), "{error}"); +} + +#[rstest] +#[case("export const c = style({ boxOrient: 'vertical' });")] +#[case("export const k = keyframes({ from: { boxOrient: 'vertical' } });")] +#[case("globalStyle('body', { boxOrient: 'vertical' });")] +#[serial] +fn literal_stylesheet_declarations_keep_exact_key_locations(#[case] statement: &str) { + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{ style, globalStyle, keyframes }} from '@vanilla-extract/css';\n{statement}" + ); + let column = position(statement, "boxOrient") + 1; + + let error = failure(extract("literal.css.ts", &source, options())); + + assert!( + error.starts_with(&format!("literal.css.ts:2:{column}:")), + "{error}" + ); +} + +#[test] +#[serial] +fn imported_helper_call_keeps_its_authored_filename() { + reset_class_map(); + reset_file_map(); + let source = "import { style } from '@vanilla-extract/css';\nimport { make } from './helper';\nexport const c = style(make());"; + let resolver = |specifier: &str, _: &str| { + match specifier { + "./helper" => Some(ResolvedModule { + path: "helper.ts".to_string(), + code: "import { style } from '@vanilla-extract/css';\nconst key = 'box' + 'Orient';\nexport const make = () => style({ [key]: 'vertical' });".to_string(), + }), + _ => None, + } + }; + let error = failure(extract_with_modules( + "importer.css.ts", + source, + options(), + false, + &resolver, + )); + + assert!(error.starts_with("helper.ts:3:27:"), "{error}"); + assert!(error.contains("boxOrient"), "{error}"); +} + +#[test] +#[serial] +fn imported_stylesheet_call_keeps_its_authored_filename() { + reset_class_map(); + reset_file_map(); + let source = "import { style } from '@vanilla-extract/css';\nimport { c } from './other.css';\nexport const result = style([c, { color: 'red' }]);"; + let resolver = |specifier: &str, _: &str| { + match specifier { + "./other.css" => Some(ResolvedModule { + path: "other.css.ts".to_string(), + code: "import { style } from '@vanilla-extract/css';\nconst key = 'box' + 'Orient';\nexport const c = style({ [key]: 'vertical' });".to_string(), + }), + _ => None, + } + }; + let error = failure(extract_with_modules( + "importer.css.ts", + source, + options(), + false, + &resolver, + )); + + assert!(error.starts_with("other.css.ts:3:18:"), "{error}"); +} + +#[rstest] +#[case("const c = css(rules);", "css(")] +#[case("const k = keyframes({ from: rules });", "keyframes(")] +#[case("globalCss({ body: rules });", "globalCss(")] +#[case("const S = styled.div(rules);", "styled.div(")] +#[case("const e = ;", " Some(ResolvedModule { + path: "rules.ts".to_string(), + code: "export const rules = { selectors: { '&:hover': { boxOrient: 'vertical' } } };" + .to_string(), + }), + _ => None, + }; + + let error = failure(extract_with_modules( + "imported.tsx", + &source, + ExtractOption::default(), + false, + &resolver, + )); + + assert!( + error.starts_with(&format!( + "imported.tsx:3:{}:", + position(statement, call) + 1 + )), + "{error}" + ); + assert!( + error.contains("selectors -> &:hover -> boxOrient"), + "{error}" + ); +} + +#[test] +#[serial] +fn variant_mapper_data_is_not_a_declaration() { + reset_class_map(); + reset_file_map(); + let source = "import { styleVariants } from '@devup-ui/react';\nexport const c = styleVariants({ a: { boxOrient: 'red' } }, data => ({ color: data.boxOrient }));"; + + let result = extract("variants.css.ts", source, ExtractOption::default()); + + assert!(result.is_ok(), "{result:?}"); +} + +#[test] +#[serial] +fn computed_object_reports_its_authored_css_call() { + reset_class_map(); + reset_file_map(); + let source = "import { css } from '@devup-ui/react';\nfunction makeRules() { return { selectors: { '&:hover': { boxOrient: 'vertical' } } }; }\nconst c = css(makeRules());"; + + let error = failure(extract("computed.tsx", source, ExtractOption::default())); + + assert!(error.starts_with("computed.tsx:3:11:"), "{error}"); + assert!( + error.contains("selectors -> &:hover -> boxOrient"), + "{error}" + ); +} + +#[rstest] +#[case( + "import { style as make } from '@devup-ui/react';", + "const c = make(rules);", + "make(" +)] +#[case( + "import * as ve from '@devup-ui/react';", + "const c = ve.style(rules);", + "ve.style(" +)] +#[case( + "import { style } from '@devup-ui/react'; const make = style;", + "const c = make(rules);", + "make(" +)] +#[case( + "import { style } from '@devup-ui/react'; const fns = { make: style };", + "const c = fns.make(rules);", + "fns.make(" +)] +#[case( + "import { style } from '@devup-ui/react'; const fns = [style]; const i = 0;", + "const c = fns[i](rules);", + "fns[i](" +)] +#[serial] +fn stylesheet_api_aliases_preserve_call_origins( + #[case] imports: &str, + #[case] statement: &str, + #[case] call: &str, +) { + reset_class_map(); + reset_file_map(); + let source = format!( + "{imports}\nconst key = 'box' + 'Orient'; const rules = {{ [key]: 'vertical' }};\n{statement}" + ); + + let error = failure(extract("aliases.css.ts", &source, ExtractOption::default())); + + assert!( + error.starts_with(&format!( + "aliases.css.ts:3:{}:", + position(statement, call) + 1 + )), + "{error}" + ); + assert!(error.contains("boxOrient"), "{error}"); +} diff --git a/libs/extractor/src/dead_properties_responsive_origin_tests.rs b/libs/extractor/src/dead_properties_responsive_origin_tests.rs new file mode 100644 index 00000000..014c051f --- /dev/null +++ b/libs/extractor/src/dead_properties_responsive_origin_tests.rs @@ -0,0 +1,221 @@ +use crate::dead_properties_test_utils::{failure, position}; +use crate::utils::{RESPONSIVE_ARRAY, build_time_error}; +use crate::{ExtractOption, ImportAlias, ResolvedModule, extract, extract_with_modules}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; + +fn options() -> ExtractOption { + ExtractOption { + import_aliases: std::collections::HashMap::from([( + "@vanilla-extract/css".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + } +} + +#[rstest] +#[case("export const bad = style({p:[[1,2],3]});", "style")] +#[case("export const bad = style({p:[([1,2] as const),3]});", "style")] +#[case( + "export const bad = style({selectors:{'&:hover':{p:[[1,2],3]}}});", + "style" +)] +#[case("export const bad = style({_hover:[{p:[1,2]},null]});", "style")] +#[case( + "export const bad = style({selectors:{'&:hover':[[1,2],3]}});", + "style" +)] +#[case("export const bad = style([{color:'red'},[{p:[[1,2],3]}]]);", "style")] +#[case("globalStyle('body',{p:[[1,2],3]});", "globalStyle")] +#[case("export const bad = keyframes({from:{p:[[1,2],3]}});", "keyframes")] +#[case( + "export const bad = styleVariants({small:{p:[[1,2],3]}});", + "styleVariants" +)] +#[serial] +fn literal_nested_arrays_report_the_authored_array(#[case] statement: &str, #[case] api: &str) { + // Given an authored literal whose nested array is not at the call's column. + reset_class_map(); + reset_file_map(); + let source = format!("import {{{api}}} from '@vanilla-extract/css';\n\n\n\n{statement}"); + let column = position(statement, "[1,2]") + 1; + + // When stylesheet declaration validation runs before evaluation. + let error = failure(extract("literal.css.ts", &source, options())); + + // Then the error uses the inner array's authored position and expression. + assert_eq!( + error, + format!( + "literal.css.ts:5:{column}: {}", + build_time_error(api, "[1, 2]", RESPONSIVE_ARRAY) + ) + ); +} + +#[rstest] +#[case("const values=[[1,2],3];", concat!("{", "p:values", "}"), "p -> 0")] +#[case("const values=Array.of([1,2],3);", concat!("{", "p:values", "}"), "p -> 0")] +#[case("const values=() => [[1,2],3];", "{p:values()}", "p -> 0")] +#[case("const key='p';", "{[key]:[[1,2],3]}", "p -> 0")] +#[case("const values={p:[[1,2],3]};", "{...values}", "p -> 0")] +#[case( + "const values=[[1,2],3];", + concat!("{selectors:{'&:hover':", "{", "p:values", "}}}"), + "selectors -> &:hover -> p -> 0" +)] +#[case( + "const values=[[1,2],3];", + "{selectors:{'&:hover':values}}", + "selectors -> &:hover -> 0" +)] +#[case( + "const values=[[1,2],3];", + concat!("{'@media':{'(min-width: 600px)':", "{", "p:values", "}}}"), + "@media -> (min-width: 600px) -> p -> 0" +)] +#[case( + "const values=[{p:[1,2]},null];", + "{_hover:values}", + "_hover -> 0 -> p" +)] +#[serial] +fn evaluated_nested_arrays_report_the_authored_call_and_path( + #[case] declaration: &str, + #[case] argument: &str, + #[case] path: &str, +) { + // Given a value that acquires its responsive shape during evaluation. + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{style}} from '@vanilla-extract/css';\n{declaration}\n\n\nexport const bad = style({argument});" + ); + + // When the evaluated JSON is validated before generated css() lowering. + let error = failure(extract("evaluated.css.ts", &source, options())); + + // Then neither coordinates nor the API name refer to the generated code. + assert_eq!( + error, + format!( + "evaluated.css.ts:5:20: {}", + build_time_error("style", path, RESPONSIVE_ARRAY) + ) + ); +} + +#[rstest] +#[case("values", "[[1,2],3]", concat!("{", "p:values", "}"))] +#[case("rules", "{selectors:{'&:hover':{p:[[1,2],3]}}}", "rules")] +#[serial] +fn imported_nested_arrays_report_the_consuming_stylesheet_call( + #[case] export: &str, + #[case] value: &str, + #[case] argument: &str, +) { + // Given imported data whose declaration is not an authored styling call. + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{style}} from '@vanilla-extract/css';\nimport {{{export}}} from './values';\n\n\nexport const bad = style({argument});" + ); + let module = format!("export const {export}={value};"); + let resolver = move |specifier: &str, _: &str| match specifier { + "./values" => Some(ResolvedModule { + path: "values.ts".to_string(), + code: module.clone(), + }), + _ => None, + }; + + // When the importing stylesheet evaluates the declaration. + let error = failure(extract_with_modules( + "importer.css.ts", + &source, + options(), + false, + &resolver, + )); + + // Then its styling call, not the data module or generated css(), owns it. + let path = match export { + "values" => "p -> 0", + _ => "selectors -> &:hover -> p -> 0", + }; + assert_eq!( + error, + format!( + "importer.css.ts:5:20: {}", + build_time_error("style", path, RESPONSIVE_ARRAY) + ) + ); +} + +#[test] +#[serial] +fn imported_stylesheet_nested_arrays_keep_the_declaring_call_origin() { + // Given an imported stylesheet containing the invalid styling call. + reset_class_map(); + reset_file_map(); + let source = "import {style} from '@vanilla-extract/css';\nimport {bad} from './other.css';\nexport const result=style([bad,{color:'red'}]);"; + let resolver = |specifier: &str, _: &str| { + match specifier { + "./other.css" => Some(ResolvedModule { + path: "other.css.ts".to_string(), + code: concat!("import {style} from '@vanilla-extract/css';\nconst values=[[1,2],3];\n\n\nexport const bad = style(", "{", "p:values", "}", ");").to_string(), + }), + _ => None, + } + }; + + // When its importer requests the exported style. + let error = failure(extract_with_modules( + "outer.css.ts", + source, + options(), + false, + &resolver, + )); + + // Then the authored styling call remains in the imported stylesheet. + assert_eq!( + error, + format!( + "other.css.ts:5:20: {}", + build_time_error("style", "p -> 0", RESPONSIVE_ARRAY) + ) + ); +} + +#[rstest] +#[case("{p:[1,2,3]}")] +#[case("{p:[1,null,,3]}")] +#[case("{_hover:[{p:1},null,{p:3}]}")] +#[case("{p:([[1,2],[3,4]])[0]}")] +#[case("{p:({small:[1,2],large:[3,4]})['small']}")] +#[case("{p:(true ? [1,2] : [[3,4],5])}")] +#[case("{p:[false ? [1,2] : 3,4]}")] +#[case("{p:([1,null,3] as const)}")] +#[case("{selectors:{'&:hover':{p:[1,null,3]}}}")] +#[case("[{p:[1,2]},[{color:'red'}]]")] +#[serial] +fn valid_responsive_values_and_root_compositions_remain_extractable( + #[case] argument: &str, +) -> Result<(), Box> { + // Given flat responsive values or arrays that compose entire styles. + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{style}} from '@vanilla-extract/css';\nexport const valid=style({argument});" + ); + + // When the stylesheet passes authored and evaluated validation. + let output = extract("valid.css.ts", &source, options())?; + + // Then it still produces extracted styles instead of a blanket array error. + assert_ne!(output.styles.len(), 0); + Ok(()) +} diff --git a/libs/extractor/src/dead_properties_stylex_tests.rs b/libs/extractor/src/dead_properties_stylex_tests.rs new file mode 100644 index 00000000..08904575 --- /dev/null +++ b/libs/extractor/src/dead_properties_stylex_tests.rs @@ -0,0 +1,64 @@ +use crate::dead_properties_test_utils::{failure, position}; +use crate::{ExtractOption, extract}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("boxAlign")] +#[case("boxPack")] +#[case("boxFlex")] +#[case("boxFlexGroup")] +#[case("boxOrient")] +#[case("boxOrdinalGroup")] +#[case("boxDirection")] +#[case("boxLines")] +#[case("flexOrder")] +#[case("flexPositive")] +#[case("flexNegative")] +#[case("flexPreferredSize")] +#[case("scrollSnapPointsX")] +#[case("scrollSnapPointsY")] +#[case("scrollSnapCoordinate")] +#[case("scrollSnapDestination")] +#[case("scrollSnapTypeX")] +#[case("scrollSnapTypeY")] +#[serial] +fn stylex_property_boundaries_reject_dead_names( + #[case] property: &str, + #[values("static", "pseudo", "dynamic", "keyframes")] shape: &str, +) { + reset_class_map(); + reset_file_map(); + let statement = match shape { + "static" => format!("const c = stylex.create({{x:{{{property}:'center'}}}});"), + "pseudo" => format!("const c = stylex.create({{x:{{':hover':{{{property}:'center'}}}}}});"), + "dynamic" => format!("const c = stylex.create({{x:(value)=>({{{property}:value}})}});"), + "keyframes" => format!("const c = stylex.keyframes({{from:{{{property}:'center'}}}});"), + _ => unreachable!(), + }; + let source = format!("import * as stylex from '@stylexjs/stylex';\n{statement}"); + let error = failure(extract( + "stylex-dead.tsx", + &source, + ExtractOption::default(), + )); + let column = position(&statement, property) + 1; + assert!( + error.starts_with(&format!("stylex-dead.tsx:2:{column}:")), + "{error}" + ); + assert!(error.contains("use "), "{error}"); +} + +#[test] +#[serial] +fn stylex_namespace_and_token_names_are_not_declarations() -> Result<(), Box> +{ + reset_class_map(); + reset_file_map(); + let source = "import * as stylex from '@stylexjs/stylex';\nconst vars = stylex.defineVars({boxAlign:'red'}); const c = stylex.create({boxAlign:{color:vars.boxAlign,strokeColor:'red',imeMode:'active',WebkitBoxOrient:'vertical',futureDraft:'on'}});"; + let output = extract("stylex-control.tsx", source, ExtractOption::default())?; + assert_ne!(output.styles.len(), 0); + Ok(()) +} diff --git a/libs/extractor/src/dead_properties_test_utils.rs b/libs/extractor/src/dead_properties_test_utils.rs new file mode 100644 index 00000000..fe0d7993 --- /dev/null +++ b/libs/extractor/src/dead_properties_test_utils.rs @@ -0,0 +1,13 @@ +pub(super) fn position(source: &str, needle: &str) -> usize { + match source.find(needle) { + Some(position) => position, + None => panic!("fixture has no {needle:?}"), + } +} + +pub(super) fn failure(result: Result>) -> String { + match result { + Err(error) => error.to_string(), + Ok(output) => panic!("expected declaration error, got {output:?}"), + } +} diff --git a/libs/extractor/src/dead_properties_tests.rs b/libs/extractor/src/dead_properties_tests.rs new file mode 100644 index 00000000..0e227e5b --- /dev/null +++ b/libs/extractor/src/dead_properties_tests.rs @@ -0,0 +1,135 @@ +use crate::dead_properties_test_utils::{failure, position}; +use crate::{ExtractOption, ExtractStyleValue, extract}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("box-align", "boxAlign")] +#[case("box-pack", "boxPack")] +#[case("box-flex", "boxFlex")] +#[case("box-flex-group", "boxFlexGroup")] +#[case("box-orient", "boxOrient")] +#[case("box-ordinal-group", "boxOrdinalGroup")] +#[case("box-direction", "boxDirection")] +#[case("box-lines", "boxLines")] +#[case("flex-order", "flexOrder")] +#[case("flex-positive", "flexPositive")] +#[case("flex-negative", "flexNegative")] +#[case("flex-preferred-size", "flexPreferredSize")] +#[case("scroll-snap-points-x", "scrollSnapPointsX")] +#[case("scroll-snap-points-y", "scrollSnapPointsY")] +#[case("scroll-snap-coordinate", "scrollSnapCoordinate")] +#[case("scroll-snap-destination", "scrollSnapDestination")] +#[case("scroll-snap-type-x", "scrollSnapTypeX")] +#[case("scroll-snap-type-y", "scrollSnapTypeY")] +#[serial] +fn rejects_closed_property_set_at_exact_keys( + #[case] canonical: &str, + #[case] camel: &str, + #[values( + "jsx", + "css", + "nested", + "styled", + "keyframes", + "global", + "fontFaces", + "text", + "template", + "styledText", + "keyframesText", + "globalText", + "upperText" + )] + api: &str, +) { + reset_class_map(); + reset_file_map(); + let statement = match api { + "jsx" => format!("const e = ;"), + "css" => format!("const c = css({{ {camel}: '1' }});"), + "nested" => format!("const c = css({{ selectors: {{ '&:hover': {{ {camel}: '1' }} }} }});"), + "styled" => format!("const S = styled.div({{ {camel}: '1' }});"), + "keyframes" => format!("const k = keyframes({{ from: {{ {camel}: '1' }} }});"), + "global" => format!("globalCss({{ body: {{ {camel}: '1' }} }});"), + "fontFaces" => format!("globalCss({{ fontFaces: [{{ {camel}: '1' }}] }});"), + "text" => format!("const c = css('color: red; {canonical}: 1');"), + "template" => format!("const c = css`color: red; {canonical}: 1`;"), + "styledText" => format!("const S = styled.div`color: red; {canonical}: 1`;"), + "keyframesText" => format!("const k = keyframes`from {{ {canonical}: 1 }}`;"), + "globalText" => format!("globalCss`body {{ {canonical}: 1 }}`;"), + "upperText" => format!("const c = css`{}: 1`;", canonical.to_ascii_uppercase()), + _ => unreachable!(), + }; + let uppercase = canonical.to_ascii_uppercase(); + let name = if api == "upperText" { + uppercase.as_str() + } else if statement.contains(camel) { + camel + } else { + canonical + }; + let column = position(&statement, name) + 1; + let source = format!( + "import {{ Box, css, styled, keyframes, globalCss }} from '@devup-ui/react';\n{statement}" + ); + let error = failure(extract("dead.tsx", &source, ExtractOption::default())); + assert!( + error.starts_with(&format!("dead.tsx:2:{column}:")), + "{error}" + ); + assert!(error.contains(canonical), "{error}"); + assert!(error.contains("use "), "{error}"); +} + +#[rstest] +#[case( + "const c = css({ strokeColor: 'red', imeMode: 'active', futureDraft: 'on', WebkitBoxOrient: 'vertical', p: 2 });" +)] +#[case( + "const c = css`stroke-color:red;ime-mode:active;future-draft:on;-webkit-box-orient:vertical;padding:8px`; " +)] +#[serial] +fn supported_and_open_ended_declarations_still_emit(#[case] statement: &str) { + reset_class_map(); + reset_file_map(); + let source = format!("import {{ css }} from '@devup-ui/react';\n{statement}"); + let output = match extract("controls.tsx", &source, ExtractOption::default()) { + Ok(output) => output, + Err(error) => panic!("control failed: {error}"), + }; + let declarations: std::collections::BTreeSet<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) => { + Some((style.property.as_str(), style.value.as_str())) + } + _ => None, + }) + .collect(); + assert_eq!( + declarations, + std::collections::BTreeSet::from([ + ("stroke-color", "red"), + ("ime-mode", "active"), + ("future-draft", "on"), + ("-webkit-box-orient", "vertical"), + ("padding", "8px"), + ]) + ); +} + +#[rstest] +#[case( + "const c = css`/* box-align: center */ content:'box-align: center'; background:url(box-align:foo); --data: box-align: center; &:is([box-align='center']) { color:red }`; " +)] +#[case("const c = css`box-align:hover { color:red }`; ")] +#[serial] +fn scanner_ignores_names_outside_declaration_keys(#[case] statement: &str) { + reset_class_map(); + reset_file_map(); + let source = format!("import {{ css }} from '@devup-ui/react';\n{statement}"); + assert!(extract("controls.tsx", &source, ExtractOption::default()).is_ok()); +} diff --git a/libs/extractor/src/dead_properties_text_tests.rs b/libs/extractor/src/dead_properties_text_tests.rs new file mode 100644 index 00000000..740c50e3 --- /dev/null +++ b/libs/extractor/src/dead_properties_text_tests.rs @@ -0,0 +1,113 @@ +use crate::dead_properties_test_utils::{failure, position}; +use crate::{ExtractOption, ImportAlias, extract}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case(r"const c = css('color:red;\nbox-align:center');", "box-align")] +#[case(r"const c = css('content:\x61; box-align:center');", "box-align")] +#[case(r"const c = css('content:\u2192; box-align:center');", "box-align")] +#[case(r"const c = css('content:\u{2192}; box-align:center');", "box-align")] +#[case( + r"const c = css('content:\uD83D\uDE00; box-align:center');", + "box-align" +)] +#[case("const c = css('color:red;\\\n box-align:center');", "box-align")] +#[case("const c = css('color:red;\\\r\n box-align:center');", "box-align")] +#[case( + r#"const c = css('content:"\\u2192"; box-align:center');"#, + "box-align" +)] +#[case("const c = css`color:${value};\nbox-align:center`;", "box-align")] +#[case("const c = css`color:${'red'};\nbox-align:center`;", "box-align")] +#[case("const c = css`${'box-align'}:center`;", "box-align")] +#[case( + "const c = css`color:red; /* comment */ box-align /* gap */:center`;", + "box-align" +)] +#[case( + "globalCss({ fontFaces: [`src:url(a); box-align:center`] });", + "box-align" +)] +#[case("const c = css({ 'BOX-ALIGN': 'center' });", "'BOX-ALIGN'")] +#[serial] +fn text_mapping_keeps_authored_declaration_location(#[case] statement: &str, #[case] key: &str) { + reset_class_map(); + reset_file_map(); + let source = format!("import {{ css, globalCss }} from '@devup-ui/react';\n{statement}"); + let offset = position(&source, key); + let prefix = &source[..offset]; + let line = prefix.bytes().filter(|byte| *byte == b'\n').count() + 1; + let column = prefix.rsplit('\n').next().unwrap_or("").chars().count() + 1; + + let error = failure(extract("text.tsx", &source, ExtractOption::default())); + + assert!( + error.starts_with(&format!("text.tsx:{line}:{column}:")), + "{error}" + ); +} + +#[rstest] +#[case("const c = cAlias({ boxOrient: 'vertical' });", "boxOrient")] +#[case("const c = cAlias`box-orient:vertical`;", "box-orient")] +#[case("const k = kAlias({ from: { boxOrient: 'vertical' } });", "boxOrient")] +#[case("gAlias`body { box-orient:vertical }`;", "box-orient")] +#[serial] +fn import_aliases_use_the_same_declaration_boundary(#[case] statement: &str, #[case] key: &str) { + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{ css as cAlias, keyframes as kAlias }} from '@emotion/react'; import {{ globalCss as gAlias }} from '@devup-ui/react';\n{statement}" + ); + let options = ExtractOption { + import_aliases: std::collections::HashMap::from([( + "@emotion/react".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }; + + let error = failure(extract("alias.tsx", &source, options)); + + assert!( + error.starts_with(&format!("alias.tsx:2:{}:", position(statement, key) + 1)), + "{error}" + ); +} + +#[rstest] +#[case("const c = css`content:'box-align: center';--rules:{box-align:center};color:red`; ")] +#[case("const c = css`&[data-value=\"box-align: center\"] { color:red }`; ")] +#[case("const c = css`/* unterminated box-align:center`; ")] +#[serial] +fn scanner_ignores_balanced_custom_values_and_selector_strings(#[case] statement: &str) { + reset_class_map(); + reset_file_map(); + let source = format!("import {{ css }} from '@devup-ui/react';\n{statement}"); + + let result = extract("controls.tsx", &source, ExtractOption::default()); + + assert!(result.is_ok(), "{result:?}"); +} + +#[rstest] +#[case("keyframes({ from: { p: [[1, 2]] } });")] +#[case("globalCss({ body: { p: [[1, 2]] } });")] +#[serial] +fn unreadable_arrays_keep_the_stored_location(#[case] statement: &str) { + reset_class_map(); + reset_file_map(); + let source = format!("import {{ keyframes, globalCss }} from '@devup-ui/react';\n{statement}"); + + let error = failure(extract("arrays.tsx", &source, ExtractOption::default())); + + assert!( + error.starts_with(&format!( + "arrays.tsx:2:{}:", + position(statement, "[1, 2]") + 1 + )), + "{error}" + ); +} diff --git a/libs/extractor/src/extract_style/constant.rs b/libs/extractor/src/extract_style/constant.rs index 3388d219..ba3088f6 100644 --- a/libs/extractor/src/extract_style/constant.rs +++ b/libs/extractor/src/extract_style/constant.rs @@ -21,19 +21,21 @@ pub(crate) static MAINTAIN_VALUE_PROPERTIES: phf::Set<&str> = phf_set! { "aspect-ratio", "flex-grow", "flex-shrink", + "flex-order", + "flex-positive", + "flex-negative", "order", "grid-area", "grid-column", "grid-column-start", "grid-column-end", + "grid-column-span", "grid-row", "grid-row-start", "grid-row-end", + "grid-row-span", "animation-iteration-count", "tab-size", - "moz-tab-size", - "-webkit-line-clamp", - "webkit-line-clamp", "border-image", "border-image-outset", "border-image-slice", @@ -62,6 +64,21 @@ pub(crate) static MAINTAIN_VALUE_PROPERTIES: phf::Set<&str> = phf_set! { "stroke-width" }; +/// Strip one known vendor prefix for classification, preserving unknown names. +pub(crate) fn strip_vendor_prefix(property: &str) -> &str { + let prefixed = property.strip_prefix('-').unwrap_or(property); + ["webkit-", "moz-", "ms-", "o-"] + .into_iter() + .find_map(|prefix| prefixed.strip_prefix(prefix)) + .unwrap_or(property) +} + +/// Classify numeric values independently of vendor spelling, without changing +/// the property name emitted in CSS or deciding whether that name is valid. +pub(crate) fn is_maintain_value_property(property: &str) -> bool { + MAINTAIN_VALUE_PROPERTIES.contains(strip_vendor_prefix(property)) +} + /// Properties taking a time, whose numbers are milliseconds pub(crate) static TIME_PROPERTIES: phf::Set<&str> = phf_set! { "transition-duration", diff --git a/libs/extractor/src/extract_style/extract_static_style.rs b/libs/extractor/src/extract_style/extract_static_style.rs index 1adc78be..7efaa2fd 100644 --- a/libs/extractor/src/extract_style/extract_static_style.rs +++ b/libs/extractor/src/extract_style/extract_static_style.rs @@ -11,7 +11,7 @@ use css::{ use crate::{ extract_style::{ ExtractStyleProperty, - constant::{MAINTAIN_VALUE_PROPERTIES, TIME_PROPERTIES}, + constant::{TIME_PROPERTIES, is_maintain_value_property, strip_vendor_prefix}, style_property::StyleProperty, }, utils::{convert_value, gcd}, @@ -68,7 +68,7 @@ impl ExtractStaticStyle { // own result). Only the aspect-ratio reduction and the `convert_value` branch // must own; both already produce owned `String`s. Byte-identical output. let normalized: Cow = - if MAINTAIN_VALUE_PROPERTIES.contains(property) || property.starts_with("--") { + if is_maintain_value_property(property) || property.starts_with("--") { if apply_aspect_ratio && property == "aspect-ratio" && value.contains('/') { if let Some((a, b)) = value.split_once('/').and_then(|(a, b)| { Some((a.trim().parse::().ok()?, b.trim().parse::().ok()?)) @@ -81,7 +81,7 @@ impl ExtractStaticStyle { } else { Cow::Borrowed(value) } - } else if TIME_PROPERTIES.contains(property) { + } else if TIME_PROPERTIES.contains(strip_vendor_prefix(property)) { // A time is written in `ms`, never on the spacing scale value.parse::().map_or(Cow::Borrowed(value), |number| { Cow::Owned(format!("{}ms", crate::utils::js_number_string(number))) @@ -212,6 +212,9 @@ impl ExtractStyleProperty for ExtractStaticStyle { } } +#[cfg(test)] +mod numeric_css_edge_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/libs/extractor/src/extract_style/extract_static_style/numeric_css_edge_tests.rs b/libs/extractor/src/extract_style/extract_static_style/numeric_css_edge_tests.rs new file mode 100644 index 00000000..f8898970 --- /dev/null +++ b/libs/extractor/src/extract_style/extract_static_style/numeric_css_edge_tests.rs @@ -0,0 +1,222 @@ +use super::ExtractStaticStyle; +use crate::extract_style::extract_style_value::ExtractStyleValue; + +#[rstest::rstest] +#[case(("animation-duration", "3ms"))] +#[case(("animation-delay", "3ms"))] +#[case(("transition-duration", "3ms"))] +#[case(("transition-delay", "3ms"))] +#[case(("line-clamp", "3"))] +#[case(("border-radius", "12px"))] +fn numeric_css_08_vendor_categories_when_prefixed( + #[case] declaration: (&str, &str), + #[values("-webkit-", "webkit-", "-moz-", "moz-", "-ms-", "ms-", "-o-", "o-")] prefix: &str, + #[values(false, true)] basic: bool, +) { + let (base, expected) = declaration; + let property = format!("{prefix}{base}"); + let constructor = if basic { + ExtractStaticStyle::new_basic + } else { + ExtractStaticStyle::new + }; + let style = constructor(&property, "3", 0, None); + assert_eq!( + (style.property(), style.value()), + (property.as_str(), expected) + ); +} + +#[test] +#[serial_test::serial] +fn numeric_css_08_inventory_matches_unprefixed_numeric_form() +-> Result<(), Box> { + let inventory = " + WebkitBoxAlign MozBoxAlign WebkitBoxDirection MozBoxDirection + WebkitBoxFlex MozBoxFlex WebkitBoxFlexGroup MozBoxFlexGroup + WebkitBoxLines MozBoxLines WebkitBoxOrdinalGroup MozBoxOrdinalGroup + WebkitBoxOrient MozBoxOrient WebkitBoxPack MozBoxPack MozOverflowClipBox + msAccelerator msBlockProgression msContentZoomChaining msContentZoomLimitMax + msContentZoomLimitMin msContentZoomSnapPoints msContentZoomSnapType msContentZooming + msFilter msFlowFrom msFlowInto msGridColumns msGridRows msHighContrastAdjust + msHyphenateLimitChars msHyphenateLimitLines msHyphenateLimitZone msImeAlign + msOverflowStyle msScrollChaining msScrollLimitXMax msScrollLimitXMin + msScrollLimitYMax msScrollLimitYMin msScrollRails msScrollSnapPointsX + msScrollSnapPointsY msScrollSnapType msScrollTranslation msScrollbar3dlightColor + msScrollbarArrowColor msScrollbarBaseColor msScrollbarDarkshadowColor msScrollbarFaceColor + msScrollbarHighlightColor msScrollbarShadowColor msScrollbarTrackColor msTextAutospace + msTouchSelect msUserSelect msWrapFlow msWrapMargin msWrapThrough + MozAppearance MozBinding MozBorderBottomColors MozBorderLeftColors + MozBorderRightColors MozBorderTopColors MozContextProperties MozFloatEdge + MozForceBrokenImageIcon MozOrient MozOutlineRadiusBottomleft MozOutlineRadiusBottomright + MozOutlineRadiusTopleft MozOutlineRadiusTopright MozStackSizing MozTextBlink + MozUserFocus MozUserInput MozUserModify MozWindowDragging MozWindowShadow + WebkitAppearance WebkitBorderAfterColor WebkitBorderAfterStyle WebkitBorderAfterWidth + WebkitBorderBeforeColor WebkitBorderBeforeStyle WebkitBorderBeforeWidth + WebkitBorderEndColor WebkitBorderEndStyle WebkitBorderEndWidth + WebkitBorderStartColor WebkitBorderStartStyle WebkitBorderStartWidth WebkitBoxReflect + WebkitLineClamp WebkitMaskAttachment WebkitMaskClip WebkitMaskComposite WebkitMaskImage + WebkitMaskOrigin WebkitMaskPosition WebkitMaskPositionX WebkitMaskPositionY + WebkitMaskRepeat WebkitMaskRepeatX WebkitMaskRepeatY WebkitMaskSize + WebkitOverflowScrolling WebkitTapHighlightColor WebkitTextFillColor + WebkitTextStrokeColor WebkitTextStrokeWidth WebkitTouchCallout WebkitUserModify WebkitUserSelect + msContentZoomLimit msContentZoomSnap msScrollLimit msScrollSnapX msScrollSnapY + MozOutlineRadius WebkitBorderAfter WebkitBorderBefore WebkitBorderEnd WebkitBorderStart + WebkitMask WebkitTextStroke + msFlex msFlexOrder msFlexPositive msFlexNegative msGridColumnSpan msGridRowSpan MozTabSize + WebkitAnimationDuration WebkitAnimationDelay WebkitTransitionDuration WebkitTransitionDelay + MozAnimationDuration MozAnimationDelay MozTransitionDuration MozTransitionDelay + msAnimationDuration msAnimationDelay msTransitionDuration msTransitionDelay + OAnimationDuration OAnimationDelay OTransitionDuration OTransitionDelay + "; + for key in inventory.split_whitespace() { + let Some((prefix, suffix)) = [ + ("Webkit", "webkit"), + ("Moz", "moz"), + ("ms", "ms"), + ("O", "o"), + ] + .into_iter() + .find_map(|(prefix, css)| key.strip_prefix(prefix).map(|suffix| (css, suffix))) else { + panic!("inventory entry needs a vendor prefix: {key}"); + }; + let base = css::utils::to_kebab_case(suffix); + let property = format!("-{prefix}-{base}"); + let expected = ExtractStaticStyle::new(&base, "3", 0, None); + let source = + format!("import {{ Box }} from '@devup-ui/react'; const box = ;"); + let output = crate::extract( + "vendor-inventory.tsx", + &source, + crate::ExtractOption::default(), + )?; + assert_eq!(output.styles.len(), 1, "{key}"); + let Some(ExtractStyleValue::Static(style)) = output.styles.iter().next() else { + panic!("expected static vendor declaration: {key}"); + }; + assert_eq!( + (style.property(), style.value()), + (property.as_str(), expected.value()), + "{key}" + ); + } + Ok(()) +} + +#[rstest::rstest] +#[case("-ms-flex")] +#[case("-ms-flex-order")] +#[case("-ms-flex-positive")] +#[case("-ms-flex-negative")] +#[case("-webkit-box-flex")] +#[case("-webkit-box-ordinal-group")] +#[case("-ms-grid-column-span")] +#[case("-ms-grid-row-span")] +#[case("-moz-tab-size")] +#[case("-webkit-line-clamp")] +#[case("-o-opacity")] +#[case("o-opacity")] +fn numeric_css_08_constructors_keep_vendor_numbers(#[case] property: &str) { + let value = "2"; + let styles = [ + ExtractStaticStyle::new(property, value, 0, None), + ExtractStaticStyle::new_basic(property, value, 0, None), + ]; + for style in styles { + assert_eq!(style.value(), value); + assert_eq!(style.property(), property); + } +} + +#[rstest::rstest] +#[case("msFlex", "-ms-flex", "2")] +#[case("msFlexOrder", "-ms-flex-order", "2")] +#[case("msFlexPositive", "-ms-flex-positive", "2")] +#[case("msFlexNegative", "-ms-flex-negative", "2")] +#[case("WebkitBoxFlex", "-webkit-box-flex", "2")] +#[case("WebkitBoxOrdinalGroup", "-webkit-box-ordinal-group", "2")] +#[case("msGridColumnSpan", "-ms-grid-column-span", "2")] +#[case("msGridRowSpan", "-ms-grid-row-span", "2")] +#[case("MozTabSize", "-moz-tab-size", "2")] +#[case("WebkitLineClamp", "-webkit-line-clamp", "2")] +#[case("flex", "flex", "2")] +#[case("flexGrow", "flex-grow", "2")] +#[case("flexShrink", "flex-shrink", "2")] +#[case("flexBasis", "flex-basis", "8px")] +#[case("p", "padding", "8px")] +#[case("m", "margin", "8px")] +#[case("WebkitAnimationDuration", "-webkit-animation-duration", "2ms")] +#[serial_test::serial] +fn numeric_css_08_emits_classified_declarations( + #[case] key: &str, + #[case] property: &str, + #[case] value: &str, +) -> Result<(), Box> { + let source = + format!("import {{ Box }} from '@devup-ui/react'; const box = ;"); + let output = crate::extract( + "numeric-css-08.tsx", + &source, + crate::ExtractOption::default(), + )?; + assert_eq!(output.styles.len(), 1); + let Some(ExtractStyleValue::Static(style)) = output.styles.iter().next() else { + panic!("expected one static declaration"); + }; + assert_eq!((style.property(), style.value()), (property, value)); + Ok(()) +} + +#[rstest::rstest] +#[case( + "import { css } from '@emotion/react'; export const box = css({ msFlex: 2, WebkitBoxFlex: 2, msGridColumnSpan: 2, MozTabSize: 2, WebkitLineClamp: 2 });", + "numbers.ts" +)] +#[case( + "import { style } from '@vanilla-extract/css'; export const box = style({ msFlex: 2, WebkitBoxFlex: 2, msGridColumnSpan: 2, MozTabSize: 2, WebkitLineClamp: 2 });", + "numbers.css.ts" +)] +#[serial_test::serial] +fn numeric_css_08_library_callers_emit_bare_vendor_numbers( + #[case] source: &str, + #[case] filename: &str, +) -> Result<(), Box> { + let option = crate::ExtractOption { + import_aliases: std::collections::HashMap::from([ + ( + "@emotion/react".to_string(), + crate::ImportAlias::NamedToNamed, + ), + ( + "@vanilla-extract/css".to_string(), + crate::ImportAlias::NamedToNamed, + ), + ]), + ..crate::ExtractOption::default() + }; + let output = crate::extract(filename, source, option)?; + let values: std::collections::BTreeSet<_> = output + .styles + .iter() + .map(|style| match style { + ExtractStyleValue::Static(style) => (style.property(), style.value()), + ExtractStyleValue::Typography(_) + | ExtractStyleValue::Dynamic(_) + | ExtractStyleValue::Css(_) + | ExtractStyleValue::Import(_) + | ExtractStyleValue::FontFace(_) + | ExtractStyleValue::Keyframes(_) => panic!("expected static vendor declarations"), + }) + .collect(); + assert_eq!( + values, + std::collections::BTreeSet::from([ + ("-ms-flex", "2"), + ("-webkit-box-flex", "2"), + ("-ms-grid-column-span", "2"), + ("-moz-tab-size", "2"), + ("-webkit-line-clamp", "2"), + ]) + ); + Ok(()) +} 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 a4484b90..b0963606 100644 --- a/libs/extractor/src/extractor/extract_global_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_global_style_from_expression.rs @@ -10,25 +10,27 @@ use crate::{ extractor::{ GlobalExtractResult, extract_style_from_expression::{ - LiteralHandling, at_rule_record_kind, extract_style_from_expression, place_in_layer, - unreadable, unreadable_key, yield_typography, + LiteralHandling, at_rule_record_kind, extract_style_from_expression, misplaced, + place_in_layer, unreadable, unreadable_key, yield_typography, }, }, utils::{ - get_str_by_property_key, get_string_by_literal_expression, get_string_by_property_key, - unwrap_syntax_only_mut, + SELECTOR_NAME, get_str_by_property_key, get_string_by_literal_expression, + get_string_by_property_key, unwrap_syntax_only_mut, }, }; 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}, + style_selector::{AtRule, AtRuleKind, StyleSelector, is_selector_name}, + utils::to_kebab_case, }; use oxc_ast::{ ast::{ArrayExpressionElement, Expression, ObjectPropertyKind}, builder::AstBuilder, }; +use oxc_span::GetSpan; pub fn extract_global_style_from_expression<'a>( ast_builder: &AstBuilder<'a>, @@ -66,6 +68,11 @@ fn collect_global_styles<'a>( styles: &mut Vec>, ) { let expression = unwrap_syntax_only_mut(expression); + let invalid = crate::dead_properties::expression_errors(expression); + if !invalid.is_empty() { + styles.extend(invalid); + return; + } if let Expression::ObjectExpression(obj) = expression { for p in &mut obj.properties { @@ -181,6 +188,23 @@ fn collect_global_styles<'a>( 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; + } styles.push(ExtractStyleProp::Static(ExtractStyleValue::FontFace(ExtractFontFace { properties: o .properties @@ -204,6 +228,11 @@ fn collect_global_styles<'a>( file: file.to_string(), }))); } 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| { @@ -247,8 +276,19 @@ fn collect_global_styles<'a>( }; let global = StyleSelector::Global( - if let Some(name) = name.strip_prefix("_") { - StyleSelector::from(name).to_string().replace('&', "*") + if let Some(pseudo) = name.strip_prefix('_') { + let pseudo = to_kebab_case(pseudo); + if !is_selector_name(&pseudo) { + styles.push(misplaced( + o.key.span().start, + name, + SELECTOR_NAME, + )); + continue; + } + StyleSelector::from(pseudo.as_ref()) + .to_string() + .replace('&', "*") } else { name }, diff --git a/libs/extractor/src/extractor/extract_keyframes_from_expression.rs b/libs/extractor/src/extractor/extract_keyframes_from_expression.rs index ae468ce3..4f4a1161 100644 --- a/libs/extractor/src/extractor/extract_keyframes_from_expression.rs +++ b/libs/extractor/src/extractor/extract_keyframes_from_expression.rs @@ -7,44 +7,61 @@ use crate::{ LiteralHandling, extract_style_from_expression, unreadable, }, }, - utils::{ - fixed_value, get_string_by_property_key, readable_code, runtime_value, - unwrap_syntax_only_mut, - }, + utils::{get_string_by_property_key, readable_code, runtime_value, unwrap_syntax_only_mut}, }; use oxc_ast::{ ast::{Expression, ObjectPropertyKind}, builder::AstBuilder, }; +use oxc_span::GetSpan; -pub fn extract_keyframes_from_expression<'a>( +pub(crate) fn extract_keyframes_with_location<'a>( ast_builder: &AstBuilder<'a>, expression: &mut Expression<'a>, -) -> KeyframesExtractResult { +) -> (KeyframesExtractResult, Option) { let mut keyframes = ExtractKeyframes::default(); + let invalid = crate::dead_properties::expression_errors(expression); + if let Some((offset, value)) = + crate::dead_properties::located_value(&invalid, expression.span().start, true) + { + return ( + KeyframesExtractResult { + keyframes, + runtime_value: Some(value), + }, + Some(offset), + ); + } let Expression::ObjectExpression(obj) = unwrap_syntax_only_mut(expression) else { - return KeyframesExtractResult { - keyframes, - runtime_value: runtime_value(&unreadable(expression).styles), - }; + return ( + KeyframesExtractResult { + keyframes, + runtime_value: runtime_value(&unreadable(expression).styles), + }, + Some(expression.span().start), + ); }; let mut runtime = None; + let mut runtime_offset = None; for p in &mut obj.properties { let o = match p { ObjectPropertyKind::ObjectProperty(o) => o, ObjectPropertyKind::SpreadProperty(spread) => { - runtime = - runtime.or_else(|| Some(format!("...{}", readable_code(&spread.argument)))); + runtime = runtime + .or_else(|| Some((format!("...{}", readable_code(&spread.argument)), None))); continue; } }; let Some(name) = get_string_by_property_key(&o.key) else { runtime = runtime.or_else(|| { - Some(format!( - "[{}]", - o.key - .as_expression() - .map_or_else(String::new, readable_code) + Some(( + format!( + "[{}]", + o.key + .as_expression() + .map_or_else(String::new, readable_code) + ), + None, )) }); continue; @@ -57,7 +74,13 @@ pub fn extract_keyframes_from_expression<'a>( &None, LiteralHandling::ExpandResponsiveThemeToken, ); - runtime = runtime.or_else(|| fixed_value(&styles)); + if runtime.is_none() + && let Some((offset, value)) = + crate::dead_properties::located_value(&styles, o.value.span().start, true) + { + runtime = Some(value); + runtime_offset = Some(offset); + } let mut styles = styles .into_iter() @@ -72,8 +95,11 @@ pub fn extract_keyframes_from_expression<'a>( styles, ); } - KeyframesExtractResult { - keyframes, - runtime_value: runtime, - } + ( + KeyframesExtractResult { + keyframes, + runtime_value: runtime, + }, + runtime_offset, + ) } diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index 4b8f7f27..0f26950c 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -10,7 +10,8 @@ use crate::{ ExtractResult, extract_style_from_member_expression::extract_style_from_member_expression, }, utils::{ - expression_to_code, get_number_by_literal_expression, get_str_by_property_key, + CSS_TEXT, RESPONSIVE_ARRAY, SELECTOR_NAME, expression_to_code, + get_number_by_literal_expression, get_str_by_property_key, get_string_by_literal_expression, get_string_by_property_key, is_same_expression, readable_code, unwrap_syntax_only, unwrap_syntax_only_mut, }, @@ -20,7 +21,10 @@ use css::{ 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, - style_selector::{AtRuleKind, StyleSelector, optimize_selector}, + rm_css_comment::rm_css_comment, + style_selector::{ + AtRuleKind, StyleSelector, is_selector_name, optimize_selector, split_selector_list, + }, theme_tokens::{get_responsive_theme_token, get_typography_keys, is_responsive_theme_token}, utils::to_kebab_case, }; @@ -62,6 +66,7 @@ pub(crate) fn unreadable<'a>(expression: &Expression<'a>) -> ExtractResult<'a> { offset: expression.span().start, code: readable_code(expression), prop: false, + requirement: None, }] }, ..ExtractResult::default() @@ -146,6 +151,21 @@ pub(crate) fn unreadable_key<'a>(key: &PropertyKey<'_>, prop: bool) -> ExtractSt key.as_expression().map_or_else(String::new, readable_code) ), prop, + requirement: None, + } +} + +/// `code` at `offset`, readable but not what its place takes +pub(crate) const fn misplaced<'a>( + offset: u32, + code: String, + requirement: &'static str, +) -> ExtractStyleProp<'a> { + ExtractStyleProp::Unreadable { + offset, + code, + prop: false, + requirement: Some(requirement), } } @@ -214,6 +234,24 @@ pub fn extract_style_from_expression<'a>( let mut typo = false; let expression = unwrap_syntax_only_mut(expression); + if name.is_none() { + let mut styles = crate::dead_properties::expression_errors(expression); + styles.extend( + crate::dead_properties::authored_errors( + expression, + crate::dead_properties::ObjectKind::Styles, + ) + .into_iter() + .map(|(offset, code, requirement)| misplaced(offset, code, requirement)), + ); + if !styles.is_empty() { + return ExtractResult { + styles, + ..ExtractResult::default() + }; + } + } + if name.is_none() && selector.is_none() { let mut style_order = None; let mut style_vars = None; @@ -236,6 +274,8 @@ pub fn extract_style_from_expression<'a>( if let Some(name) = get_str_by_property_key(&prop.key) && !is_special_property(&name) { + // The upfront authored walk already checks every root key, + // including the literal spreads flattened above. for disassembled in disassemble_property(&name) { let disassembled: &str = &disassembled; if name == "styleOrder" { @@ -345,21 +385,18 @@ pub fn extract_style_from_expression<'a>( if let Some(name) = name && is_nested_selector_key(name) { + let offset = expression.span().start; let mut styles = vec![]; for part in split_selector_list(name) { - if let Some(nested) = nest_selectors_key(selector.as_ref(), part) { - styles.extend( - extract_style_from_expression( - ast_builder, - None, - expression, - level, - &Some(nested), - literal_handling, - ) - .styles, - ); - } + styles.extend(extract_under_key( + ast_builder, + part, + offset, + expression, + level, + selector, + literal_handling, + )); } return ExtractResult { styles, @@ -394,20 +431,17 @@ pub fn extract_style_from_expression<'a>( props.push(unreadable_key(&o.key, false)); continue; }; + let offset = o.key.span().start; for part in split_selector_list(key_name.trim()) { - if let Some(child) = nest_selectors_key(selector.as_ref(), part) { - props.extend( - extract_style_from_expression( - ast_builder, - None, - &mut o.value, - level, - &Some(child), - literal_handling, - ) - .styles, - ); - } + props.extend(extract_under_key( + ast_builder, + part, + offset, + &mut o.value, + level, + selector, + literal_handling, + )); } } } @@ -474,23 +508,20 @@ pub fn extract_style_from_expression<'a>( }; } - let nested = if let Some((kind, query)) = split_at_rule_key(name) { - Some(StyleSelector::nest_at_rule(selector.as_ref(), kind, query)) - } else { - name.strip_prefix('_') - .map(|child| nest_underscore_name(selector.as_ref(), child)) - }; - if let Some(nested) = nested { - return nested.map_or_else(ExtractResult::default, |nested| { - extract_style_from_expression( + if name.starts_with('_') || split_at_rule_key(name).is_some() { + let offset = expression.span().start; + return ExtractResult { + styles: extract_under_key( ast_builder, - None, + name, + offset, expression, level, - &Some(nested), + selector, literal_handling, - ) - }); + ), + ..ExtractResult::default() + }; } typo = name == "typography"; } @@ -540,11 +571,26 @@ pub fn extract_style_from_expression<'a>( ..ExtractResult::default() } } else { + let styles = css_to_style(&value, level, selector); ExtractResult { - styles: css_to_style(&value, level, selector) - .into_iter() - .map(|ex| ExtractStyleProp::Static(ExtractStyleValue::Static(ex))) - .collect(), + styles: if styles.is_empty() + && !matches!( + unwrap_syntax_only(expression), + Expression::BooleanLiteral(_) + ) + && !rm_css_comment(&value).trim().is_empty() + { + vec![misplaced( + expression.span().start, + readable_code(expression), + CSS_TEXT, + )] + } else { + styles + .into_iter() + .map(|ex| ExtractStyleProp::Static(ExtractStyleValue::Static(ex))) + .collect() + }, ..ExtractResult::default() } } @@ -840,6 +886,16 @@ pub fn extract_style_from_expression<'a>( } } Expression::ArrayExpression(array) => { + if literal_handling == LiteralHandling::KeepSingleClass { + return ExtractResult { + styles: vec![misplaced( + array.span.start, + readable_code(expression), + RESPONSIVE_ARRAY, + )], + ..ExtractResult::default() + }; + } let mut props = vec![]; for (idx, element) in array.elements.iter_mut().enumerate() { @@ -977,6 +1033,13 @@ pub fn extract_style_from_expression<'a>( if key_name == "params" { continue; } + if let Some(error) = crate::dead_properties::authored_declaration_error( + &key_name, + o.key.span().start, + ) { + props.push(error); + continue; + } for name in disassemble_property(&key_name) { let name: &str = &name; props.extend( @@ -1221,36 +1284,57 @@ pub(crate) fn at_rule_record_kind(name: &str) -> Option { } } -fn split_selector_list(key: &str) -> Vec<&str> { - let mut parts = vec![]; - let mut depth = 0usize; - let mut start = 0; - for (index, c) in key.char_indices() { - match c { - '(' => depth += 1, - ')' => depth = depth.saturating_sub(1), - ',' if depth == 0 => { - parts.push(key[start..index].trim()); - start = index + 1; - } - _ => {} - } +enum Nested { + Under(StyleSelector), + /// Its conditions can never hold together + Never, + Unknown, +} + +impl From> for Nested { + fn from(selector: Option) -> Self { + selector.map_or(Self::Never, Self::Under) } - if !key.is_empty() { - parts.push(key[start..].trim()); +} + +fn extract_under_key<'a>( + ast_builder: &AstBuilder<'a>, + key: &str, + offset: u32, + expression: &mut Expression<'a>, + level: u8, + selector: &Option, + literal_handling: LiteralHandling, +) -> Vec> { + match nest_selectors_key(selector.as_ref(), key) { + Nested::Under(nested) => { + extract_style_from_expression( + ast_builder, + None, + expression, + level, + &Some(nested), + literal_handling, + ) + .styles + } + Nested::Never => vec![], + Nested::Unknown => vec![misplaced(offset, key.to_string(), SELECTOR_NAME)], } - parts } /// Resolve a `_name` style key (without the `_`) under `parent`: media -/// shorthands such as `print` or `motionReduce` wrap it in `@media`, anything -/// else nests a selector. `None` means the styles can never apply. -fn nest_underscore_name(parent: Option<&StyleSelector>, name: &str) -> Option { +/// shorthands such as `print` or `motionReduce` wrap it in `@media`, a +/// pseudo-class or pseudo-element nests a selector. +fn nest_underscore_name(parent: Option<&StyleSelector>, name: &str) -> Nested { let name = to_kebab_case(name); if let Some(query) = media_shorthand_query(&name) { - return StyleSelector::nest_at_rule(parent, AtRuleKind::Media, query); + return StyleSelector::nest_at_rule(parent, AtRuleKind::Media, query).into(); + } + if !is_selector_name(&name) { + return Nested::Unknown; } - Some(StyleSelector::nest_selector( + Nested::Under(StyleSelector::nest_selector( parent, &StyleSelector::from(name.as_ref()).to_string(), )) @@ -1262,20 +1346,33 @@ fn is_nested_selector_key(key: &str) -> bool { key.starts_with(':') || key.contains('&') } -fn nest_selectors_key(parent: Option<&StyleSelector>, key: &str) -> Option { +/// A key without `&` is relative to the element: a pseudo-class, a +/// pseudo-element or an attribute applies to the element itself, a name such +/// as `hover` is a pseudo-class, and any other selector (`.child`, `> p`) +/// selects descendants +fn nest_selectors_key(parent: Option<&StyleSelector>, key: &str) -> Nested { if let Some((kind, query)) = split_at_rule_key(key) { - StyleSelector::nest_at_rule(parent, kind, query) + StyleSelector::nest_at_rule(parent, kind, query).into() } else if let Some(name) = key.strip_prefix('_') { nest_underscore_name(parent, name) - } else if key.starts_with(':') { - Some(StyleSelector::nest_selector(parent, &format!("&{key}"))) - } else if parent.is_some() { - Some(StyleSelector::nest_selector(parent, key)) + } else if key.contains('&') { + Nested::Under(StyleSelector::nest_selector(parent, key)) + } else if key.starts_with([':', '[']) { + Nested::Under(StyleSelector::nest_selector(parent, &format!("&{key}"))) + } else if is_name(key) { + nest_underscore_name(parent, key) } else { - Some(StyleSelector::from(key)) + Nested::Under(StyleSelector::nest_selector(parent, &format!("& {key}"))) } } +fn is_name(key: &str) -> bool { + key.strip_prefix('-') + .unwrap_or(key) + .starts_with(|c: char| c.is_ascii_alphabetic()) + && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') +} + fn typography_atom(name: &str, level: u8, selector: &Option) -> ExtractStaticStyle { ExtractStaticStyle { property: "typography".to_string(), 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 b52e3169..62e5c9f1 100644 --- a/libs/extractor/src/extractor/extract_style_from_member_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_member_expression.rs @@ -242,6 +242,7 @@ fn runtime_member<'a>( offset, code: readable_code(&member), prop: false, + requirement: None, }, } } diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index 425ed024..b97671fc 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -216,6 +216,26 @@ pub fn extract_style_from_styled<'a>( // Check if tag is styled.div or styled(...) // Extract CSS from template literal + let mut invalid = Vec::new(); + unreadable_styles( + &crate::dead_properties::template_errors(&tag.quasi), + true, + &mut invalid, + ); + errors.extend(invalid.into_iter().map(|(offset, code, requirement)| { + ( + offset, + build_time_error("styled", &code, requirement.unwrap_or(STYLE_OBJECT)), + ) + })); + if !errors.is_empty() { + return ( + ExtractResult::default(), + Expression::new_string_literal(SPAN, "", None, ast_builder), + errors, + ); + } + let TemplateStyles { styles, statements, @@ -319,11 +339,12 @@ pub fn extract_style_from_styled<'a>( ); let mut unreadable = Vec::new(); unreadable_styles(&styles, true, &mut unreadable); - errors.extend( - unreadable - .into_iter() - .map(|(offset, code)| (offset, build_time_error("styled", &code, STYLE_OBJECT))), - ); + errors.extend(unreadable.into_iter().map(|(offset, code, requirement)| { + ( + if offset == 0 { call.span.start } else { offset }, + build_time_error("styled", &code, requirement.unwrap_or(STYLE_OBJECT)), + ) + })); if let Some(default_class_name) = base.styles.take() { styles.extend(default_class_name.into_iter().map(ExtractStyleProp::Static)); } diff --git a/libs/extractor/src/extractor/extract_style_from_stylex.rs b/libs/extractor/src/extractor/extract_style_from_stylex.rs index 69ff713a..7e2ddeac 100644 --- a/libs/extractor/src/extractor/extract_style_from_stylex.rs +++ b/libs/extractor/src/extractor/extract_style_from_stylex.rs @@ -60,6 +60,13 @@ pub fn extract_stylex_declarations( continue; }; let name = normalize_stylex_property(name.as_ref()); + if let Some(requirement) = crate::dead_properties::requirement(&name) { + errors.push(( + property.key.span().start, + build_time_error(api, &name, requirement), + )); + continue; + } match stylex_value(&name, &property.value) { Some(value) => declarations.push((name, optimize_value(&value).into_owned())), None => errors.push(( @@ -244,10 +251,18 @@ fn extract_stylex_namespace<'a>( errors.push(key_error("stylex.create", &inner_prop.key)); continue; }; + let property = normalize_stylex_property(inner_name.as_ref()); + if let Some(requirement) = crate::dead_properties::requirement(&property) { + errors.push(( + inner_prop.key.span().start, + build_time_error("stylex.create", &property, requirement), + )); + continue; + } push_decomposed( &mut styles, decompose_value_conditions( - &normalize_stylex_property(inner_name.as_ref()), + &property, &inner_prop.value, &parent_selectors, leaf, @@ -259,6 +274,13 @@ fn extract_stylex_namespace<'a>( } let css_property = normalize_stylex_property(prop_name.as_ref()); + if let Some(requirement) = crate::dead_properties::requirement(&css_property) { + errors.push(( + style_prop.key.span().start, + build_time_error("stylex.create", &css_property, requirement), + )); + continue; + } if SHORTHAND_PROPERTIES.contains(css_property.as_str()) { eprintln!( "[stylex] WARNING: Shorthand property '{css_property}' may cause unexpected specificity issues. Consider using longhand properties (e.g., 'marginTop', 'paddingLeft')." @@ -354,6 +376,13 @@ fn extract_stylex_dynamic_namespace<'a>( continue; }; let css_property = normalize_stylex_property(&prop_name); + if let Some(requirement) = crate::dead_properties::requirement(&css_property) { + errors.push(( + prop.key.span().start, + build_time_error("stylex.create", &css_property, requirement), + )); + continue; + } // Check if value references a parameter (dynamic) let is_dynamic = if prop.shorthand { diff --git a/libs/extractor/src/extractor/mod.rs b/libs/extractor/src/extractor/mod.rs index 1b47b978..872388de 100644 --- a/libs/extractor/src/extractor/mod.rs +++ b/libs/extractor/src/extractor/mod.rs @@ -37,5 +37,5 @@ pub struct GlobalExtractResult<'a> { pub struct KeyframesExtractResult { pub keyframes: ExtractKeyframes, /// A value only known at runtime, which keyframes cannot hold - pub runtime_value: Option, + pub runtime_value: Option, } diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 68981f36..e25c9307 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -11,6 +11,8 @@ mod imported_constants; mod module_loader; mod mutations; mod prop_modify_utils; +#[cfg(test)] +mod responsive_selector_tests; mod source_map; mod style_values; mod stylex; @@ -19,6 +21,24 @@ mod util_type; mod utils; mod vanilla_extract; mod visit; + +mod dead_properties; +#[cfg(test)] +mod dead_properties_call_boundary_tests; +#[cfg(test)] +mod dead_properties_declaration_boundary_tests; +#[cfg(test)] +mod dead_properties_origin_tests; +#[cfg(test)] +mod dead_properties_responsive_origin_tests; +#[cfg(test)] +mod dead_properties_stylex_tests; +#[cfg(test)] +mod dead_properties_test_utils; +#[cfg(test)] +mod dead_properties_tests; +#[cfg(test)] +mod dead_properties_text_tests; use crate::extract_style::extract_style_value::ExtractStyleValue; use crate::visit::DevupVisitor; use css::file_map::{canonical, get_file_num_by_filename, is_global}; @@ -69,11 +89,13 @@ pub enum ExtractStyleProp<'a> { }, /// Styles written where the build cannot read them, reported as an error; /// `prop` for a computed key among an element's props, which the element - /// takes as it is at runtime + /// takes as it is at runtime; `requirement` for code that is readable but + /// not what the place takes, telling what it must be instead Unreadable { offset: u32, code: String, prop: bool, + requirement: Option<&'static str>, }, } @@ -113,10 +135,16 @@ impl<'a> ExtractStyleProp<'a> { expression: expression.clone_in(alloc), } } - ExtractStyleProp::Unreadable { offset, code, prop } => ExtractStyleProp::Unreadable { + ExtractStyleProp::Unreadable { + offset, + code, + prop, + requirement, + } => ExtractStyleProp::Unreadable { offset: *offset, code: code.clone(), prop: *prop, + requirement: *requirement, }, } } @@ -326,7 +354,7 @@ fn extract_source( // otherwise the untouched `transformed_code` is parsed directly (no copy). let processed_code: Option = if utils::is_vanilla_extract_file(filename) { // Use transformed code (with imports already pointing to @devup-ui/react) - match vanilla_extract::execute_stylesheet(&transformed_code, filename, &option, resolver) { + match vanilla_extract::execute_stylesheet(code, filename, &option, resolver) { Ok((collected, imports)) => { dependencies = imports.dependencies; // Keyframes names are generated, so extract the referenced ones @@ -367,6 +395,11 @@ fn extract_source( // A stylesheet another one imports must give its own values, and an // import cycle read too early fails as it does in ES modules, so both // are reported rather than hidden behind plain extraction + Err(error) if error.starts_with(dead_properties::ERROR_CHANNEL) => { + return Err(error + .trim_start_matches(dead_properties::ERROR_CHANNEL) + .into()); + } Err(error) if module_loader::loading_for_stylesheet() || error.contains(module_loader::IMPORT_CYCLE) => @@ -448,6 +481,10 @@ 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)) && evaluated.is_none() && !utils::is_vanilla_extract_file(filename) && let Some((computed, value_edits, read)) = build_time_values::evaluate( @@ -478,7 +515,15 @@ fn extract_source( .collect(); visitor.errors.append(&mut visitor.unknown_parts); if !visitor.errors.is_empty() { - let mut message = located_errors(filename, source, &edits, visitor.errors); + let mut errors = visitor.errors; + let error_edits = if evaluated.is_some() { + let calls = dead_properties::evaluated_calls(source, filename, &option); + dead_properties::map_evaluated(&mut errors, &edits, &calls)?; + &[][..] + } else { + edits.as_slice() + }; + let mut message = located_errors(filename, source, error_edits, errors); message += &changed_notes(&message, filename, source, &edits, &inlined.changed); return Err(message.into()); } @@ -836,8 +881,8 @@ mod tests { alternate: None, }; - assert!(empty.extract().is_empty()); - assert!(empty.into_extract().is_empty()); + assert_eq!(empty.extract(), vec![]); + assert_eq!(empty.into_extract(), vec![]); } #[test] @@ -7996,7 +8041,7 @@ globalCss() ) .unwrap_err() .to_string(), - "test.tsx:2:1: `globalCss()` cannot use `1` at build time: its values must be literals, theme tokens or constants, or be computed from them" + "test.tsx:2:11: `globalCss()` cannot use `1` at build time: its values must be literals, theme tokens or constants, or be computed from them" ); } @@ -9266,7 +9311,7 @@ export const B = styled.div`${SEL} & { color: ${C}; }`;", [ "src/App.tsx:6:56: `css()` cannot use `x` at build time: its values must be literals, theme tokens or constants, or be computed from them", "src/App.tsx:7:27: `globalCss()` cannot use `y` at build time: its values must be literals, theme tokens or constants, or be computed from them", - "src/App.tsx:8:18: `keyframes()` cannot use `z` at build time: its values must be literals, theme tokens or constants, or be computed from them", + "src/App.tsx:8:34: `keyframes()` cannot use `z` at build time: its values must be literals, theme tokens or constants, or be computed from them", "src/App.tsx:9:18: `css()` cannot use `x` at build time: its values must be literals, theme tokens or constants, or be computed from them", ] ); @@ -13598,7 +13643,7 @@ globalCss({ ); assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code.is_empty()); + assert_ne!(output.code, ""); } #[test] @@ -18778,7 +18823,7 @@ export const k = styled('div')({ color: SIZE });", &memory_resolver(CONSTANT_MODULES), ) .unwrap(); - assert!(without_imports.dependencies.is_empty()); + assert_eq!(without_imports.dependencies.len(), 0); let without_constants = extract_with_modules( "/src/Handler.tsx", "import { Box } from '@devup-ui/react';\nimport { handler } from './handler';\nexport const a = ;", @@ -20648,10 +20693,68 @@ const logical = ;" fn test_raw_selector_key_without_parent() { assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( r"import { Box } from '@devup-ui/react'; -const e = b, i': { color: 'blue' } }} />;" +const e = b, > i': { color: 'blue' } }} />;" ))); } + /// A selector key naming nothing, or a string that is not CSS text where a + /// selector takes styles, would give CSS that selects or declares nothing + #[test] + #[serial] + fn test_selectors_that_select_nothing_are_errors() { + let mut errors = vec![]; + for source in [ + "import { Box } from '@devup-ui/react';\nconst e = ;", + "import { Box } from '@devup-ui/react';\nconst e = ;", + "import { Box } from '@devup-ui/react';\nconst e = ;", + "import { Box } from '@devup-ui/react';\nconst e = ;", + "import { Box } from '@devup-ui/react';\nconst e = ;", + "import { css } from '@devup-ui/react';\nconst c = css({ _hover: { ':nope, _nope': { color: 'red' } } });", + "import { css } from '@devup-ui/react';\nconst c = css({ _focus: 'red' });", + "import { styled } from '@devup-ui/react';\nconst S = styled.div({ selectors: { nope: { color: 'red' } } });", + "import { globalCss } from '@devup-ui/react';\nglobalCss({ _nope: { color: 'red' } });", + ] { + reset_class_map(); + reset_file_map(); + errors.push( + extract("test.tsx", source, ExtractOption::default()) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(), + ); + } + let name = |location: &str, api: &str, code: &str| { + format!( + "test.tsx:2:{location}: {api} cannot use `{code}` at build time: {}", + utils::SELECTOR_NAME + ) + }; + let text = |location: &str, api: &str, code: &str| { + format!( + "test.tsx:2:{location}: {api} cannot use `{code}` at build time: {}", + utils::CSS_TEXT + ) + }; + assert_eq!( + errors, + [ + name("31", "``", "_notASelector"), + name("29", "``", "definitelyNotASelector"), + format!( + "{}\n{}", + name("29", "``", "div"), + name("29", "``", "i") + ), + text("23", "``", "\"external-class\""), + text("43", "``", "\"external-class\""), + name("43", "`css()`", "_nope"), + text("25", "`css()`", "\"red\""), + name("37", "`styled()`", "nope"), + name("13", "`globalCss()`", "_nope"), + ] + ); + } + #[test] #[serial] fn test_minus_zero_is_normalized() { diff --git a/libs/extractor/src/module_loader.rs b/libs/extractor/src/module_loader.rs index 4082ff39..7cb13f3d 100644 --- a/libs/extractor/src/module_loader.rs +++ b/libs/extractor/src/module_loader.rs @@ -185,7 +185,10 @@ impl<'r> ModuleLoader<'r> { } else { module.code }; - let script = crate::vanilla_extract::strip_typescript(&code, &module.path); + let instrumented = + crate::dead_properties::instrument(&code, &module.path, &self.option.package) + .map_err(|error| format!("{}{error}", crate::dead_properties::ERROR_CHANNEL))?; + let script = crate::vanilla_extract::strip_typescript(&instrumented.code, &module.path); let module_script = module_script(&script, &module.path, self, false)?; // Live bindings: a read before the binding is initialized fails as it // does in an ES module @@ -267,7 +270,7 @@ pub(crate) fn module_script( } continue; } - let module = if source == package { + let module = if source == package || source == "@vanilla-extract/css" { PACKAGE_BINDING.to_string() } else { loader.load(source, filename, entry)? diff --git a/libs/extractor/src/responsive_selector_tests.rs b/libs/extractor/src/responsive_selector_tests.rs new file mode 100644 index 00000000..17241b7b --- /dev/null +++ b/libs/extractor/src/responsive_selector_tests.rs @@ -0,0 +1,291 @@ +use crate::{ExtractOption, extract, extract_style::extract_style_value::ExtractStyleValue}; +use css::{class_map::reset_class_map, file_map::reset_file_map, style_selector::StyleSelector}; +use rstest::rstest; +use serial_test::serial; +use std::collections::BTreeSet; + +#[rstest] +#[case("[[1, 2], 3]", "[1, 2]")] +#[case("[0, [1, 2]]", "[1, 2]")] +#[case("[[[1, 2]], 3]", "[[1, 2]]")] +#[case("[[], 3]", "[]")] +#[case("[([1, 2]), 3]", "[1, 2]")] +#[case("[([1, 2] as const), 3]", "[1, 2]")] +#[case("[([1, 2] satisfies number[]), 3]", "[1, 2]")] +#[case("[([1, 2]!), 3]", "[1, 2]")] +#[case("[on ? [1, 2] : 3, 4]", "[1, 2]")] +#[case("[on ? 3 : ([1, 2] as const), 4]", "[1, 2]")] +#[case("[on && [1, 2], 4]", "[1, 2]")] +#[case("[true ? [1, 2] : 3, 4]", "[1, 2]")] +#[serial] +fn nested_responsive_arrays_are_errors( + #[case] value: &str, + #[case] nested: &str, + #[values("jsx", "css", "styled")] api: &str, +) { + // Given + reset_class_map(); + reset_file_map(); + let statement = match api { + "jsx" => format!("const e = ;"), + "css" => format!("const c = css({{ p: {value} }});"), + "styled" => format!("const S = styled.div({{ p: {value} }});"), + _ => unreachable!(), + }; + let source = format!("import {{ Box, css, styled }} from '@devup-ui/react';\n{statement}"); + let column = crate::dead_properties_test_utils::position(&statement, nested) + 1; + let owner = match api { + "jsx" => "``", + "css" => "`css()`", + "styled" => "`styled()`", + _ => unreachable!(), + }; + // When + let error = crate::dead_properties_test_utils::failure(extract( + "edges.tsx", + &source, + ExtractOption::default(), + )); + // Then + assert_eq!( + error, + format!( + "edges.tsx:2:{column}: {owner} cannot use `{nested}` at build time: responsive arrays must be flat; each entry supplies one breakpoint value or selector style object, not another array" + ) + ); +} + +#[rstest] +#[case("const e = ;")] +#[case("const c = css({ p: [1, null, 2, , 3] });")] +#[case("const S = styled.div({ p: [1, null, 2, , 3] });")] +#[case("const e = ;")] +#[serial] +fn flat_responsive_arrays_preserve_property_and_breakpoint( + #[case] statement: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = format!("import {{ Box, css, styled }} from '@devup-ui/react';\n{statement}"); + // When + let output = extract("edges.tsx", &source, ExtractOption::default())?; + // Then + let styles: BTreeSet<_> = output + .styles + .iter() + .map(|style| match style { + ExtractStyleValue::Static(style) => { + assert_eq!(style.selector, None); + (style.property.as_str(), style.level, style.value.as_str()) + } + other => panic!("expected static responsive style, got {other:?}"), + }) + .collect(); + assert_eq!( + styles, + BTreeSet::from([ + ("padding", 0, "4px"), + ("padding", 2, "8px"), + ("padding", 4, "12px") + ]) + ); + Ok(()) +} + +#[rstest] +#[case("idx", vec![(0, "4px"), (1, "8px"), (2, "12px"), (0, "16px"), (1, "20px"), (2, "24px")])] +#[case("1", vec![(0, "16px"), (1, "20px"), (2, "24px")])] +#[serial] +fn indexed_arrays_select_one_responsive_array( + #[case] index: &str, + #[case] expected: Vec<(u8, &str)>, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{ Box }} from '@devup-ui/react';\nconst e = ;" + ); + // When + let output = extract("edges.tsx", &source, ExtractOption::default())?; + // Then + let styles: BTreeSet<_> = output + .styles + .iter() + .map(|style| match style { + ExtractStyleValue::Static(style) => { + assert_eq!(style.property, "padding"); + assert_eq!(style.selector, None); + (style.level, style.value.as_str()) + } + other => panic!("expected static indexed style, got {other:?}"), + }) + .collect(); + assert_eq!(styles, expected.into_iter().collect()); + Ok(()) +} + +#[rstest] +#[case("")] +#[case("")] +#[case("")] +#[case("")] +#[serial] +fn hover_responsive_styles_are_scoped( + #[case] element: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = format!("import {{ Box }} from '@devup-ui/react';\nconst e = {element};"); + // When + let output = extract("edges.tsx", &source, ExtractOption::default())?; + // Then + let styles: BTreeSet<_> = output + .styles + .iter() + .map(|style| match style { + ExtractStyleValue::Static(style) => { + assert_eq!( + style.selector, + Some(StyleSelector::Selector("&:hover".to_string())) + ); + (style.property.as_str(), style.level, style.value.as_str()) + } + other => panic!("expected scoped static style, got {other:?}"), + }) + .collect(); + assert_eq!( + styles, + BTreeSet::from([("padding", 0, "4px"), ("padding", 2, "8px")]) + ); + Ok(()) +} + +#[test] +#[serial] +fn body_selector_is_a_located_build_error() { + // Given + reset_class_map(); + reset_file_map(); + let source = "import { Box } from '@devup-ui/react';\nconst e = ;"; + // When + let error = crate::dead_properties_test_utils::failure(extract( + "edges.tsx", + source, + ExtractOption::default(), + )); + // Then + assert_eq!( + error, + format!( + "edges.tsx:2:29: `` cannot use `body` at build time: {}", + crate::utils::SELECTOR_NAME + ) + ); +} + +#[test] +#[serial] +fn raw_parent_selector_preserves_scope() -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = "import { Box } from '@devup-ui/react';\nconst e = p': { color: 'pink' } }} />;"; + // When + let output = extract("edges.tsx", source, ExtractOption::default())?; + // Then + assert_eq!(output.styles.len(), 1); + let style = output + .styles + .iter() + .next() + .ok_or("expected one static style")?; + match style { + ExtractStyleValue::Static(style) => { + assert_eq!(style.property, "color"); + assert_eq!(style.value, "pink"); + assert_eq!(style.level, 0); + assert_eq!( + style.selector, + Some(StyleSelector::Selector("& > p".to_string())) + ); + } + other => panic!("expected scoped child style, got {other:?}"), + } + Ok(()) +} + +#[rstest] +#[case( + "", + "[\"red\", \"blue\"]" +)] +#[case("", "[{ p: 1 }]")] +#[case("", "[1, 2]")] +#[case("", "[1, 2]")] +#[serial] +fn nested_responsive_selector_values_are_errors(#[case] element: &str, #[case] nested: &str) { + // Given + reset_class_map(); + reset_file_map(); + let statement = format!("const e = {element};"); + let source = format!("import {{ Box }} from '@devup-ui/react';\n{statement}"); + let column = crate::dead_properties_test_utils::position(&statement, nested) + 1; + // When + let error = crate::dead_properties_test_utils::failure(extract( + "edges.tsx", + &source, + ExtractOption::default(), + )); + // Then + assert_eq!( + error, + format!( + "edges.tsx:2:{column}: `` cannot use `{nested}` at build time: {}", + crate::utils::RESPONSIVE_ARRAY + ) + ); +} + +#[test] +fn nested_array_diagnostic_retains_its_exact_span() { + use crate::extractor::extract_style_from_expression::{ + LiteralHandling, extract_style_from_expression, + }; + use oxc_allocator::Allocator; + use oxc_ast::{ast::Statement, builder::AstBuilder}; + use oxc_parser::Parser; + use oxc_span::SourceType; + + // Given + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let source = "[0, ([1, 2] as const)];"; + let mut parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let Statement::ExpressionStatement(statement) = &mut parsed.program.body[0] else { + panic!("expected array expression statement"); + }; + // When + let result = extract_style_from_expression( + &builder, + Some("padding"), + &mut statement.expression, + 0, + &None, + LiteralHandling::ExpandResponsiveThemeToken, + ); + // Then + let mut diagnostics = vec![]; + crate::utils::unreadable_styles(&result.styles, false, &mut diagnostics); + assert_eq!( + diagnostics, + vec![( + 5, + "[1, 2]".to_string(), + Some(crate::utils::RESPONSIVE_ARRAY) + )] + ); +} diff --git a/libs/extractor/src/snapshots/extractor__tests__maintain_value.snap b/libs/extractor/src/snapshots/extractor__tests__maintain_value.snap index a936a635..605b4206 100644 --- a/libs/extractor/src/snapshots/extractor__tests__maintain_value.snap +++ b/libs/extractor/src/snapshots/extractor__tests__maintain_value.snap @@ -1,13 +1,13 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr#\"import {Flex} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr\"import {Flex} from '@devup-ui/core'\n \n \",\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { Static( ExtractStaticStyle { property: "-moz-tab-size", - value: "16px", + value: "4", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__raw_selector_key_without_parent.snap b/libs/extractor/src/snapshots/extractor__tests__raw_selector_key_without_parent.snap index 3ac2686c..d5615675 100644 --- a/libs/extractor/src/snapshots/extractor__tests__raw_selector_key_without_parent.snap +++ b/libs/extractor/src/snapshots/extractor__tests__raw_selector_key_without_parent.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract_tsx(r\"import { Box } from '@devup-ui/react';\nconst e = b, i': { color: 'blue' } }} />;\"))" +expression: "ToBTreeSet::from(extract_tsx(r\"import { Box } from '@devup-ui/react';\nconst e = b, > i': { color: 'blue' } }} />;\"))" --- ToBTreeSet { styles: { @@ -11,7 +11,7 @@ ToBTreeSet { level: 0, selector: Some( Selector( - "&:a > b", + "& > i", ), ), style_order: None, @@ -25,7 +25,7 @@ ToBTreeSet { level: 0, selector: Some( Selector( - "&:i", + "& a > b", ), ), style_order: None, @@ -39,7 +39,7 @@ ToBTreeSet { level: 0, selector: Some( Selector( - "&:div p", + "& div p", ), ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__with_prefix.snap b/libs/extractor/src/snapshots/extractor__tests__with_prefix.snap index 08b5fc24..ebae17c8 100644 --- a/libs/extractor/src/snapshots/extractor__tests__with_prefix.snap +++ b/libs/extractor/src/snapshots/extractor__tests__with_prefix.snap @@ -1,13 +1,13 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr#\"import {Flex} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr\"import {Flex} from '@devup-ui/core'\n \n \",\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { Static( ExtractStaticStyle { property: "-moz-tab-size", - value: "16px", + value: "4", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/tailwind.rs b/libs/extractor/src/tailwind.rs index 87e9f517..70643aa7 100644 --- a/libs/extractor/src/tailwind.rs +++ b/libs/extractor/src/tailwind.rs @@ -284,7 +284,7 @@ pub struct TailwindClass { /// non-overlapping) but mutates the existing buffer instead of allocating a new /// `String`. `needle` must be non-empty. fn remove_all_substr(haystack: &mut String, needle: &str) { - debug_assert!(!needle.is_empty()); + debug_assert_ne!(needle, ""); let mut search_from = 0; while let Some(rel) = haystack[search_from..].find(needle) { let at = search_from + rel; @@ -3934,7 +3934,7 @@ mod tests { #[test] fn test_empty_string() { let styles = parse_tailwind_to_styles(""); - assert!(styles.is_empty()); + assert_eq!(styles, vec![]); } #[test] diff --git a/libs/extractor/src/utils.rs b/libs/extractor/src/utils.rs index 4fa94f72..15a011a7 100644 --- a/libs/extractor/src/utils.rs +++ b/libs/extractor/src/utils.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; -use crate::extract_style::constant::MAINTAIN_VALUE_PROPERTIES; +use crate::extract_style::constant::is_maintain_value_property; use css::utils::to_kebab_case; use oxc_allocator::{Allocator, CloneIn, GetAllocator}; use oxc_ast::{ @@ -29,7 +29,7 @@ pub(super) fn is_unitless_key(key: &str) -> bool { key.starts_with("--") || key.starts_with("var(") || key.bytes().all(|byte| byte.is_ascii_digit()) - || MAINTAIN_VALUE_PROPERTIES.contains(to_kebab_case(key).as_ref()) + || is_maintain_value_property(to_kebab_case(key).as_ref()) } /// Whether a number on `key` stays bare in a library whose numbers mean pixels: @@ -851,21 +851,25 @@ fn string_class<'a>(ast_builder: &AstBuilder<'a>, value: &Expression<'a>) -> Exp ) } +/// Code the build cannot use where it is written, with what the place takes +/// when that is not the usual requirement +pub(super) type Unused = (String, Option<&'static str>); + /// The first value in `props` that is only known at runtime -pub(super) fn runtime_value(props: &[crate::ExtractStyleProp<'_>]) -> Option { +pub(super) fn runtime_value(props: &[crate::ExtractStyleProp<'_>]) -> Option { let mut unreadable = Vec::new(); unreadable_styles(props, true, &mut unreadable); unreadable .into_iter() .next() - .map(|(_, code)| code) + .map(|(_, code, requirement)| (code, requirement)) .or_else(|| { props .iter() .flat_map(crate::ExtractStyleProp::extract) .find_map(|value| match value { crate::ExtractStyleValue::Dynamic(style) => { - Some(style.identifier().to_string()) + Some((style.identifier().to_string(), None)) } _ => None, }) @@ -875,14 +879,14 @@ pub(super) fn runtime_value(props: &[crate::ExtractStyleProp<'_>]) -> Option]) -> Option { - fn condition(prop: &crate::ExtractStyleProp<'_>) -> Option { +pub(super) fn fixed_value(props: &[crate::ExtractStyleProp<'_>]) -> Option { + fn condition(prop: &crate::ExtractStyleProp<'_>) -> Option { use crate::ExtractStyleProp; match prop { ExtractStyleProp::Conditional { condition, .. } - | ExtractStyleProp::Enum { condition, .. } => Some(readable_code(condition)), + | ExtractStyleProp::Enum { condition, .. } => Some((readable_code(condition), None)), ExtractStyleProp::MemberExpression { expression, .. } => { - Some(readable_code(expression)) + Some((readable_code(expression), None)) } ExtractStyleProp::StaticArray(props) => props.iter().find_map(condition), _ => None, @@ -891,19 +895,24 @@ pub(super) fn fixed_value(props: &[crate::ExtractStyleProp<'_>]) -> Option], keys: bool, - found: &mut Vec<(u32, String)>, + found: &mut Vec<(u32, String, Option<&'static str>)>, ) { use crate::ExtractStyleProp; for prop in props { match prop { - ExtractStyleProp::Unreadable { offset, code, prop } => { + ExtractStyleProp::Unreadable { + offset, + code, + prop, + requirement, + } => { if keys || !prop { - found.push((*offset, code.clone())); + found.push((*offset, code.clone(), *requirement)); } } ExtractStyleProp::StaticArray(props) => unreadable_styles(props, keys, found), @@ -949,6 +958,17 @@ pub(super) fn runtime_value_error(api: &str, value: &str) -> String { build_time_error(api, value, COMPUTED_VALUE) } +/// [`runtime_value_error`] for what [`runtime_value`] finds +pub(super) fn unused_error(api: &str, (code, requirement): &Unused) -> String { + build_time_error(api, code, requirement.unwrap_or(COMPUTED_VALUE)) +} + +pub(super) const SELECTOR_NAME: &str = "a selector key names a pseudo-class or pseudo-element, as `_hover` or `hover`, or is a selector, as `&:hover`, `& > p` or `.parent &`"; + +pub(super) const RESPONSIVE_ARRAY: &str = "responsive arrays must be flat; each entry supplies one breakpoint value or selector style object, not another array"; + +pub(super) const CSS_TEXT: &str = "a selector takes styles, an object such as `{ color: 'red' }` or CSS text such as `color: red`"; + pub(super) fn element_error(component: &str, code: &str, requirement: &str) -> String { format!("`<{component}>` cannot use `{code}` at build time: {requirement}") } @@ -1391,6 +1411,46 @@ mod tests { assert!(!keeps_bare_number("WebkitTextStrokeWidth")); } + #[rstest::rstest] + #[case("msFlex")] + #[case("msFlexOrder")] + #[case("msFlexPositive")] + #[case("msFlexNegative")] + #[case("WebkitBoxFlex")] + #[case("WebkitBoxOrdinalGroup")] + #[case("msGridColumnSpan")] + #[case("msGridRowSpan")] + #[case("MozTabSize")] + #[case("WebkitLineClamp")] + fn numeric_css_08_shared_callers_keep_vendor_numbers(#[case] key: &str) { + let kebab = to_kebab_case(key); + for spelling in [key, kebab.as_ref(), &format!("-{kebab}")] { + assert!(is_unitless_key(spelling), "{spelling}"); + assert!(keeps_bare_number(spelling), "{spelling}"); + } + } + + #[rstest::rstest] + #[case("flex", true, true)] + #[case("flexGrow", true, true)] + #[case("flexShrink", true, true)] + #[case("flexBasis", false, false)] + #[case("WebkitFlexBasis", false, false)] + #[case("WebkitTextStrokeWidth", false, false)] + #[case("unknown-flex", false, false)] + #[case("--ms-flex", true, true)] + #[case("p", false, true)] + #[case("mx", false, true)] + #[case("padding", false, false)] + fn numeric_css_08_shared_callers_preserve_existing_units( + #[case] key: &str, + #[case] unitless: bool, + #[case] bare: bool, + ) { + assert_eq!(is_unitless_key(key), unitless); + assert_eq!(keeps_bare_number(key), bare); + } + #[test] fn test_is_pure_and_suspends() { use oxc_ast_visit::Visit; diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 9e736b09..9d25d480 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -76,6 +76,8 @@ struct Collector { file_num: usize, placeholders: usize, identifiers: usize, + origin: Option<(String, String)>, + declaration_errors: Vec, } type StyleCollector = Rc>; @@ -241,8 +243,17 @@ pub fn execute_stylesheet( ) -> Result<(CollectedStyles, StylesheetImports), String> { let _evaluating = Evaluating::enter(filename); let mut loader = ModuleLoader::new(resolver, option); + let instrumented = crate::dead_properties::instrument(code, filename, &option.package) + .map_err(|error| format!("{}{error}", crate::dead_properties::ERROR_CHANNEL))?; + let instrumented = crate::import_alias_visit::transform_import_aliases_with_edits( + &instrumented.code, + filename, + &option.package, + &option.import_aliases, + ) + .0; let script = module_script( - &strip_typescript(code, filename), + &strip_typescript(&instrumented, filename), filename, &mut loader, true, @@ -281,6 +292,14 @@ pub fn execute_stylesheet( .eval(Source::from_bytes(run.as_bytes())) .map_err(|e| format!("JS execution error: {e}"))?; + if !collector.borrow().declaration_errors.is_empty() { + return Err(format!( + "{}{}", + crate::dead_properties::ERROR_CHANNEL, + collector.borrow().declaration_errors.join("\n") + )); + } + let mut collected = std::mem::take(&mut collector.borrow_mut().styles); name_entries( &mut collected, @@ -590,6 +609,43 @@ fn strip(code: &str, filename: &str) -> String { type Api = fn(&StyleCollector, &[JsValue], &mut Context) -> JsResult; +fn authored_call( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let location = to_text(args.get_or_undefined(0), context)?; + let api = to_text(args.get_or_undefined(1), context)?; + let previous = collector.borrow_mut().origin.replace((location, api)); + let result = args + .get_or_undefined(2) + .as_callable() + .map_or(Ok(JsValue::undefined()), |thunk| { + thunk.call(&JsValue::undefined(), &[], context) + }); + collector.borrow_mut().origin = previous; + result +} + +fn check_declarations( + collector: &StyleCollector, + json: &str, + kind: crate::dead_properties::ObjectKind, +) { + let errors = crate::dead_properties::evaluated_errors(json, kind); + let mut collector = collector.borrow_mut(); + if let Some((location, api)) = collector.origin.clone() { + collector + .declaration_errors + .extend(errors.into_iter().map(|(path, requirement)| { + format!( + "{location}: {}", + crate::utils::build_time_error(&api, &path, requirement) + ) + })); + } +} + fn api(collector: &StyleCollector, function: Api) -> NativeFunction { let collector = collector.clone(); // SAFETY: the closure captures only an `Rc>`, which holds no @@ -607,6 +663,7 @@ fn register_vanilla_extract_apis( collector: &StyleCollector, ) -> Result<(), String> { let apis = [ + ("__at", api(collector, authored_call), 3), ("style", api(collector, style), 1), ("globalStyle", api(collector, global_style), 2), ("styleVariants", api(collector, style_variants), 1), @@ -678,6 +735,11 @@ fn register_style( let mut rules = Vec::new(); compose(rule, &mut entry, &mut rules, context)?; entry.json = format!("{{{}}}", rules.join(",")); + check_declarations( + collector, + &entry.json, + crate::dead_properties::ObjectKind::Styles, + ); let mut collector = collector.borrow_mut(); let id = collector.placeholder(); collector.styles.styles.insert(id.clone(), entry); @@ -725,6 +787,7 @@ fn global_style( ) -> JsResult { let selector = to_text(args.get_or_undefined(0), context)?; let json = style_to_json(args.get_or_undefined(1), context); + check_declarations(collector, &json, crate::dead_properties::ObjectKind::Styles); collector .borrow_mut() .styles @@ -762,6 +825,11 @@ fn style_variants( fn keyframes(collector: &StyleCollector, args: &[JsValue], context: &mut Context) -> JsValue { let json = style_to_json(args.get_or_undefined(0), context); + check_declarations( + collector, + &json, + crate::dead_properties::ObjectKind::Records, + ); let mut collector = collector.borrow_mut(); let id = collector.placeholder(); collector.styles.keyframes.insert( @@ -780,6 +848,9 @@ fn font_face(collector: &StyleCollector, args: &[JsValue], context: &mut Context .borrow_mut() .identifier(js_str(args.get_or_undefined(1)), "font"); let faces = font_face_rules(&family, args.get_or_undefined(0), context); + for face in &faces { + check_declarations(collector, face, crate::dead_properties::ObjectKind::Styles); + } collector.borrow_mut().styles.font_faces.extend(faces); js_string!(family).into() } @@ -791,6 +862,9 @@ fn global_font_face( ) -> JsResult { let family = to_text(args.get_or_undefined(0), context)?; let faces = font_face_rules(&family, args.get_or_undefined(1), context); + for face in &faces { + check_declarations(collector, face, crate::dead_properties::ObjectKind::Styles); + } collector.borrow_mut().styles.font_faces.extend(faces); Ok(JsValue::undefined()) } diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 93846123..ff448e32 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -9,7 +9,7 @@ use crate::extract_style::extract_css::ExtractCss; use crate::extract_style::extract_keyframes::ExtractKeyframes; use crate::extract_style::style_property::StyleProperty; use crate::extractor::KeyframesExtractResult; -use crate::extractor::extract_keyframes_from_expression::extract_keyframes_from_expression; +use crate::extractor::extract_keyframes_from_expression::extract_keyframes_with_location; use crate::extractor::extract_style_from_stylex::{ extract_stylex_declarations, extract_stylex_namespace_styles, }; @@ -61,7 +61,7 @@ use crate::utils::{ 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, spread_error, stays_attribute, style_arguments, uncomposable_error, unplaced_error, - unreadable_styles, unwrap_syntax_only, unwrap_syntax_only_mut, + unreadable_styles, unused_error, unwrap_syntax_only, unwrap_syntax_only_mut, }; use oxc_ast::builder::AstBuilder; use oxc_span::{GetSpan, SPAN}; @@ -1514,14 +1514,19 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { && let [arg] = call.arguments.as_mut_slice() && let Some(arg @ Expression::ObjectExpression(_)) = arg.as_expression_mut() { - let KeyframesExtractResult { - keyframes, - runtime_value, - } = extract_keyframes_from_expression(&self.ast, arg); + let ( + KeyframesExtractResult { + keyframes, + runtime_value, + }, + runtime_offset, + ) = extract_keyframes_with_location(&self.ast, arg); if let Some(value) = runtime_value { self.errors.push(( - call.span.start, - runtime_value_error("stylex.keyframes", &value), + runtime_offset + .filter(|offset| *offset != 0) + .unwrap_or(call.span.start), + unused_error("stylex.keyframes", &value), )); } let name = @@ -1651,9 +1656,10 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { &None, LiteralHandling::ExpandResponsiveThemeToken, ); - if let Some(value) = runtime_value(&styles) { - self.errors - .push((offset, runtime_value_error("css", &value))); + if let Some((offset, value)) = + crate::dead_properties::located_value(&styles, offset, false) + { + self.errors.push((offset, unused_error("css", &value))); } if styles.is_empty() { @@ -1678,10 +1684,10 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } } } else if matches!(r, UtilType::Keyframes) { - let KeyframesExtractResult { + let (KeyframesExtractResult { keyframes, runtime_value, - } = extract_keyframes_from_expression( + }, runtime_offset) = crate::extractor::extract_keyframes_from_expression::extract_keyframes_with_location( &self.ast, if let Argument::SpreadElement(spread) = &mut call.arguments[0] { &mut spread.argument @@ -1690,8 +1696,12 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { }, ); if let Some(value) = runtime_value { - self.errors - .push((offset, runtime_value_error("keyframes", &value))); + self.errors.push(( + runtime_offset + .filter(|offset| *offset != 0) + .unwrap_or(offset), + unused_error("keyframes", &value), + )); } let name = style_property_into_string( @@ -1718,9 +1728,11 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { }, &self.filename, ); - if let Some(value) = fixed_value(&styles) { + if let Some((offset, value)) = + crate::dead_properties::located_value(&styles, offset, true) + { self.errors - .push((offset, runtime_value_error("globalCss", &value))); + .push((offset, unused_error("globalCss", &value))); } // already set style order let style_order = style_order.unwrap_or(0); @@ -1774,9 +1786,11 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { &mut folded, &self.filename, ); - if let Some(value) = fixed_value(&styles) { + if let Some((offset, value)) = + crate::dead_properties::located_value(&styles, offset, true) + { self.errors - .push((offset, runtime_value_error("globalCss", &value))); + .push((offset, unused_error("globalCss", &value))); } let style_order = style_order.unwrap_or(0); self.styles.extend( @@ -1819,6 +1833,23 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { UtilType::Keyframes => "keyframes", UtilType::GlobalCss | UtilType::GlobalCssComponent => "globalCss", }; + let mut invalid = Vec::new(); + unreadable_styles( + &crate::dead_properties::template_errors(&tag.quasi), + true, + &mut invalid, + ); + if !invalid.is_empty() { + self.errors + .extend(invalid.into_iter().map(|(offset, code, requirement)| { + ( + offset, + build_time_error(api, &code, requirement.unwrap_or(STYLE_OBJECT)), + ) + })); + *it = Expression::new_string_literal(SPAN, "", None, &self.ast); + return; + } let mut build_css_str = || { template_css_text(&tag.quasi, api).unwrap_or_else(|error| { self.errors.push(error); @@ -1837,7 +1868,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { .collect::>(); if let Some(value) = runtime_value(&style_props) { self.errors - .push((tag.span.start, runtime_value_error(api, &value))); + .push((tag.span.start, unused_error(api, &value))); } for index in unplaced { let expression = &tag.quasi.expressions[index]; @@ -2337,8 +2368,10 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { style_order, } = extract_global_style_from_expression(&self.ast, expression, &self.filename); if let Some(value) = fixed_value(&styles) { - self.errors - .push((offset, element_error(&name, &value, RUNTIME_VALUE))); + self.errors.push(( + offset, + element_error(&name, &value.0, value.1.unwrap_or(RUNTIME_VALUE)), + )); } let style_order = style_order.unwrap_or(0); self.styles.extend( @@ -2389,6 +2422,13 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { && !is_special_property(&name.name) { let property_name = name.name.as_str(); + if let Some(error) = crate::dead_properties::authored_declaration_error( + property_name, + name.span.start, + ) { + props_styles.push(error); + continue; + } for disassembled in disassemble_property(property_name) { // Probe with `contains`, run the body borrowing `&disassembled` // (it has no early exits), then MOVE the value into the set at @@ -2656,10 +2696,14 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { .for_each(|style| self.styles.extend(style.into_extract())); } - for (offset, code) in unreadable { + for (offset, code, requirement) in unreadable { self.errors.push(( - offset, - element_error(&elem.opening_element.name.to_string(), &code, STYLE_OBJECT), + if offset == 0 { elem.span.start } else { offset }, + element_error( + &elem.opening_element.name.to_string(), + &code, + requirement.unwrap_or(STYLE_OBJECT), + ), )); } diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs index 9a989df8..ed6e7fc1 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -3508,6 +3508,45 @@ mod tests { } } + #[test] + #[serial] + #[allow(clippy::literal_string_with_formatting_args)] + fn test_nested_selector_pipeline() { + for (source, expected) in [ + // Without `&`, a nested rule selects descendants; a pseudo-class or + // pseudo-element selects the component itself. + ( + "const X = styled.div`.child { color: red; } > p { color: blue; &:hover { color: green; } } :hover { color: black; } ::before { content: 'x'; } html.test & { display: none; } & + & { margin: 0; }`", + ".c0 + .c0{margin:0}.c1:hover{color:black}.c2 .c3{color:red}.c4 > p{color:blue}.c5 > p:hover{color:green}.c6::before{content:'x'}html.c7 .c8{display:none}", + ), + // Each selector of a list is relative on its own, and a rule nested + // in a list applies under every selector of it. + ( + "const X = styled.div`h1, &:after { color: red; } h2, h3 { span { color: blue; } }`", + ".c0 h1,.c0:after{color:red}.c1 h2 span,.c1 h3 span{color:blue}", + ), + // `content` keeps its strings as written. + ( + r#"const X = styled.div`&::before { content: 'hello world'; } &::after { content: "x"; } &.empty::after { content: ""; } &.escaped::after { content: "a\"b"; } &.attr::after { content: attr(data-x); }`"#, + r#".c0.c1::after{content:attr(data-x)}.c2.c3::after{content:""}.c4.c5::after{content:"a\"b"}.c6::after{content:"x"}.c7::before{content:'hello world'}"#, + ), + // `params` go on the pseudo-class that takes them, wherever nesting + // put it. + ( + "", + ":is([role=group],[data-group]):nth-child(3n) .c0:hover{color:blue}.c1:not(.c2):nth-child(2n){color:green}:is([role=group],[data-group]):nth-child(2n) .c3{color:red}", + ), + // A bare `selectors` key is a pseudo-class name, an attribute of the + // element, or a selector of its descendants, under a parent too. + ( + " p': { m: 2 }, _focus: { selectors: { focusVisible: { color: 'green' }, '[data-x]': { color: 'black' }, 'b i': { color: 'white' } } } }} _hover=\"color: pink\" _active={false} _focusWithin={[false, null, true]} />", + ".c0:hover{color:pink}.c1:hover{color:red}:is([role=group],[data-group]):hover .c2{color:blue}.c3 .c4{margin:4px}.c5 > p{margin:8px}.c6:focus b i{color:white}.c7:focus:focus-visible{color:green}.c8:focus[data-x]{color:black}.c9[aria-busy]{padding:4px}", + ), + ] { + assert_eq!(pipeline_css(Theme::default(), source), expected, "{source}"); + } + } + #[test] #[serial] #[allow(clippy::literal_string_with_formatting_args)]