diff --git a/src/css/css_parser.rs b/src/css/css_parser.rs index 54f041ba317d..4a2cc0413193 100644 --- a/src/css/css_parser.rs +++ b/src/css/css_parser.rs @@ -1595,8 +1595,12 @@ mod rule_parsers { b"container" => { let container_name: Option = input.try_parse(css_rules::container::ContainerName::parse).ok(); + let unknown_blocks = input.general_enclosed_blocks(); let condition: ContainerCondition = css_rules::container::ContainerCondition::parse(input)?; + if input.general_enclosed_blocks() != unknown_blocks { + media_query::warn_general_enclosed(input, this.options, &condition); + } break 'brk AtRulePrelude::Container { name: container_name, condition }; }, b"starting-style" => break 'brk AtRulePrelude::StartingStyle, @@ -3293,6 +3297,17 @@ impl<'a> Parser<'a> { self.input.token_list_parse_failures += 1; } + /// See `ParserInput::general_enclosed_blocks`. + #[inline] + pub(crate) fn general_enclosed_blocks(&self) -> u64 { + self.input.general_enclosed_blocks + } + + #[inline] + pub(crate) fn note_general_enclosed_block(&mut self) { + self.input.general_enclosed_blocks += 1; + } + pub(crate) fn is_exhausted(&mut self) -> bool { self.expect_exhausted().is_ok() } @@ -3308,7 +3323,6 @@ impl<'a> Parser<'a> { match tok { Token::Function(_) | Token::OpenParen | Token::OpenSquare | Token::OpenCurly => { self.parse_nested_block(|i| i.expect_no_error_token())?; - return Ok(()); } _ => { if tok.is_parse_error() { @@ -3833,6 +3847,8 @@ pub struct ParserInput<'a> { /// alternative is guaranteed to fail again, so they propagate the error /// instead of retrying (which is exponential in the nesting depth). token_list_parse_failures: u64, + /// Count of the blocks that became `` conditions. + general_enclosed_blocks: u64, } /// See `ParserInput::unclosed_block_at_eof`. @@ -3861,6 +3877,7 @@ impl<'a> ParserInput<'a> { unclosed_block_at_eof: None, math_fn_parse_failures: 0, token_list_parse_failures: 0, + general_enclosed_blocks: 0, } } } diff --git a/src/css/media_query.rs b/src/css/media_query.rs index 1258998f178e..fdeb10ac53cb 100644 --- a/src/css/media_query.rs +++ b/src/css/media_query.rs @@ -79,10 +79,16 @@ pub trait QueryCondition: Sized + ToCss { targets: &css::targets::Targets, ) -> bool; + /// Makes a `` condition from its source text. `None`: no such variant. + const GENERAL_ENCLOSED: Option Self> = None; + // ─── variant-walk accessors (drive `condition_to_css`) ─── fn as_feature(&self) -> Option<&Self::Feature>; fn as_not(&self) -> Option<&Self>; fn as_operation(&self) -> Option<(Operator, &[Self])>; + fn as_general_enclosed(&self) -> Option<&'static [u8]> { + None + } /// Serialize the leaf feature. Not defaulted: `Property::to_css` takes an /// extra `is_custom_property` flag, and `QueryFeature::to_css` is inherent @@ -111,6 +117,10 @@ pub trait QueryCondition: Sized + ToCss { if let Some((op, conds)) = self.as_operation() { return operation_to_css(op, conds, dest); } + if let Some(raw) = self.as_general_enclosed() { + // Source text: it has its own parentheses and can span lines. + return dest.write_bytes(raw); + } self.extra_to_css(dest) } } @@ -225,6 +235,8 @@ pub enum MediaCondition { operator: Operator, conditions: Vec, }, + /// ``, as its source text. + Unknown(&'static [u8]), } // ───────────────────────── QueryFeature / MediaFeature ───────────────────────── @@ -814,6 +826,14 @@ impl QueryCondition for MediaCondition { None } } + const GENERAL_ENCLOSED: Option Self> = Some(MediaCondition::Unknown); + fn as_general_enclosed(&self) -> Option<&'static [u8]> { + if let Self::Unknown(raw) = self { + Some(raw) + } else { + None + } + } fn feature_to_css(f: &MediaFeature, dest: &mut Printer) -> core::result::Result<(), PrintErr> { f.to_css(dest) } @@ -856,6 +876,7 @@ impl QueryCondition for MediaCondition { MediaCondition::Not(_) => true, MediaCondition::Operation { operator, .. } => Some(*operator) != parent_operator, MediaCondition::Feature(f) => f.needs_parens(parent_operator, targets), + MediaCondition::Unknown(_) => false, } } } @@ -1299,6 +1320,7 @@ impl MediaCondition { out }, }, + MediaCondition::Unknown(raw) => MediaCondition::Unknown(raw), } } } @@ -1435,6 +1457,7 @@ impl MediaList { impl MediaQuery { pub fn parse(input: &mut Parser, options: &css::ParserOptions) -> Result { + let unknown_blocks = input.general_enclosed_blocks(); let (qualifier, explicit_media_type) = input .try_parse(|i| -> Result<(Option, Option)> { let qualifier = i.try_parse(Qualifier::parse).ok(); @@ -1459,6 +1482,12 @@ impl MediaQuery { None }; + if input.general_enclosed_blocks() != unknown_blocks + && let Some(condition) = &condition + { + warn_general_enclosed(input, options, condition); + } + let media_type = explicit_media_type.unwrap_or(MediaType::All); Ok(MediaQuery { @@ -1501,41 +1530,23 @@ pub(crate) fn parse_query_condition_with_options( ) -> Result { use bun_core::strings; let location = input.current_source_location(); - let (is_negation, is_style) = 'brk: { - let tok = *input.next()?; - match &tok { - css::Token::OpenParen => break 'brk (false, false), - css::Token::Ident(ident) => { - if strings::eql_case_insensitive_ascii_check_length(ident, b"not") { - break 'brk (true, false); - } - } - css::Token::Function(f) => { - if flags.allow_style() - && strings::eql_case_insensitive_ascii_check_length(f, b"style") - { - break 'brk (false, true); - } - } - _ => {} - } - return Err(location.new_unexpected_token_error(tok)); - }; + input.skip_whitespace(); + let start = input.position(); + let tok = *input.next_including_whitespace_and_comments()?; - let alloc = ArenaPtr::new(input.arena()); - - // (is_negation, is_style) - let first_condition: C = match (is_negation, is_style) { - (true, false) => { + let first_condition: C = match &tok { + css::Token::Ident(ident) + if strings::eql_case_insensitive_ascii_check_length(ident, b"not") => + { let inner_condition = parse_parens_or_function::(input, flags, options)?; + let alloc = ArenaPtr::new(input.arena()); return Ok(C::create_negation_in(inner_condition, alloc)); } - (true, true) => { - let inner_condition = C::parse_style_query_with_options(input, options)?; - return Ok(C::create_negation_in(inner_condition, alloc)); + css::Token::OpenParen => parse_paren_block::(input, flags, options)?, + css::Token::Function(_) => { + parse_function::(input, location, start, tok, flags, options)? } - (false, false) => parse_paren_block::(input, flags, options)?, - (false, true) => C::parse_style_query_with_options(input, options)?, + _ => return Err(location.new_unexpected_token_error(tok)), }; let operator: Operator = match input.try_parse(Operator::parse) { @@ -1547,9 +1558,8 @@ pub(crate) fn parse_query_condition_with_options( return Err(location.new_unexpected_token_error(css::Token::Ident(b"or"))); } - let mut conditions: Vec = Vec::new_in(alloc); + let mut conditions: Vec = Vec::new_in(ArenaPtr::new(input.arena())); conditions.push(first_condition); - conditions.push(parse_parens_or_function::(input, flags, options)?); let delim: &[u8] = match operator { Operator::And => b"and", @@ -1557,33 +1567,119 @@ pub(crate) fn parse_query_condition_with_options( }; loop { + match parse_parens_or_function::(input, flags, options) { + Ok(operand) => conditions.push(operand), + Err(error) => return Err(operand_error(input, &conditions, error)), + } if input.try_parse(|i| i.expect_ident_matching(delim)).is_err() { return Ok(C::create_operation_in(operator, conditions)); } - conditions.push(parse_parens_or_function::(input, flags, options)?); } } +/// The error for a missing operand. `kept` holds the operands before it. +#[cold] +fn operand_error( + input: &Parser, + kept: &[C], + error: css::error::ParseError, +) -> css::error::ParseError { + // `MediaList::parse` drops a query that fails with `end_of_input`. + if matches!( + error.kind, + css::error::ParserErrorKind::basic(css::BasicParseErrorKind::end_of_input) + ) && kept + .iter() + .any(|operand| first_general_enclosed(operand).is_some()) + { + return input.new_custom_error(css::ParserError::invalid_media_query); + } + error +} + +/// Whether the text of `condition` starts with a function name, not with `(`. +fn starts_with_function(condition: &C) -> bool { + if let Some((_, operands)) = condition.as_operation() { + return operands.first().is_some_and(starts_with_function); + } + condition + .as_general_enclosed() + .is_some_and(|raw| !raw.starts_with(b"(")) +} + +/// The source text of the first `` in `condition`. +fn first_general_enclosed(condition: &C) -> Option<&'static [u8]> { + if let Some(inner) = condition.as_not() { + return first_general_enclosed(inner); + } + if let Some((_, operands)) = condition.as_operation() { + return operands.iter().find_map(first_general_enclosed); + } + condition.as_general_enclosed() +} + +/// Reports the first `` that `condition` holds, if any. +#[cold] +pub(crate) fn warn_general_enclosed( + input: &Parser, + options: &css::ParserOptions, + condition: &C, +) { + let Some(raw) = first_general_enclosed(condition) else { + return; + }; + let location = input.current_source_location(); + options.warn_fmt( + format_args!( + "Unknown condition is kept as written: {}", + bstr::BStr::new(raw) + ), + location.line, + location.column, + ); +} + /// Parse a media condition in parentheses, or a style() function. pub(crate) fn parse_parens_or_function( input: &mut Parser, flags: QueryConditionFlags, options: &css::ParserOptions, ) -> Result { - use bun_core::strings; let location = input.current_source_location(); - let t = *input.next()?; + input.skip_whitespace(); + let start = input.position(); + let t = *input.next_including_whitespace_and_comments()?; match &t { - css::Token::OpenParen => return parse_paren_block::(input, flags, options), - css::Token::Function(f) => { - if flags.allow_style() && strings::eql_case_insensitive_ascii_check_length(f, b"style") - { - return C::parse_style_query_with_options(input, options); + css::Token::OpenParen => parse_paren_block::(input, flags, options), + css::Token::Function(_) => parse_function::(input, location, start, t, flags, options), + _ => Err(location.new_unexpected_token_error(t)), + } +} + +/// Parse the block of the function `token`, which starts at `start`. +fn parse_function( + input: &mut Parser, + location: css::SourceLocation, + start: usize, + token: css::Token, + flags: QueryConditionFlags, + options: &css::ParserOptions, +) -> Result { + use bun_core::strings; + let block = input.state(); + let error = match token { + css::Token::Function(f) + if flags.allow_style() + && strings::eql_case_insensitive_ascii_check_length(f, b"style") => + { + match C::parse_style_query_with_options(input, options) { + Ok(condition) => return Ok(condition), + Err(error) => error, } } - _ => {} - } - Err(location.new_unexpected_token_error(t)) + _ => location.new_unexpected_token_error(token), + }; + parse_general_enclosed(input, &block, start, error) } fn parse_paren_block( @@ -1591,14 +1687,57 @@ fn parse_paren_block( flags: QueryConditionFlags, options: &css::ParserOptions, ) -> Result { - input.parse_nested_block(|i| { - if let Ok(inner) = - i.try_parse(|i2| parse_query_condition_with_options::(i2, flags, options)) + let block = input.state(); + // One capture for the closure, not one for each field. + let block_start = █ + let result = input.parse_nested_block(|i| { + // `(calc(1px) < width)` is a range. A bare `layer(x)` is an `@import` layer. + if let Ok(inner) = parse_query_condition_with_options::(i, flags, options) + && !starts_with_function(&inner) { return Ok(inner); } + // The nested input starts where `block` is, with no block pending. + i.reset(&css::ParserState { + at_start_of: None, + ..*block_start + }); C::parse_feature_with_options(i, options) - }) + }); + match result { + Ok(condition) => Ok(condition), + // The `(` is the byte before the block. + Err(error) => parse_general_enclosed(input, &block, block.position - 1, error), + } +} + +/// Keeps the block at `block`, which starts at `start`, as ``, or returns `error`. +#[cold] +fn parse_general_enclosed( + input: &mut Parser, + block: &css::ParserState, + start: usize, + error: css::error::ParseError, +) -> Result { + let Some(general_enclosed) = C::GENERAL_ENCLOSED else { + return Err(error); + }; + input.reset(block); + let is_closed = input + .parse_nested_block(|i| { + i.expect_no_error_token()?; + // The scan also stops at the end of the input. + if i.input.tokenizer.is_eof() { + return Err(i.new_error(css::BasicParseErrorKind::end_of_input)); + } + Ok(()) + }) + .is_ok(); + if !is_closed { + return Err(error); + } + input.note_general_enclosed_block(); + Ok(general_enclosed(input.slice_from_cloned(start))) } impl QueryFeature { diff --git a/src/css/rules/container.rs b/src/css/rules/container.rs index cf585ca26de2..3eca92cded22 100644 --- a/src/css/rules/container.rs +++ b/src/css/rules/container.rs @@ -208,6 +208,8 @@ pub enum ContainerCondition { }, /// A style query. Style(StyleQuery), + /// ``, as its source text. + Unknown(&'static [u8]), } impl ToCss for ContainerCondition { @@ -244,6 +246,14 @@ impl QueryCondition for ContainerCondition { None } } + const GENERAL_ENCLOSED: Option Self> = Some(ContainerCondition::Unknown); + fn as_general_enclosed(&self) -> Option<&'static [u8]> { + if let Self::Unknown(raw) = self { + Some(raw) + } else { + None + } + } fn feature_to_css( f: &ContainerSizeFeature, dest: &mut Printer, @@ -288,7 +298,7 @@ impl QueryCondition for ContainerCondition { ContainerCondition::Not(_) => true, ContainerCondition::Operation { operator, .. } => Some(*operator) != parent_operator, ContainerCondition::Feature(f) => f.needs_parens(parent_operator, targets), - ContainerCondition::Style(_) => false, + ContainerCondition::Style(_) | ContainerCondition::Unknown(_) => false, } } } @@ -308,6 +318,7 @@ impl ContainerCondition { conditions: conditions.iter().map(|c| c.deep_clone(bump)).collect(), }, Self::Style(q) => Self::Style(q.deep_clone(bump)), + Self::Unknown(raw) => Self::Unknown(raw), } } } diff --git a/test/bundler/esbuild/css.test.ts b/test/bundler/esbuild/css.test.ts index 0679a6644722..f4a98e0524b2 100644 --- a/test/bundler/esbuild/css.test.ts +++ b/test/bundler/esbuild/css.test.ts @@ -2315,4 +2315,122 @@ c { "/across-files-url.css", ], }); + // A media or container condition that is `` (Media + // Queries 4, section 3), such as the IE 9 and 10 hack `(min-width:0\\0)`. + // A browser reads it without an error and the rule applies nowhere. + const generalEnclosed = { + "/entry.css": /* css */ ` + @import "./ie.css" screen and (min-width:0\\0); + @media screen and (min-width:0\\0) { .media { color: red } } + @container scroll-state(stuck: top) { .container { color: red } } + `, + "/ie.css": /* css */ `.ie { color: red }`, + }; + itBundled("css/GeneralEnclosedCondition", { + files: generalEnclosed, + outfile: "/out.css", + onAfterBundle(api) { + api.expectFile("/out.css").toEqualIgnoringWhitespace(` + /* ie.css */ + @media screen and (min-width:0\\0) { + .ie { + color: red; + } + } + + /* entry.css */ + @media screen and (min-width:0\\0) { + .media { + color: red; + } + } + + @container scroll-state(stuck: top) { + .container { + color: red; + } + } + `); + }, + }); + itBundled("css/GeneralEnclosedConditionMinified", { + files: generalEnclosed, + outfile: "/out.css", + minifyWhitespace: true, + onAfterBundle(api) { + api + .expectFile("/out.css") + .toBe( + "@media screen and (min-width:0\\0){.ie{color:red}}" + + "@media screen and (min-width:0\\0){.media{color:red}}" + + "@container scroll-state(stuck: top){.container{color:red}}\n", + ); + }, + }); + // An `@import` that stays a statement keeps the condition as written. + // `(layer(x))` keeps its parentheses: without them it would name a layer. + itBundled("css/GeneralEnclosedConditionOnExternalImport", { + files: { + "/entry.css": /* css */ ` + @import "https://example.com/a.css" (min-width:0\\0); + @import "https://example.com/b.css" (layer(x)); + .entry { color: red } + `, + }, + outfile: "/out.css", + onAfterBundle(api) { + api.expectFile("/out.css").toEqualIgnoringWhitespace(` + @import "https://example.com/a.css" (min-width:0\\0); + @import "https://example.com/b.css" (layer(x)); + + /* entry.css */ + .entry { + color: red; + } + `); + }, + }); + // The text of a kept condition is not an import. + itBundled("css/GeneralEnclosedConditionURL", { + files: { + "/entry.css": /* css */ ` + @media (foo: url("./missing.png")) { .media { color: red } } + @container (style(--bg: url(./missing.png)) junk) { .container { color: red } } + `, + }, + outfile: "/out.css", + onAfterBundle(api) { + api.expectFile("/out.css").toEqualIgnoringWhitespace(` + /* entry.css */ + @media (foo: url("./missing.png")) { + .media { + color: red; + } + } + + @container (style(--bg: url(./missing.png)) junk) { + .container { + color: red; + } + } + `); + }, + }); + // A condition that is not closed, or holds an error token, is an error. + itBundled("css/InvalidConditionIsStillAnError", { + files: { + "/unclosed-condition.css": `@media (min-width:0\\0 { .a { color: red } }`, + "/dangling-and.css": `@media (min-width:0\\0) and { .a { color: red } }`, + "/error-token.css": `@media (a ] b) { .a { color: red } }`, + "/after-condition.css": `@media (min-width:0\\0) foo { .a { color: red } }`, + }, + entryPoints: ["/unclosed-condition.css", "/dangling-and.css", "/error-token.css", "/after-condition.css"], + outdir: "/out", + bundleErrors: { + "/unclosed-condition.css": ["Invalid media query"], + "/dangling-and.css": ["Invalid media query"], + "/error-token.css": ["Unexpected token: ]"], + "/after-condition.css": ["Unexpected token: foo"], + }, + }); }); diff --git a/test/js/bun/css/css.test.ts b/test/js/bun/css/css.test.ts index 890ffa4dcd95..5018e3371a3a 100644 --- a/test/js/bun/css/css.test.ts +++ b/test/js/bun/css/css.test.ts @@ -3,7 +3,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, tempDir } from "harness"; import { join } from "path"; import { cssTest, @@ -6012,9 +6012,11 @@ describe("css tests", () => { "@media (not (color)) or (hover) { .foo { color: chartreuse }}", "@media (not (color)) or (hover){.foo{color:#7fff00}}", ); - error_test( + // The example of Media Queries 4, section 3.2. `(example, all,)` is + // ``, so the list keeps both queries. + minify_test( "@media (example, all,), speech { .foo { color: chartreuse }}", - "ParserError::UnexpectedToken(Token::Comma)", + "@media (example, all,),speech{.foo{color:#7fff00}}", ); error_test("@media &test, speech { .foo { color: chartreuse }}", "ParserError::UnexpectedToken(Token::Delim('&'))"); error_test("@media &test { .foo { color: chartreuse }}", "ParserError::UnexpectedToken(Token::Delim('&'))"); @@ -6535,20 +6537,167 @@ describe("css tests", () => { true, ); - error_test("@media (min-width: hi) { .foo { color: chartreuse }}", "ParserError::InvalidMediaQuery"); - error_test("@media (width >= hi) { .foo { color: chartreuse }}", "ParserError::InvalidMediaQuery"); - error_test( - "@media (width >= 2/1) { .foo { color: chartreuse }}", - "ParserError::UnexpectedToken(Token::Delim('/'))", - ); - error_test("@media (600px <= min-height) { .foo { color: chartreuse }}", "ParserError::InvalidMediaQuery"); - error_test("@media (scan >= 1) { .foo { color: chartreuse }}", "ParserError::InvalidMediaQuery"); - error_test("@media (min-scan: interlace) { .foo { color: chartreuse }}", "ParserError::InvalidMediaQuery"); - error_test("@media (1px <= width <= bar) { .foo { color: chartreuse }}", "ParserError::InvalidMediaQuery"); - error_test("@media (1px <= min-width <= 2px) { .foo { color: chartreuse }}", "ParserError::InvalidMediaQuery"); - error_test("@media (1px <= scan <= 2px) { .foo { color: chartreuse }}", "ParserError::InvalidMediaQuery"); - error_test("@media (grid: 10) { .foo { color: chartreuse }}", "ParserError::InvalidMediaQuery"); - error_test("@media (prefers-color-scheme = dark) { .foo { color: chartreuse }}", "ParserError::InvalidMediaQuery"); + // A `( .. )` or `name( .. )` block that is no feature this parser knows is + // `` (Media Queries 4, section 3). It evaluates to + // unknown and is not a parse error, so it is kept as written. lightningcss + // rejects these eleven in its strict mode. Browsers keep them. + minify_test("@media (min-width: hi) { .foo { color: chartreuse }}", "@media (min-width: hi){.foo{color:#7fff00}}"); + minify_test("@media (width >= hi) { .foo { color: chartreuse }}", "@media (width >= hi){.foo{color:#7fff00}}"); + minify_test("@media (width >= 2/1) { .foo { color: chartreuse }}", "@media (width >= 2/1){.foo{color:#7fff00}}"); + minify_test( + "@media (600px <= min-height) { .foo { color: chartreuse }}", + "@media (600px <= min-height){.foo{color:#7fff00}}", + ); + minify_test("@media (scan >= 1) { .foo { color: chartreuse }}", "@media (scan >= 1){.foo{color:#7fff00}}"); + minify_test( + "@media (min-scan: interlace) { .foo { color: chartreuse }}", + "@media (min-scan: interlace){.foo{color:#7fff00}}", + ); + minify_test( + "@media (1px <= width <= bar) { .foo { color: chartreuse }}", + "@media (1px <= width <= bar){.foo{color:#7fff00}}", + ); + minify_test( + "@media (1px <= min-width <= 2px) { .foo { color: chartreuse }}", + "@media (1px <= min-width <= 2px){.foo{color:#7fff00}}", + ); + minify_test( + "@media (1px <= scan <= 2px) { .foo { color: chartreuse }}", + "@media (1px <= scan <= 2px){.foo{color:#7fff00}}", + ); + minify_test("@media (grid: 10) { .foo { color: chartreuse }}", "@media (grid: 10){.foo{color:#7fff00}}"); + minify_test( + "@media (prefers-color-scheme = dark) { .foo { color: chartreuse }}", + "@media (prefers-color-scheme = dark){.foo{color:#7fff00}}", + ); + + // `(min-width: 0\0)` is the IE 9 and 10 hack. + minify_test("@media screen and (min-width:0\\0){.a{top:1px}}", "@media screen and (min-width:0\\0){.a{top:1px}}"); + minify_test( + "@media screen and (min-width: 0\\0 ) { .a { top: 1px } }", + "@media screen and (min-width: 0\\0 ){.a{top:1px}}", + ); + cssTest( + "@media screen and (min-width: 0\\0 ) { .a { top: 1px } }", + "@media screen and (min-width: 0\\0 ) {\n .a {\n top: 1px;\n }\n}\n", + ); + minify_test( + "@media screen and (min-width:0\\0) and (min-resolution: +72dpi){.a{top:1px}}", + "@media screen and (min-width:0\\0) and (resolution>=72dpi){.a{top:1px}}", + ); + minify_test( + "@media screen and (min-width:0\\0), print{.a{top:1px}}", + "@media screen and (min-width:0\\0),print{.a{top:1px}}", + ); + minify_test("@media not (min-width:0\\0){.a{top:1px}}", "@media not (min-width:0\\0){.a{top:1px}}"); + minify_test("@media (min-width:0\\0) or (hover){.a{top:1px}}", "@media (min-width:0\\0) or (hover){.a{top:1px}}"); + minify_test("@media (hover) and (max-width:){.a{top:1px}}", "@media (hover) and (max-width:){.a{top:1px}}"); + minify_test("@media (){.a{top:1px}}", "@media (){.a{top:1px}}"); + minify_test("@media (foo: bar baz){.a{top:1px}}", "@media (foo: bar baz){.a{top:1px}}"); + minify_test("@media foo(bar){.a{top:1px}}", "@media foo(bar){.a{top:1px}}"); + minify_test("@media (a (b) [c] {d} e){.a{top:1px}}", "@media (a (b) [c] {d} e){.a{top:1px}}"); + minify_test("@media (min-width:\n0\\0){.a{top:1px}}", "@media (min-width:\n0\\0){.a{top:1px}}"); + minify_test("@custom-media --ie (min-width:0\\0);", "@custom-media --ie (min-width:0\\0);"); + minify_test('@import "b.css" screen and (min-width:0\\0);', '@import "b.css" screen and (min-width:0\\0);'); + + // Two rules merge when the text of their queries is the same. + minify_test( + "@media (min-width:0\\0){.a{top:1px}}@media (min-width:0\\0){.b{top:2px}}", + "@media (min-width:0\\0){.a{top:1px}.b{top:2px}}", + ); + minify_test( + "@media (min-width:0\\0){.a{top:1px}}@media (min-width:1\\0){.b{top:2px}}", + "@media (min-width:0\\0){.a{top:1px}}@media (min-width:1\\0){.b{top:2px}}", + ); + + // A range that starts with a function is still a range. + minify_test("@media (calc(1px + 2px) <= width){.a{top:1px}}", "@media (width>=3px){.a{top:1px}}"); + + // Any other group that starts with a function keeps its parentheses. + // Without them `(layer(x))` would name a layer after an `@import` URL. + minify_test("@media (foo(bar)){.a{top:1px}}", "@media (foo(bar)){.a{top:1px}}"); + minify_test("@media (calc(1px) and (color)){.a{top:1px}}", "@media (calc(1px) and (color)){.a{top:1px}}"); + minify_test("@media ((color) or foo(bar)){.a{top:1px}}", "@media (color) or foo(bar){.a{top:1px}}"); + minify_test('@import "b.css" (layer(x));', '@import "b.css" (layer(x));'); + minify_test('@import "b.css" ((LAYER(x)));', '@import "b.css" (LAYER(x));'); + minify_test('@import "b.css" (supports(display:grid));', '@import "b.css" (supports(display:grid));'); + minify_test('@import "b.css" (layer(x) or (color)), print;', '@import "b.css" (layer(x) or (color)),print;'); + minify_test('@import "b.css" layer(x) (min-width:0\\0);', '@import "b.css" layer(x) (min-width:0\\0);'); + + // A group with something after its condition is the block as written. + minify_test("@media ((color) and (hover) x){.a{top:1px}}", "@media ((color) and (hover) x){.a{top:1px}}"); + minify_test("@media ((x y) z){.a{top:1px}}", "@media ((x y) z){.a{top:1px}}"); + minify_test("@media ((color) and (hover)){.a{top:1px}}", "@media (color) and (hover){.a{top:1px}}"); + + // A feature with no value is the block as written. The rule is not one + // that applies to everything. + minify_test("@media screen and (width >= ){.a{top:1px}}", "@media screen and (width >= ){.a{top:1px}}"); + minify_test("@media (width >=), print{.a{top:1px}}", "@media (width >=),print{.a{top:1px}}"); + + // `@supports` reads an unknown condition with the same scan, which goes + // on after a nested block. The text is kept up to the closing parenthesis. + minify_test( + "@supports (a: b(c) d) and foo(bar(1) baz){.a{top:1px}}", + "@supports (a: b(c) d) and foo(bar(1) baz){.a{top:1px}}", + ); + minify_test("@supports selector(a:has(b) c){.a{top:1px}}", "@supports selector(a:has(b) c){.a{top:1px}}"); + minify_test("@supports selector(:is(a) ){.a{top:1px}}", "@supports selector(:is(a) ){.a{top:1px}}"); + minify_test( + "@import url(x.css) supports(transform:translate(1px) rotate(1deg));", + '@import "x.css" supports(transform:translate(1px) rotate(1deg));', + ); + + // The bundler reports each query that holds such a block. A range that + // starts with a function is known, so it is not reported. + test("a kept condition is a warning", async () => { + using dir = tempDir("css-general-enclosed-warning", { + "entry.css": ` + @import "./dep.css" screen and (min-width:0\\0); + @media (max-width: $tablet) and (color), print { .a { color: red } } + @container scroll-state(stuck: top) { .b { color: red } } + @media (calc(1px + 2px) <= width) { .c { color: red } } + `, + "dep.css": ".dep { color: red }", + }); + const result = await Bun.build({ entrypoints: [join(String(dir), "entry.css")], minify: true }); + expect(result.logs.map(log => [log.level, log.message])).toEqual([ + ["warn", "Unknown condition is kept as written: (min-width:0\\0)"], + ["warn", "Unknown condition is kept as written: (max-width: $tablet)"], + ["warn", "Unknown condition is kept as written: scroll-state(stuck: top)"], + ]); + expect(await result.outputs[0].text()).toBe( + "@media screen and (min-width:0\\0){.dep{color:red}}" + + "@media (max-width: $tablet) and (color),print{.a{color:red}}" + + "@container scroll-state(stuck: top){.b{color:red}}" + + "@media (min-width:3px){.c{color:red}}\n", + ); + }); + + for (const [source, error] of [ + // The block is not closed. + ["@media (min-width:0\\0 {.a{top:1px}}", "Invalid media query"], + ["@media foo(bar {.a{top:1px}}", "Unexpected token: foo("], + // The block holds an error token. + ["@media (a ] b){.a{top:1px}}", "Unexpected token: ]"], + ['@media (a "x\n b){.a{top:1px}}', 'Unexpected token: "x'], + ["@media (a url(x y) b){.a{top:1px}}", "Unexpected token: url(x y)"], + ["@import url(x.css) supports(transform:translate(1px) ]);", "Unexpected token: ]"], + ["@supports (a: b(c) ] d){.a{top:1px}}", "Unexpected token: ]"], + // The query ends after `and` or `or`. + ["@media (min-width:0\\0) and {.a{top:1px}}", "Invalid media query"], + ["@media (not (min-width:0\\0)) and {.a{top:1px}}", "Invalid media query"], + ["@media (min-width:0\\0) or {.a{top:1px}}", "Invalid media query"], + ['@import "b.css" (min-width:0\\0) and;', "Invalid media query"], + // Something follows the block. + ["@media (min-width:0\\0) foo{.a{top:1px}}", "Unexpected token: foo"], + ["@media (width <) foo{.a{top:1px}}", "Unexpected token: foo"], + // An error token follows the condition of a group. + ["@media ((color) ] x){.a{top:1px}}", "Unexpected token: ]"], + ]) { + test(`ERROR: ${source}`, () => { + expect(() => minify_test_with_options(source, "")).toThrow(`parsing failed: ${error}`); + }); + } }); describe("transition", () => { @@ -7867,6 +8016,37 @@ describe("css tests", () => { minify_test("@container (width > 100px) { a { color: red } }", "@container (width>100px){a{color:red}}"); minify_test("@container not (width > 100px) { a { color: red } }", "@container not (width>100px){a{color:red}}"); + // A block or a function this parser does not know is `` + // and is kept as written. + minify_test("@container (min-width:0\\0){a{color:red}}", "@container (min-width:0\\0){a{color:red}}"); + minify_test( + "@container scroll-state(stuck: top){a{color:red}}", + "@container scroll-state(stuck: top){a{color:red}}", + ); + minify_test( + "@container (scroll-state(stuck: top)){a{color:red}}", + "@container (scroll-state(stuck: top)){a{color:red}}", + ); + // A style query has no ``: the `style( .. )` is kept. + minify_test( + "@container style((x y) and (--a: 1)){a{color:red}}", + "@container style((x y) and (--a: 1)){a{color:red}}", + ); + minify_test( + "@container foo (width > 100px) and bar(baz){a{color:red}}", + "@container foo (width>100px) and bar(baz){a{color:red}}", + ); + minify_test("@container not foo(bar){a{color:red}}", "@container not foo(bar){a{color:red}}"); + for (const [source, error] of [ + ["@container foo(bar {a{color:red}}", "Unexpected token: foo("], + ['@container (a "x\n b){a{color:red}}', 'Unexpected token: "x'], + ["@container (min-width:0\\0) and {a{color:red}}", "Invalid media query"], + ]) { + test(`ERROR: ${source}`, () => { + expect(() => minify_test_with_options(source, "")).toThrow(`parsing failed: ${error}`); + }); + } + // `not` takes a single parenthesized query, so the grouping parens around // a nested and/or group must survive minification for the output to parse. minify_test( diff --git a/test/js/bun/css/nested-function-backtracking.test.ts b/test/js/bun/css/nested-function-backtracking.test.ts index 164aa6cdee93..21c46edd1586 100644 --- a/test/js/bun/css/nested-function-backtracking.test.ts +++ b/test/js/bun/css/nested-function-backtracking.test.ts @@ -85,3 +85,75 @@ test("stylesheets truncated inside an otherwise-valid value still parse", async expect(output).toContain("calc(100% - 5px)"); expect({ exitCode, stderr }).toMatchObject({ exitCode: 0 }); }); + +// A `( .. )` block in a media or container condition that is no known +// condition is read a second time, as raw tokens (``). + +const open50 = Buffer.alloc(50, "(").toString(); +const close50 = Buffer.alloc(50, ")").toString(); + +test("an unknown media condition block at every nesting level is kept as written", async () => { + // The token after each inner block makes the block around it unknown too. + let condition = "x y"; + for (let i = 0; i < 50; i++) condition = `(${condition}) z`; + const { stderr, exitCode, output } = await buildCSS( + "unknown-media-every-level.css", + `@media (${condition}){a{color:red}}`, + ); + expect(output).toContain(`@media (${condition}) {`); + expect({ exitCode, stderr }).toMatchObject({ exitCode: 0 }); +}); + +test("an unknown media condition block inside nested parentheses is kept", async () => { + const { stderr, exitCode, output } = await buildCSS( + "unknown-media-innermost.css", + `@media ${open50}x y${close50}{a{color:red}}`, + ); + expect(output).toContain("@media (x y) {"); + expect({ exitCode, stderr }).toMatchObject({ exitCode: 0 }); +}); + +test("nested media condition blocks around an error token error out", async () => { + const { stderr, exitCode } = await buildCSS("error-token-media.css", `@media ${open50}x ] y${close50}{a{color:red}}`); + expect(stderr).toContain("Invalid media query"); + expect(exitCode).toBe(1); +}); + +test("unclosed nested media condition blocks error out", async () => { + const { stderr, exitCode } = await buildCSS("unclosed-media.css", `@media ${open50}x y{a{color:red}}`); + expect(stderr).toContain("Invalid media query"); + expect(exitCode).toBe(1); +}); + +// Blocks nest up to `MAX_NESTING_DEPTH` (512). The second read of a block +// must fit the same stack as the first. +const open512 = Buffer.alloc(512, "(").toString(); +const close512 = Buffer.alloc(512, ")").toString(); + +test.concurrent.each([ + ["@media", "color", "(color)"], + ["@media", "x y", "(x y)"], + ["@container", "width > 1px", "(width > 1px)"], + ["@container", "x y", "(x y)"], +])("%s with 512 nested blocks around `%s` builds", async (rule, inner, printed) => { + const { stderr, exitCode, output } = await buildCSS( + "nested-512.css", + `${rule} ${open512}${inner}${close512}{a{color:red}}`, + ); + expect(output).toContain(`${rule} ${printed} {`); + expect({ exitCode, stderr }).toMatchObject({ exitCode: 0 }); +}); + +test.concurrent.each([ + ["@media", "color"], + ["@media", "x y"], + ["@container", "width > 1px"], + ["@container", "x y"], +])("%s with 513 nested blocks around `%s` is an error", async (rule, inner) => { + const { stderr, exitCode } = await buildCSS( + "nested-513.css", + `${rule} (${open512}${inner}${close512}){a{color:red}}`, + ); + expect(stderr).toContain("error: Invalid media query"); + expect(exitCode).toBe(1); +});