diff --git a/crates/biome_css_formatter/src/css/auxiliary/url_function.rs b/crates/biome_css_formatter/src/css/auxiliary/url_function.rs index 32c13bbbce9c..a0daa0177e7c 100644 --- a/crates/biome_css_formatter/src/css/auxiliary/url_function.rs +++ b/crates/biome_css_formatter/src/css/auxiliary/url_function.rs @@ -14,15 +14,12 @@ impl FormatNodeRule for FormatCssUrlFunction { r_paren_token, } = node.as_fields(); - write!( - f, - [ - name.format(), - l_paren_token.format(), - value.format(), - modifiers.format(), - r_paren_token.format() - ] - ) + write!(f, [name.format(), l_paren_token.format(), value.format()])?; + + if value.is_some() && modifiers.iter().next().is_some() { + write!(f, [space()])?; + } + + write!(f, [modifiers.format(), r_paren_token.format()]) } } diff --git a/crates/biome_css_formatter/tests/specs/css/scss/at-rule/container-general-enclosed.scss.snap b/crates/biome_css_formatter/tests/specs/css/scss/at-rule/container-general-enclosed.scss.snap index f3ac59eed61f..e457b1911cbb 100644 --- a/crates/biome_css_formatter/tests/specs/css/scss/at-rule/container-general-enclosed.scss.snap +++ b/crates/biome_css_formatter/tests/specs/css/scss/at-rule/container-general-enclosed.scss.snap @@ -1,5 +1,6 @@ --- source: crates/biome_formatter_test/src/snapshot_builder.rs +assertion_line: 240 info: css/scss/at-rule/container-general-enclosed.scss --- @@ -38,7 +39,7 @@ info: css/scss/at-rule/container-general-enclosed.scss .b { color: blue; } } -@container (foo(url("a.svg"namespace.fn(test)))) { +@container (foo(url("a.svg" namespace.fn(test)))) { .c { color: green; } @@ -50,7 +51,7 @@ info: css/scss/at-rule/container-general-enclosed.scss @container main-layout or(test) { } -@container main-layout foo(url("a.svg"fn(test))) { +@container main-layout foo(url("a.svg" fn(test))) { } ``` diff --git a/crates/biome_css_formatter/tests/specs/css/scss/at-rule/supports-general-enclosed.scss.snap b/crates/biome_css_formatter/tests/specs/css/scss/at-rule/supports-general-enclosed.scss.snap index b6d2652cd12c..1da7dca73935 100644 --- a/crates/biome_css_formatter/tests/specs/css/scss/at-rule/supports-general-enclosed.scss.snap +++ b/crates/biome_css_formatter/tests/specs/css/scss/at-rule/supports-general-enclosed.scss.snap @@ -1,5 +1,6 @@ --- source: crates/biome_formatter_test/src/snapshot_builder.rs +assertion_line: 240 info: css/scss/at-rule/supports-general-enclosed.scss --- @@ -36,7 +37,7 @@ info: css/scss/at-rule/supports-general-enclosed.scss } } -@supports foo(url("a.svg"namespace.fn(test))) { +@supports foo(url("a.svg" namespace.fn(test))) { .c { color: green; } diff --git a/crates/biome_css_formatter/tests/specs/css/scss/declaration/url-interpolated-functions.scss b/crates/biome_css_formatter/tests/specs/css/scss/declaration/url-interpolated-functions.scss new file mode 100644 index 000000000000..bf0ca75b1c64 --- /dev/null +++ b/crates/biome_css_formatter/tests/specs/css/scss/declaration/url-interpolated-functions.scss @@ -0,0 +1,13 @@ +.urls{ +expr3:url( a#{b}"c" ); +expr4:url( foo#{1 + 1}( bar ) ); +expr5:url( +#{name}( bar ) +); +expr6:url( #{prefix}-#{suffix}( bar ) ); +modifier-interpolated-mid:url( +"a.png" foo#{1 + 1}( bar ) +); +modifier-interpolated-start:url( "a.png" +#{format}( woff2 ) ); +} diff --git a/crates/biome_css_formatter/tests/specs/css/scss/declaration/url-interpolated-functions.scss.snap b/crates/biome_css_formatter/tests/specs/css/scss/declaration/url-interpolated-functions.scss.snap new file mode 100644 index 000000000000..87b084b49ed5 --- /dev/null +++ b/crates/biome_css_formatter/tests/specs/css/scss/declaration/url-interpolated-functions.scss.snap @@ -0,0 +1,39 @@ +--- +source: crates/biome_formatter_test/src/snapshot_builder.rs +assertion_line: 240 +info: css/scss/declaration/url-interpolated-functions.scss +--- + +# Input + +```scss +.urls{ +expr3:url( a#{b}"c" ); +expr4:url( foo#{1 + 1}( bar ) ); +expr5:url( +#{name}( bar ) +); +expr6:url( #{prefix}-#{suffix}( bar ) ); +modifier-interpolated-mid:url( +"a.png" foo#{1 + 1}( bar ) +); +modifier-interpolated-start:url( "a.png" +#{format}( woff2 ) ); +} + +``` + + +# Formatted + +```scss +.urls { + expr3: url(a#{b}"c"); + expr4: url(foo#{1 + 1}(bar)); + expr5: url(#{name}(bar)); + expr6: url(#{prefix}-#{suffix}(bar)); + modifier-interpolated-mid: url("a.png" foo#{1 + 1}(bar)); + modifier-interpolated-start: url("a.png" #{format}(woff2)); +} + +``` diff --git a/crates/biome_css_parser/src/lexer/mod.rs b/crates/biome_css_parser/src/lexer/mod.rs index f9a69071c1a7..2d4448822655 100644 --- a/crates/biome_css_parser/src/lexer/mod.rs +++ b/crates/biome_css_parser/src/lexer/mod.rs @@ -1,7 +1,14 @@ //! An extremely fast, lookup table based, СSS lexer which yields SyntaxKind tokens used by the rome-css parser. #[rustfmt::skip] mod tests; +mod scan_cursor; +mod source_cursor; +use self::scan_cursor::CssScanCursor; +use self::scan_cursor::{ + PendingUrlRawValueScan, StringBodyScan, StringBodyScanStop, UrlBodyStartScan, +}; +use self::source_cursor::SourceCursor; use crate::CssParserOptions; use biome_css_syntax::{ CssFileSource, CssSyntaxKind, CssSyntaxKind::*, T, TextLen, TextRange, TextSize, @@ -13,10 +20,8 @@ use biome_parser::lexer::{ use biome_rowan::SyntaxKind; use biome_unicode_table::{ Dispatch::{self, *}, - is_css_non_ascii, lookup_byte, + lookup_byte, }; -use smallvec::SmallVec; -use std::char::REPLACEMENT_CHARACTER; #[derive(Debug, Copy, Clone, Eq, PartialEq, Default)] pub enum CssLexContext { @@ -32,7 +37,11 @@ pub enum CssLexContext { PseudoNthSelector, /// Applied when lexing CSS url function. - /// Greedily consume tokens in the URL function until encountering ")" + /// Chooses whether the first body token should stay on regular tokenization + /// or become a raw URL literal. + UrlBody { scss_exclusive_syntax_allowed: bool }, + /// Applied when lexing CSS url function raw bodies after classification. + /// Greedily consume tokens in the URL function until encountering `)`. UrlRawValue, /// Applied when lexing CSS color literals. @@ -111,11 +120,8 @@ pub enum CssReLexContext { /// An extremely fast, lookup table based, lossless CSS lexer #[derive(Debug)] pub(crate) struct CssLexer<'src> { - /// Source text - source: &'src str, - - /// The start byte position in the source text of the next token. - position: usize, + /// Source text plus the next-token byte position within it. + cursor: SourceCursor<'src>, /// `true` if there has been a line break between the last non-trivia token and the next non-trivia token. after_newline: bool, @@ -127,7 +133,7 @@ pub(crate) struct CssLexer<'src> { /// Byte offset of the current token from the start of the source. /// - /// The range of the current token can be computed by `self.position - self.current_start` + /// The range of the current token can be computed by `self.position() - self.current_start` current_start: TextSize, /// The kind of the current token @@ -141,6 +147,7 @@ pub(crate) struct CssLexer<'src> { options: CssParserOptions, source_type: CssFileSource, pending_scss_string_start: Option, + pending_url_raw_value_scan: Option, } impl<'src> Lexer<'src> for CssLexer<'src> { @@ -152,7 +159,7 @@ impl<'src> Lexer<'src> for CssLexer<'src> { type ReLexContext = CssReLexContext; fn source(&self) -> &'src str { - self.source + self.cursor.source() } fn current(&self) -> Self::Kind { @@ -160,7 +167,7 @@ impl<'src> Lexer<'src> for CssLexer<'src> { } fn position(&self) -> usize { - self.position + self.cursor.position() } fn current_start(&self) -> TextSize { @@ -183,6 +190,9 @@ impl<'src> Lexer<'src> for CssLexer<'src> { } CssLexContext::Selector => self.consume_selector_token(current), CssLexContext::PseudoNthSelector => self.consume_pseudo_nth_selector_token(current), + CssLexContext::UrlBody { + scss_exclusive_syntax_allowed, + } => self.consume_url_body_token(current, scss_exclusive_syntax_allowed), CssLexContext::UrlRawValue => self.consume_url_raw_value_token(current), CssLexContext::Color => self.consume_color_token(current), CssLexContext::UnicodeRange => self.consume_unicode_range_token(current), @@ -235,9 +245,7 @@ impl<'src> Lexer<'src> for CssLexer<'src> { diagnostics_pos, } = checkpoint; - let new_pos = u32::from(position) as usize; - - self.position = new_pos; + self.cursor.set_position(usize::from(position)); self.current_kind = current_kind; self.current_start = current_start; self.current_flags = current_flags; @@ -246,6 +254,7 @@ impl<'src> Lexer<'src> for CssLexer<'src> { self.unicode_bom_length = unicode_bom_length; self.diagnostics.truncate(diagnostics_pos as usize); self.pending_scss_string_start = None; + self.pending_url_raw_value_scan = None; } fn finish(self) -> Vec { @@ -258,14 +267,41 @@ impl<'src> Lexer<'src> for CssLexer<'src> { #[inline] fn advance_char_unchecked(&mut self) { - let c = self.current_char_unchecked(); - self.position += c.len_utf8(); + let Some(current) = self.current_byte() else { + return; + }; + self.cursor.advance_byte_or_char(current); } /// Advances the current position by `n` bytes. #[inline] fn advance(&mut self, n: usize) { - self.position += n; + self.cursor.advance(n); + } + + #[inline] + fn assert_at_char_boundary(&self, offset: usize) { + debug_assert!(self.source().is_char_boundary(self.position() + offset)); + } + + #[inline] + fn assert_current_char_boundary(&self) { + debug_assert!(self.source().is_char_boundary(self.position())); + } + + #[inline] + fn current_byte(&self) -> Option { + self.cursor.current_byte() + } + + #[inline] + fn peek_byte(&self) -> Option { + self.cursor.peek_byte() + } + + #[inline] + fn byte_at(&self, offset: usize) -> Option { + self.cursor.byte_at(offset) } } @@ -273,18 +309,18 @@ impl<'src> CssLexer<'src> { /// Make a new lexer from a str, this is safe because strs are valid utf8 pub fn from_str(source: &'src str) -> Self { Self { - source, + cursor: SourceCursor::new(source, 0), after_newline: false, after_whitespace: false, unicode_bom_length: 0, current_kind: TOMBSTONE, current_start: TextSize::from(0), current_flags: TokenFlags::empty(), - position: 0, diagnostics: vec![], options: CssParserOptions::default(), source_type: CssFileSource::default(), pending_scss_string_start: None, + pending_url_raw_value_scan: None, } } @@ -333,6 +369,32 @@ impl<'src> CssLexer<'src> { self.options.allow_wrong_line_comments || self.is_scss() } + fn scan_cursor(&self) -> CssScanCursor<'src> { + self.scan_cursor_at(self.position()) + } + + fn scan_cursor_at(&self, position: usize) -> CssScanCursor<'src> { + // The lexer owns the stable scan environment. Shared scanners vary + // only by the byte position they inspect. + CssScanCursor::new( + SourceCursor::new(self.source(), position), + self.is_scss(), + self.is_line_comment_enabled(), + ) + } + + fn byte_before(&self, position: usize, offset: usize) -> Option { + position + .checked_sub(offset) + .and_then(|index| self.source().as_bytes().get(index)) + .copied() + } + + #[inline] + fn set_position(&mut self, position: usize) { + self.cursor.set_position(position); + } + /// Get the UTF8 char which starts at the current byte /// /// ## Safety @@ -341,47 +403,14 @@ impl<'src> CssLexer<'src> { self.char_unchecked_at(0) } - /// Peek the UTF8 char which starts at the current byte - /// - /// ## Safety - /// Must be called at a valid UT8 char boundary - fn peek_char_unchecked(&self) -> char { - self.char_unchecked_at(1) - } - /// Get the UTF8 char which starts at the current byte /// /// ## Safety /// Must be called at a valid UT8 char boundary fn char_unchecked_at(&self, offset: usize) -> char { - // Precautionary measure for making sure the unsafe code below does not read over memory boundary debug_assert!(!self.is_eof()); self.assert_at_char_boundary(offset); - - // Safety: We know this is safe because we require the input to the lexer to be valid utf8 and we always call this when we are at a char - let string = unsafe { - std::str::from_utf8_unchecked( - self.source - .as_bytes() - .get_unchecked((self.position + offset)..), - ) - }; - if let Some(chr) = string.chars().next() { - chr - } else { - // Safety: we always call this when we are at a valid char, so this branch is completely unreachable - unsafe { - core::hint::unreachable_unchecked(); - } - } - } - - /// Check if the lexer is at a valid escape. U+005C REVERSE SOLIDUS (\) - fn is_valid_escape_at(&self, offset: usize) -> bool { - match self.byte_at(offset) { - Some(b'\n' | b'\r') | None => false, - Some(_) => true, - } + self.cursor.char_at(offset) } /// Lexes the next token @@ -426,7 +455,7 @@ impl<'src> CssLexer<'src> { // Check for BOM first at position 0, before checking if UNI is an identifier start. // The BOM character (U+FEFF) is in the valid CSS non-ASCII identifier range, // so without this check it would be incorrectly consumed as part of an identifier. - UNI if self.position == 0 => { + UNI if self.position() == 0 => { if let Some((bom, bom_size)) = self.consume_potential_bom(UNICODE_BOM) { self.unicode_bom_length = bom_size; return bom; @@ -563,39 +592,61 @@ impl<'src> CssLexer<'src> { } fn consume_url_raw_value_token(&mut self, current: u8) -> CssSyntaxKind { - if let Some(chr) = self.current_byte() { - let dispatch = lookup_byte(chr); - return match dispatch { - // TLD byte covers `url(~package/tilde.css)`; - // HAS byte covers `url(#IDofSVGpath);` - IDT | DOL | UNI | PRD | SLH | ZER | DIG | TLD | HAS => self.consume_url_raw_value(), - _ => self.consume_token(current), - }; - } - self.consume_token(current) + self.scan_cursor() + .scan_url_raw_value() + .and_then(|scan| self.consume_pending_url_raw_value(scan)) + .unwrap_or_else(|| self.consume_token(current)) } - fn consume_url_raw_value(&mut self) -> CssSyntaxKind { - let start = self.text_position(); - while let Some(chr) = self.current_byte() { - let dispatch = lookup_byte(chr); - match dispatch { - PNC => { - return CSS_URL_VALUE_RAW_LITERAL; - } - BSL if self.is_valid_escape_at(1) => { - // We can escape any character, so we just skip over the escape sequence - // Even a closing parenthesis (PNC token): - // url(https://example.com/ima\)ge.png); - // ^^ escaped closing paren - self.advance(2) + fn consume_url_body_token( + &mut self, + current: u8, + scss_exclusive_syntax_allowed: bool, + ) -> CssSyntaxKind { + if let Some(scan) = self.take_pending_url_raw_value_scan_at_current_position() { + return self + .consume_pending_url_raw_value(scan) + .unwrap_or_else(|| self.consume_token(current)); + } + + match self.scan_url_body_start(self.position(), scss_exclusive_syntax_allowed) { + UrlBodyStartScan::InterpolatedFunction | UrlBodyStartScan::Other => { + self.consume_token(current) + } + UrlBodyStartScan::RawValue(scan) => { + if scan.start == self.position() { + self.consume_pending_url_raw_value(scan) + .unwrap_or_else(|| self.consume_token(current)) + } else { + // Leading trivia is still emitted as normal trivia tokens. + // Cache the raw-value scan so the next non-trivia token can + // commit the already-classified URL body in one step. + self.pending_url_raw_value_scan = Some(scan); + self.consume_token(current) } - _ => self.advance(1), } } - let diagnostic = ParseDiagnostic::new("Invalid url raw value", start..self.text_position()); - self.diagnostics.push(diagnostic); - CSS_URL_VALUE_RAW_LITERAL + } + + fn consume_pending_url_raw_value( + &mut self, + scan: PendingUrlRawValueScan, + ) -> Option { + if scan.start != self.position() { + return None; + } + + self.set_position(scan.end); + + if !scan.terminated { + let diagnostic = ParseDiagnostic::new( + "Invalid url raw value", + TextSize::from(scan.start as u32)..self.text_position(), + ); + self.diagnostics.push(diagnostic); + } + + Some(CSS_URL_VALUE_RAW_LITERAL) } fn consume_pseudo_nth_selector_token(&mut self, current: u8) -> CssSyntaxKind { @@ -634,7 +685,7 @@ impl<'src> CssLexer<'src> { if current == quote.as_byte() { return match self.scan_same_quote_in_interpolation(quote) { SameQuoteInInterpolation::NestedString(scan) => { - match self.finish_scss_string_start(self.position, quote, scan) { + match self.finish_scss_string_start(self.position(), quote, scan) { StringLexResult::Token(kind) => kind, StringLexResult::Unterminated => { debug_assert!( @@ -659,7 +710,9 @@ impl<'src> CssLexer<'src> { /// before `}`. This helper performs a non-mutating scan so the valid nested /// string path can reuse the scan result for real token commitment. fn scan_same_quote_in_interpolation(&self, quote: CssStringQuote) -> SameQuoteInInterpolation { - let scan = self.scan_string_body(1, quote, true); + let scan = self + .scan_cursor_at(self.position() + 1) + .scan_interpolated_string_body(quote); match scan.stop { StringBodyScanStop::ClosingQuote { .. } | StringBodyScanStop::Interpolation { .. } => { @@ -672,16 +725,17 @@ impl<'src> CssLexer<'src> { } fn lex_string_token(&mut self, mode: StringLexMode) -> StringLexResult { + let start = self.position(); + match mode { StringLexMode::Plain { quote } => { - let start = self.position; - let scan = self.scan_string_body(1, quote, false); + let scan = self.scan_cursor_at(start + 1).scan_plain_string_body(quote); self.commit_plain_string_literal(start, scan) } StringLexMode::ScssStart { quote } => { - let start = self.position; - // Skip the opening quote we are already on. - let scan = self.scan_string_body(1, quote, true); + let scan = self + .scan_cursor_at(start + 1) + .scan_interpolated_string_body(quote); self.finish_scss_string_start(start, quote, scan) } } @@ -712,7 +766,7 @@ impl<'src> CssLexer<'src> { return self.consume_byte(SCSS_STRING_QUOTE); } - if self.is_at_scss_interpolation_start() { + if self.is_at_scss_interpolation() { self.pending_scss_string_start = None; return self.consume_byte(T![#]); } @@ -720,17 +774,17 @@ impl<'src> CssLexer<'src> { self.commit_scss_string_content(quote) } - fn is_at_scss_interpolation_start(&self) -> bool { - self.current_byte() == Some(b'#') && self.peek_byte() == Some(b'{') + fn is_at_scss_interpolation(&self) -> bool { + self.scan_cursor().is_at_scss_interpolation() } fn commit_scss_string_content(&mut self, quote: CssStringQuote) -> CssSyntaxKind { self.assert_current_char_boundary(); - let start = self.position; + let start = self.position(); let scan = self .take_pending_scss_string_start_scan(quote) - .unwrap_or_else(|| self.scan_string_body(0, quote, true)); + .unwrap_or_else(|| self.scan_cursor().scan_interpolated_string_body(quote)); match scan.stop { StringBodyScanStop::Interpolation { position } @@ -740,15 +794,15 @@ impl<'src> CssLexer<'src> { "SCSS string content must never be zero-length" ); - self.position = position; - self.push_string_issues(&scan.issues); + self.set_position(position); + self.push_string_issues(&scan.invalid_escape_ranges); // Invalid escapes still belong to this string chunk; keep the // semantic token kind and surface the problem via diagnostics. SCSS_STRING_CONTENT_LITERAL } StringBodyScanStop::Newline { position, .. } => { - self.position = position; - self.push_string_issues(&scan.issues); + self.set_position(position); + self.push_string_issues(&scan.invalid_escape_ranges); let unterminated = ParseDiagnostic::new( "Missing closing quote", TextSize::from(start as u32)..self.text_position(), @@ -758,14 +812,14 @@ impl<'src> CssLexer<'src> { ERROR_TOKEN } StringBodyScanStop::Eof { position } => { - self.position = position; - self.push_string_issues(&scan.issues); + self.set_position(position); + self.push_string_issues(&scan.invalid_escape_ranges); let unterminated = ParseDiagnostic::new( "Missing closing quote", TextSize::from(start as u32)..self.text_position(), ) .with_detail( - self.source.text_len()..self.source.text_len(), + self.source().text_len()..self.source().text_len(), "file ends here", ); self.diagnostics.push(unterminated); @@ -781,17 +835,17 @@ impl<'src> CssLexer<'src> { ) -> StringLexResult { match scan.stop { StringBodyScanStop::ClosingQuote { position } => { - self.position = position + 1; - self.push_string_issues(&scan.issues); - StringLexResult::Token(if !scan.issues.is_empty() { + self.set_position(position + 1); + self.push_string_issues(&scan.invalid_escape_ranges); + StringLexResult::Token(if !scan.invalid_escape_ranges.is_empty() { ERROR_TOKEN } else { CSS_STRING_LITERAL }) } StringBodyScanStop::Newline { position, .. } => { - self.position = position; - self.push_string_issues(&scan.issues); + self.set_position(position); + self.push_string_issues(&scan.invalid_escape_ranges); let unterminated = ParseDiagnostic::new( "Missing closing quote", TextSize::from(start as u32)..self.text_position(), @@ -801,14 +855,14 @@ impl<'src> CssLexer<'src> { StringLexResult::Unterminated } StringBodyScanStop::Eof { position } => { - self.position = position; - self.push_string_issues(&scan.issues); + self.set_position(position); + self.push_string_issues(&scan.invalid_escape_ranges); let unterminated = ParseDiagnostic::new( "Missing closing quote", TextSize::from(start as u32)..self.text_position(), ) .with_detail( - self.source.text_len()..self.source.text_len(), + self.source().text_len()..self.source().text_len(), "file ends here", ); self.diagnostics.push(unterminated); @@ -824,80 +878,10 @@ impl<'src> CssLexer<'src> { } } - fn push_string_issues(&mut self, issues: &[StringIssue]) { - for issue in issues { - match issue { - StringIssue::InvalidEscape(range) => { - self.diagnostics - .push(ParseDiagnostic::new("Invalid escape sequence", *range)); - } - } - } - } - - fn scan_string_body( - &self, - mut offset: usize, - quote: CssStringQuote, - allow_interpolation: bool, - ) -> StringBodyScan { - let mut issues = SmallVec::new(); - let quote_byte = quote.as_byte(); - - while let Some(byte) = self.byte_at(offset) { - let position = self.position + offset; - - if byte == quote_byte { - return StringBodyScan { - stop: StringBodyScanStop::ClosingQuote { position }, - issues, - }; - } - - if allow_interpolation && byte == b'#' && self.byte_at(offset + 1) == Some(b'{') { - return StringBodyScan { - stop: StringBodyScanStop::Interpolation { position }, - issues, - }; - } - - match lookup_byte(byte) { - BSL => { - let (next_offset, invalid_hex_escape) = self.scan_string_escape(offset, quote); - if invalid_hex_escape { - issues.push(StringIssue::InvalidEscape(TextRange::new( - TextSize::from(position as u32), - TextSize::from((self.position + next_offset) as u32), - ))); - } - - offset = next_offset; - } - WHS if matches!(byte, b'\n' | b'\r') => { - let len = if byte == b'\r' && self.byte_at(offset + 1) == Some(b'\n') { - 2 - } else { - 1 - }; - - return StringBodyScan { - stop: StringBodyScanStop::Newline { position, len }, - issues, - }; - } - // SAFETY: `offset` starts at a known UTF-8 boundary and only - // advances over ASCII bytes, escape scanners, or full Unicode - // scalar lengths, so it remains on a char boundary here. - UNI => offset += self.char_unchecked_at(offset).len_utf8(), - _ => offset += 1, - } - } - - StringBodyScan { - stop: StringBodyScanStop::Eof { - position: self.source.len(), - }, - issues, + fn push_string_issues(&mut self, invalid_escape_ranges: &[TextRange]) { + for range in invalid_escape_ranges { + self.diagnostics + .push(ParseDiagnostic::new("Invalid escape sequence", *range)); } } @@ -907,76 +891,10 @@ impl<'src> CssLexer<'src> { ) -> Option { self.pending_scss_string_start .take() - .filter(|pending| pending.quote == quote && pending.content_start == self.position) + .filter(|pending| pending.quote == quote && pending.content_start == self.position()) .map(|pending| pending.initial_chunk_scan) } - fn consume_escape_sequence(&mut self) -> char { - let (next_offset, escaped) = self.scan_hex_escape(0); - self.position += next_offset; - escaped - } - - fn scan_string_escape(&self, offset: usize, quote: CssStringQuote) -> (usize, bool) { - let quote = quote.as_byte(); - let next = offset + 1; - match self.byte_at(next) { - Some(b'\n') => (next + 1, false), - Some(b'\r') => { - let len = if self.byte_at(next + 1) == Some(b'\n') { - 2 - } else { - 1 - }; - (next + len, false) - } - Some(byte) if byte == quote => (next + 1, false), - Some(byte) if byte.is_ascii_hexdigit() => { - let (next_offset, escaped) = self.scan_hex_escape(next); - (next_offset, escaped == REPLACEMENT_CHARACTER) - } - Some(byte) if byte.is_ascii() => (next + 1, false), - // SAFETY: `next` is immediately after `\`, which is ASCII, so it - // is still a valid UTF-8 boundary for the escaped code point. - Some(_) => (next + self.char_unchecked_at(next).len_utf8(), false), - None => (next, false), - } - } - - fn scan_hex_escape(&self, offset: usize) -> (usize, char) { - debug_assert!( - self.byte_at(offset) - .is_some_and(|byte| byte.is_ascii_hexdigit()), - "hex escape scanning must start on a hex digit" - ); - - let max_end = offset + 6; - let mut cursor = offset; - let mut hex = 0u32; - - while cursor < max_end { - let Some(byte) = self.byte_at(cursor) else { - break; - }; - - let Some(digit) = (byte as char).to_digit(16) else { - break; - }; - - hex = hex * 16 + digit; - cursor += 1; - } - - if self - .byte_at(cursor) - .is_some_and(|byte| matches!(byte, b'\t' | b' ' | b'\n' | b'\r' | 0x0C)) - { - cursor += 1; - } - - (cursor, decode_escaped_code_point(hex)) - } - /// Lexes a CSS number literal fn consume_number(&mut self, current: u8) -> CssSyntaxKind { debug_assert!(self.is_number_start()); @@ -1333,19 +1251,15 @@ impl<'src> CssLexer<'src> { } } - /// Consumes a sequence of identifier characters from a byte stream, appending - /// them to the provided buffer in lowercase ASCII form. - /// - /// This function iteratively processes bytes from the stream, which are part - /// of an identifier, and appends their lowercase ASCII representation to the buffer. - /// It stops processing either when the buffer is full or when a non-identifier - /// character is encountered. + /// Consumes one identifier token via a single shared scan cursor and + /// returns the keyword-buffer bookkeeping needed by the lexer. /// /// # Arguments /// - /// * `buf` - A mutable reference to a byte array where the identifier characters - /// will be appended. This buffer should be pre-allocated and have enough - /// space to hold the expected identifier. + /// * `buf` - Lowercased ASCII keyword buffer filled by the shared scanner + /// while the identifier remains ASCII-only. + /// * `allow_slash` - Whether `/` should be accepted as an identifier + /// fragment for Tailwind utility lexing. /// /// # Returns /// @@ -1361,114 +1275,34 @@ impl<'src> CssLexer<'src> { fn consume_ident_sequence(&mut self, buf: &mut [u8], allow_slash: bool) -> (usize, bool) { debug_assert!(self.is_ident_start()); - let mut idx = 0; - let mut only_ascii_used = true; - // Repeatedly consume the next input code point from the stream. - while let Some(current) = self.current_byte() { - if let Some(part) = self.consume_ident_part(current, allow_slash) { - if only_ascii_used && !part.is_ascii() { - only_ascii_used = false; - } - - if only_ascii_used { - // Ensure that there is space in the buffer. - // Since we're only dealing with ASCII, we need at most 1 byte. - if let Some(buf) = buf.get_mut(idx..idx + 1) { - // Convert the ASCII character to lowercase. - buf[0] = part.to_ascii_lowercase() as u8; - idx += 1; - } + let mut scan_cursor = self.scan_cursor(); + let scan = if allow_slash { + scan_cursor.consume_ident_sequence_with_slash(buf) + } else { + scan_cursor.consume_ident_sequence(buf) + }; + let mut position = scan.position; + let mut count = scan.count; + if self.options.is_tailwind_directives_enabled() && scan.stop_byte == Some(b'*') { + // Tailwind keeps `--*` as a unit, but stops before the trailing + // `-` in patterns like `--color-*`. Check the raw source bytes at + // the scan boundary so escaped hyphens do not trigger this fixup. + let prev_is_hyphen = self.byte_before(scan.position, 1) == Some(b'-'); + let prev_prev_is_hyphen = self.byte_before(scan.position, 2) == Some(b'-'); + + if prev_is_hyphen && !prev_prev_is_hyphen { + position = position.saturating_sub(1); + + // Only trim the keyword-buffer count if that trailing `-` + // actually fit into the ASCII buffer. + if scan.only_ascii_used && scan.last_was_buffered { + count = count.saturating_sub(1); } - } else { - break; } } - (idx, only_ascii_used) - } - - /// Consume a character that forms part of a CSS identifier. - /// - /// Before calling this function, you should make sure that there is a valid identifier start - /// using [Self::is_ident_start]. - /// - /// Also handles CSS escape sequences in identifiers and attach appropriate diagnostics for invalid cases. - /// - /// Returns the consumed character wrapped in `Some` if it is part of an identifier, - /// and `None` if it is not. - fn consume_ident_part(&mut self, current: u8, allow_slash: bool) -> Option { - let dispatched = lookup_byte(current); - - if allow_slash && dispatched == SLH { - self.advance(1); - return Some(current as char); - } - if self.options.is_tailwind_directives_enabled() - && dispatched == MIN - && self.peek_byte().map(lookup_byte) == Some(MUL) - { - // HACK: handle `--*` - if self.prev_byte().map(lookup_byte) == Some(MIN) { - self.advance(1); - return Some(current as char); - } - // otherwise, handle cases like `--color-*` - return None; - } - - let chr = match dispatched { - IDT | MIN | DIG | ZER => { - self.advance(1); - // SAFETY: We know that the current byte is a hyphen or a number. - current as char - } - // name code point - UNI => { - // SAFETY: We know that the current byte is a valid unicode code point - let chr = self.current_char_unchecked(); - if is_css_non_ascii(chr) { - self.advance(chr.len_utf8()); - chr - } else { - return None; - } - } - // U+005C REVERSE SOLIDUS (\) - // If the first and second code points are a valid escape, continue consume. - // Otherwise, break. - // BSL if self.is_valid_escape_at(1) => '\\', - BSL if self.is_valid_escape_at(1) => { - let escape_start = self.text_position(); - self.advance(1); - - match self.current_byte() { - // Any valid escape sequence can be used as an identifier, - // even if it becomes the REPLACEMENT CHARACTER (like `\0`). - // This is important to handle for cases like the "media - // min-width hack": http://browserbu.gs/css-hacks/media-min-width-0-backslash-0/. - Some(c) if c.is_ascii_hexdigit() => self.consume_escape_sequence(), - - Some(_) => { - let chr = self.current_char_unchecked(); - self.advance(chr.len_utf8()); - chr - } - - None => { - let diagnostic = ParseDiagnostic::new( - "Invalid escape sequence", - escape_start..self.text_position(), - ); - self.diagnostics.push(diagnostic); - - return None; - } - } - } - _ => return None, - }; - - Some(chr) + self.set_position(position); + (count, scan.only_ascii_used) } /// Lexes a comment. @@ -1519,7 +1353,7 @@ impl<'src> CssLexer<'src> { let err = ParseDiagnostic::new("Unterminated block comment", start..self.text_position()) .with_detail( - self.position..self.position + 1, + self.position()..self.position() + 1, "... but the file ends here", ); @@ -1743,54 +1577,10 @@ impl<'src> CssLexer<'src> { /// Check if the lexer starts an identifier. fn is_ident_start(&self) -> bool { - // See https://drafts.csswg.org/css-syntax-3/#typedef-ident-token - let Some(current) = self.current_byte() else { - return false; - }; - // Look at the first code point: - match lookup_byte(current) { - // U+002D HYPHEN-MINUS - MIN => { - let Some(next) = self.peek_byte() else { - return false; - }; - match lookup_byte(next) { - MIN => { - let Some(next) = self.byte_at(2) else { - return false; - }; - match lookup_byte(next) { - IDT | MIN | DIG | ZER => true, - // If the third code point is a name-start code point - // return true. - // SAFETY: offset 2 is reached only after two ASCII - // `-` bytes, so it is a valid UTF-8 boundary. - UNI => is_css_non_ascii(self.char_unchecked_at(2)), - // or the third and fourth code points are a valid escape - // return true. - BSL => self.is_valid_escape_at(3), - MUL => true, - _ => false, - } - } - IDT => true, - // If the second code point is a name-start code point - // return true. - UNI => is_css_non_ascii(self.peek_char_unchecked()), - // or the second and third code points are a valid escape - // return true. - BSL => self.is_valid_escape_at(2), - _ => false, - } - } - IDT => true, - UNI => is_css_non_ascii(self.current_char_unchecked()), - // U+005C REVERSE SOLIDUS (\) - // If the first and second code points are a valid escape, return true. Otherwise, - // return false. - BSL => self.is_valid_escape_at(1), - _ => false, - } + // ASCII identifier bytes are already proven by dispatch, so avoid + // building a shared scan cursor for that trivial hot path. + matches!(self.current_byte().map(lookup_byte), Some(IDT)) + || self.scan_cursor().is_ident_start() } fn consume_token_tailwind_utility(&mut self, current: u8) -> CssSyntaxKind { @@ -1838,8 +1628,8 @@ impl<'src> CssLexer<'src> { impl<'src> ReLexer<'src> for CssLexer<'src> { fn re_lex(&mut self, context: Self::ReLexContext) -> Self::Kind { - let old_position = self.position; - self.position = u32::from(self.current_start) as usize; + let old_position = self.position(); + self.set_position(usize::from(self.current_start)); let re_lexed_kind = match self.current_byte() { Some(current) => match context { @@ -1852,7 +1642,7 @@ impl<'src> ReLexer<'src> for CssLexer<'src> { if self.current() == re_lexed_kind { // Didn't re-lex anything. Return existing token again - self.position = old_position; + self.set_position(old_position); } else { self.current_kind = re_lexed_kind; } @@ -1861,11 +1651,38 @@ impl<'src> ReLexer<'src> for CssLexer<'src> { } } -impl CssLexer<'_> { +impl<'src> CssLexer<'src> { pub(crate) fn has_pending_scss_string_start(&self) -> bool { self.pending_scss_string_start.is_some() } + /// Returns true when `start` begins an interpolation-containing identifier + /// immediately followed by `(`, without consuming lexer state. + pub(crate) fn is_at_scss_interpolated_function(&self, start: usize) -> bool { + self.scan_cursor_at(start) + .is_at_scss_interpolated_function() + } + + fn take_pending_url_raw_value_scan_at_current_position( + &mut self, + ) -> Option { + let scan = self.pending_url_raw_value_scan?; + + if scan.start == self.position() { + self.pending_url_raw_value_scan = None; + Some(scan) + } else { + if self.position() > scan.start { + // A fallback tokenization path already moved past the cached + // raw-literal start, so the speculative range is no longer + // aligned with the real lexer position. + self.pending_url_raw_value_scan = None; + } + + None + } + } + fn consume_scss_expression_token(&mut self, current: u8) -> CssSyntaxKind { match current { b'+' => self.consume_byte(T![+]), @@ -1873,12 +1690,21 @@ impl CssLexer<'_> { _ => self.consume_token(current), } } + + fn scan_url_body_start( + &self, + start: usize, + scss_exclusive_syntax_allowed: bool, + ) -> UrlBodyStartScan { + self.scan_cursor_at(start) + .scan_url_body_start(scss_exclusive_syntax_allowed) + } } impl<'src> LexerWithCheckpoint<'src> for CssLexer<'src> { fn checkpoint(&self) -> LexerCheckpoint { LexerCheckpoint { - position: TextSize::from(self.position as u32), + position: TextSize::from(self.cursor.position() as u32), current_start: self.current_start, current_flags: self.current_flags, current_kind: self.current_kind, @@ -1946,48 +1772,3 @@ enum SameQuoteInInterpolation { /// The quote should be treated as the outer string closing early. OuterQuote, } - -/// Result of scanning a string body from the current lexer position. -#[derive(Debug, Clone, Eq, PartialEq)] -struct StringBodyScan { - /// Where scanning stopped. - stop: StringBodyScanStop, - /// Ranges of invalid escape sequences encountered before the stop - /// boundary. The caller decides when to emit diagnostics for them. - issues: SmallVec<[StringIssue; 1]>, -} - -/// Next boundary encountered while scanning a string body. -#[derive(Debug, Copy, Clone, Eq, PartialEq)] -enum StringBodyScanStop { - /// An unescaped `#{` starts an interpolation part. - Interpolation { position: usize }, - /// The matching quote closes the outer string. - ClosingQuote { position: usize }, - /// A newline terminates the string as malformed input. - Newline { position: usize, len: usize }, - /// The file ended before a closing quote was found. - Eof { position: usize }, -} - -/// Non-fatal issues discovered while scanning a quoted string body. -/// -/// These are returned as data by the shared string-body scanner. The caller -/// decides when to emit diagnostics after committing the scan result. -#[derive(Debug, Copy, Clone, Eq, PartialEq)] -enum StringIssue { - /// Invalid escape sequence such as `\0`. - InvalidEscape(TextRange), -} - -/// Decodes a CSS hex escape sequence into a Unicode scalar value or the -/// replacement character when the escape is invalid. -#[inline] -fn decode_escaped_code_point(hex: u32) -> char { - match hex { - 0 => REPLACEMENT_CHARACTER, - 55_296..=57_343 => REPLACEMENT_CHARACTER, - 1_114_112.. => REPLACEMENT_CHARACTER, - _ => char::from_u32(hex).unwrap_or(REPLACEMENT_CHARACTER), - } -} diff --git a/crates/biome_css_parser/src/lexer/scan_cursor.rs b/crates/biome_css_parser/src/lexer/scan_cursor.rs new file mode 100644 index 000000000000..f81822cfeaae --- /dev/null +++ b/crates/biome_css_parser/src/lexer/scan_cursor.rs @@ -0,0 +1,864 @@ +use super::{ + CssStringQuote, + source_cursor::{SourceCursor, is_newline_byte}, +}; +use biome_css_syntax::{TextRange, TextSize}; +use biome_unicode_table::{Dispatch::*, is_css_non_ascii, lookup_byte}; +use std::char::REPLACEMENT_CHARACTER; + +/// Classification result for the first non-trivia content inside `url(...)`. +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub(crate) enum UrlBodyStartScan { + /// Keep lexing in regular mode so the parser can build an SCSS function. + InterpolatedFunction, + /// Commit a raw URL literal starting at the recorded non-trivia position. + RawValue(PendingUrlRawValueScan), + /// Fall back to regular tokenization. + Other, +} + +/// Byte range for a raw URL literal discovered by speculative scanning. +/// +/// The lexer can cache this after consuming leading trivia so it does not need +/// to rescan when it later reaches the actual raw-value start. +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub(crate) struct PendingUrlRawValueScan { + /// Absolute byte position where the non-trivia raw value starts. + pub(crate) start: usize, + /// Absolute byte position immediately before the closing `)` or EOF. + pub(crate) end: usize, + /// Whether the scan stopped on a closing `)` instead of EOF. + pub(crate) terminated: bool, +} + +/// Result of scanning a string body from the current lexer position. +#[derive(Debug, Clone, Eq, PartialEq)] +pub(crate) struct StringBodyScan { + /// Where scanning stopped. + pub(crate) stop: StringBodyScanStop, + /// Ranges of invalid escape sequences encountered before the stop + /// boundary. The caller decides when to emit diagnostics for them. + pub(crate) invalid_escape_ranges: Vec, +} + +/// Next boundary encountered while scanning a string body. +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub(crate) enum StringBodyScanStop { + /// An unescaped `#{` starts an interpolation part. + Interpolation { position: usize }, + /// The matching quote closes the outer string. + ClosingQuote { position: usize }, + /// A newline terminates the string as malformed input. + Newline { position: usize, len: usize }, + /// The file ended before a closing quote was found. + Eof { position: usize }, +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +enum InterpolatedIdentifierFragment { + Identifier, + Interpolation, +} + +/// Shared CSS-aware scanner with low-level source navigation plus CSS-specific +/// scan primitives. +/// +/// This layer exposes reusable scan primitives for the real lexer and for +/// non-committing lookahead. It intentionally does not own diagnostics, token +/// kinds, or parser state. +#[derive(Debug, Copy, Clone)] +pub(crate) struct CssScanCursor<'src> { + cursor: SourceCursor<'src>, + is_scss: bool, + line_comments_enabled: bool, +} + +/// Result metadata from scanning one identifier sequence with the shared +/// scanner. +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub(crate) struct IdentifierSequenceScan { + /// Number of lowercase ASCII bytes written into the provided buffer before + /// scanning encountered non-ASCII content or the buffer filled up. + pub(crate) count: usize, + /// Whether every consumed identifier fragment decoded to ASCII. + pub(crate) only_ascii_used: bool, + /// Whether the most recently consumed fragment was written into the + /// keyword buffer. + pub(crate) last_was_buffered: bool, + /// Absolute byte position immediately after the last consumed fragment. + pub(crate) position: usize, + /// First byte that stopped the identifier scan, if any. + pub(crate) stop_byte: Option, +} + +impl<'src> CssScanCursor<'src> { + /// Creates a shared scan cursor at `position` with a stable lexical + /// environment. + pub(crate) const fn new( + cursor: SourceCursor<'src>, + is_scss: bool, + line_comments_enabled: bool, + ) -> Self { + Self { + cursor, + is_scss, + line_comments_enabled, + } + } + + /// Returns the current absolute byte position. + pub(crate) fn position(self) -> usize { + self.cursor.position() + } + + /// Returns the byte at the current scan position, if any. + fn current_byte(&self) -> Option { + self.cursor.current_byte() + } + + /// Returns the byte immediately after the current scan position, if any. + fn peek_byte(&self) -> Option { + self.cursor.peek_byte() + } + + /// Returns the byte at `position + offset`, if any. + fn byte_at(&self, offset: usize) -> Option { + self.cursor.byte_at(offset) + } + + /// Advances the shared scan cursor by a known byte count. + fn advance(&mut self, amount: usize) { + self.cursor.advance(amount); + } + + /// Advances over one ASCII byte or one full UTF-8 code point. + fn advance_byte_or_char(&mut self, current: u8) { + self.cursor.advance_byte_or_char(current); + } + + /// Returns the character that starts at the current byte position. + /// + /// ## Safety + /// Must be called at a valid non-EOF UTF-8 char boundary. + fn current_char_at_boundary(&self) -> char { + self.cursor.current_char() + } + + /// Returns the character that starts at `position + offset`. + /// + /// ## Safety + /// The target byte position must be a valid non-EOF UTF-8 char boundary. + pub(crate) fn char_at_boundary(&self, offset: usize) -> char { + self.cursor.char_at(offset) + } + + /// Returns true when a backslash escape starting at `offset` is valid. + fn is_valid_escape_at(&self, offset: usize) -> bool { + self.cursor.is_valid_escape_at(offset) + } + + /// Returns the total byte length of the backing source. + fn source_len(&self) -> usize { + self.cursor.source().len() + } + + /// Consumes a CSS hex escape sequence that starts at the current position + /// and returns the decoded code point. + pub(crate) fn consume_escape_sequence(&mut self) -> char { + debug_assert!( + self.current_byte() + .is_some_and(|byte| byte.is_ascii_hexdigit()), + "hex escape scanning must start on a hex digit" + ); + + let mut offset = 0usize; + let mut hex = 0u32; + + while offset < 6 { + let Some(byte) = self.byte_at(offset) else { + break; + }; + + let Some(digit) = (byte as char).to_digit(16) else { + break; + }; + + hex = hex * 16 + digit; + offset += 1; + } + + if self + .byte_at(offset) + .is_some_and(|byte| matches!(byte, b'\t' | b' ' | b'\n' | b'\r' | 0x0C)) + { + offset += 1; + } + + self.advance(offset); + decode_escaped_code_point(hex) + } + + /// Returns true when `position + offset` can start a CSS identifier. + pub(crate) fn is_ident_start_at(self, offset: usize) -> bool { + let Some(current) = self.byte_at(offset) else { + return false; + }; + + match lookup_byte(current) { + MIN => { + let Some(next) = self.byte_at(offset + 1) else { + return false; + }; + + match lookup_byte(next) { + MIN => { + let Some(next) = self.byte_at(offset + 2) else { + return false; + }; + + match lookup_byte(next) { + IDT | MIN | DIG | ZER => true, + UNI => is_css_non_ascii(self.char_at_boundary(offset + 2)), + BSL => self.is_valid_escape_at(offset + 3), + MUL => true, + _ => false, + } + } + IDT => true, + UNI => is_css_non_ascii(self.char_at_boundary(offset + 1)), + BSL => self.is_valid_escape_at(offset + 2), + _ => false, + } + } + IDT => true, + UNI => is_css_non_ascii(self.char_at_boundary(offset)), + BSL => self.is_valid_escape_at(offset + 1), + _ => false, + } + } + + /// Returns true when the current position can start a CSS identifier. + pub(crate) fn is_ident_start(self) -> bool { + self.is_ident_start_at(0) + } + + fn consume_ident_part(&mut self, allow_slash: bool) -> Option { + if allow_slash && self.current_byte() == Some(b'/') { + self.advance(1); + return Some('/'); + } + + let current = self.current_byte()?; + + match lookup_byte(current) { + IDT | MIN | DIG | ZER => { + self.advance(1); + Some(current as char) + } + UNI => { + let chr = self.current_char_at_boundary(); + if is_css_non_ascii(chr) { + self.advance(chr.len_utf8()); + Some(chr) + } else { + None + } + } + BSL if self.is_valid_escape_at(1) => { + self.advance(1); + + match self.current_byte() { + Some(byte) if byte.is_ascii_hexdigit() => Some(self.consume_escape_sequence()), + Some(_) => { + let chr = self.current_char_at_boundary(); + self.advance(chr.len_utf8()); + Some(chr) + } + None => None, + } + } + _ => None, + } + } + + /// Consumes standard CSS identifier fragments until the current position + /// no longer matches the identifier grammar. + /// + /// This is the shared hot-path entrypoint for lexer identifier scanning: + /// it advances one scan cursor across the whole token while optionally + /// lowercasing ASCII content into `buf` for keyword matching. + pub(crate) fn consume_ident_sequence(&mut self, buf: &mut [u8]) -> IdentifierSequenceScan { + self.scan_ident_sequence(Some(buf), false) + } + + /// Consumes identifier fragments, also accepting `/` for slash-enabled + /// utility names such as Tailwind `w/2`. + pub(crate) fn consume_ident_sequence_with_slash( + &mut self, + buf: &mut [u8], + ) -> IdentifierSequenceScan { + self.scan_ident_sequence(Some(buf), true) + } + + /// Advances through one standard CSS identifier sequence without + /// collecting lexer keyword-buffer bookkeeping. + pub(crate) fn advance_ident_sequence(&mut self) { + let _ = self.scan_ident_sequence(None, false); + } + + fn scan_ident_sequence( + &mut self, + mut buf: Option<&mut [u8]>, + allow_slash: bool, + ) -> IdentifierSequenceScan { + let mut idx = 0; + let mut only_ascii_used = true; + let mut last_was_buffered = false; + + while self.current_byte().is_some() { + let start = self.position(); + + let Some(part) = self.consume_ident_part(allow_slash) else { + break; + }; + + if only_ascii_used && !part.is_ascii() { + only_ascii_used = false; + } + + // Once a non-ASCII fragment appears, the lexer can no longer use + // the lowercase ASCII buffer for keyword matching, but the scan + // still needs to advance through the whole identifier. + last_was_buffered = false; + if only_ascii_used + && let Some(buf) = buf.as_deref_mut() + && let Some(slot) = buf.get_mut(idx) + { + *slot = part.to_ascii_lowercase() as u8; + idx += 1; + // The lexer's Tailwind `-*` fixup may later need to know + // whether this last fragment actually entered the buffer. + last_was_buffered = true; + } + + debug_assert!( + self.position() > start, + "identifier-part consumption must advance the scan cursor", + ); + } + + IdentifierSequenceScan { + count: idx, + only_ascii_used, + last_was_buffered, + position: self.position(), + stop_byte: self.current_byte(), + } + } + + /// Consumes a string escape after `\` and returns whether it produced an + /// invalid hex escape that should later surface as a diagnostic. + fn consume_string_escape(&mut self, quote: CssStringQuote) -> bool { + debug_assert!(self.current_byte() == Some(b'\\')); + + self.advance(1); + + match self.current_byte() { + Some(byte) if is_newline_byte(byte) => { + let len = if byte == b'\r' && self.peek_byte() == Some(b'\n') { + 2 + } else { + 1 + }; + self.advance(len); + false + } + Some(byte) if byte == quote.as_byte() => { + self.advance(1); + false + } + Some(byte) if byte.is_ascii_hexdigit() => { + self.consume_escape_sequence() == REPLACEMENT_CHARACTER + } + Some(byte) if byte.is_ascii() => { + self.advance(1); + false + } + Some(_) => { + let chr = self.current_char_at_boundary(); + self.advance(chr.len_utf8()); + false + } + None => false, + } + } + + /// Consumes a quoted string, including nested escapes, until the matching + /// closing quote or EOF. + fn consume_string(&mut self, quote: CssStringQuote) { + let quote_byte = quote.as_byte(); + self.advance(1); + + while let Some(current) = self.current_byte() { + match current { + b'\\' => { + self.consume_string_escape(quote); + } + _ if current == quote_byte => { + self.advance(1); + return; + } + _ => self.advance_byte_or_char(current), + } + } + } + + /// Consumes a CSS block comment, including SCSS nested block comments when + /// SCSS mode is enabled. + fn consume_block_comment(&mut self) { + debug_assert!(self.current_byte() == Some(b'/') && self.peek_byte() == Some(b'*')); + self.advance(2); + let mut depth = 1usize; + + while let Some(current) = self.current_byte() { + match current { + b'/' if self.is_scss && self.peek_byte() == Some(b'*') => { + self.advance(2); + depth += 1; + } + b'*' if self.peek_byte() == Some(b'/') => { + self.advance(2); + depth -= 1; + + if !self.is_scss || depth == 0 { + return; + } + } + _ => self.advance_byte_or_char(current), + } + } + } + + /// Consumes an SCSS/CSS line comment and returns whether it terminated on a + /// newline before EOF. + fn consume_line_comment(&mut self) -> bool { + debug_assert!(self.current_byte() == Some(b'/') && self.peek_byte() == Some(b'/')); + self.advance(2); + + while let Some(current) = self.current_byte() { + match current { + b'\n' | b'\r' => return true, + _ => self.advance_byte_or_char(current), + } + } + + false + } + + /// Returns true when the current position starts a line comment under the + /// active lexical configuration. + fn is_at_line_comment(self) -> bool { + self.line_comments_enabled + && self.current_byte() == Some(b'/') + && self.peek_byte() == Some(b'/') + } + + /// Skips whitespace and block comments while leaving line comments for + /// callers that need custom `//` handling. + fn skip_whitespace_and_block_comments(&mut self) { + loop { + let start = self.position(); + + while let Some(byte) = self.current_byte() { + if matches!(byte, b'\t' | b' ' | b'\n' | b'\r' | 0x0C) { + self.advance(1); + } else { + break; + } + } + + if self.current_byte() == Some(b'/') && self.peek_byte() == Some(b'*') { + self.consume_block_comment(); + } + + if self.position() == start { + return; + } + } + } + + /// Skips URL-leading trivia while preserving protocol-relative raw URLs + /// such as `url(//cdn.example.com/app.css)`. + fn skip_url_body_trivia(&mut self) { + loop { + self.skip_whitespace_and_block_comments(); + + if !self.is_at_line_comment() { + return; + } + + let mut after_comment = *self; + // `url(//cdn.example.com/app.css)` is a valid protocol-relative raw + // URL, so only treat `//...` as trivia when the comment actually + // terminates before the URL body continues. + if !after_comment.consume_line_comment() { + return; + } + + *self = after_comment; + } + } + + /// Returns true when `position + offset` starts an SCSS interpolation. + pub(crate) fn is_at_scss_interpolation_at(self, offset: usize) -> bool { + self.byte_at(offset) == Some(b'#') && self.byte_at(offset + 1) == Some(b'{') + } + + /// Returns true when the current position starts an SCSS interpolation. + pub(crate) fn is_at_scss_interpolation(self) -> bool { + self.is_at_scss_interpolation_at(0) + } + + /// Consumes a full SCSS interpolation body, including nested + /// interpolations, strings, and comments. + fn consume_scss_interpolation(&mut self) -> bool { + if !self.is_at_scss_interpolation() { + return false; + } + + self.advance(2); + let mut brace_depth = 1usize; + + while let Some(current) = self.current_byte() { + match current { + b'\'' => self.consume_string(CssStringQuote::Single), + b'"' => self.consume_string(CssStringQuote::Double), + b'/' if self.peek_byte() == Some(b'*') => self.consume_block_comment(), + b'/' if self.line_comments_enabled && self.peek_byte() == Some(b'/') => { + self.consume_line_comment(); + } + b'#' if self.peek_byte() == Some(b'{') => { + self.advance(2); + brace_depth += 1; + } + b'{' => { + self.advance(1); + brace_depth += 1; + } + b'}' => { + self.advance(1); + brace_depth -= 1; + + if brace_depth == 0 { + return true; + } + } + _ => self.advance_byte_or_char(current), + } + } + + false + } + + /// Scans a plain string body until a closing quote, newline, or EOF. + pub(crate) fn scan_plain_string_body(self, quote: CssStringQuote) -> StringBodyScan { + ScssStringScanner { cursor: self }.scan_plain_body(quote) + } + + /// Scans an interpolation-aware string body until a closing quote, + /// interpolation boundary, newline, or EOF. + pub(crate) fn scan_interpolated_string_body(self, quote: CssStringQuote) -> StringBodyScan { + ScssStringScanner { cursor: self }.scan_interpolated_body(quote) + } + + /// Returns true when this cursor begins an interpolation-containing + /// identifier immediately followed by `(`. + pub(crate) fn is_at_scss_interpolated_function(self) -> bool { + ScssInterpolatedIdentifierScanner::new(self).is_at_function() + } + + /// Classifies the first non-trivia content in a URL body as raw URL text, + /// interpolated function syntax, or ordinary tokenization. + pub(crate) fn scan_url_body_start( + self, + scss_exclusive_syntax_allowed: bool, + ) -> UrlBodyStartScan { + UrlBodyScanner::new(self).scan_url_body_start(scss_exclusive_syntax_allowed) + } + + /// Scans a raw URL literal starting at the current position, if one can + /// begin here. + pub(crate) fn scan_url_raw_value(self) -> Option { + UrlBodyScanner::new(self).scan_url_raw_value() + } +} + +/// Scans the body of a quoted string without committing lexer state. +/// +/// This helper centralizes the shared stop conditions for plain CSS strings and +/// SCSS strings that may suspend on interpolation boundaries. +#[derive(Debug, Copy, Clone)] +struct ScssStringScanner<'src> { + cursor: CssScanCursor<'src>, +} + +impl<'src> ScssStringScanner<'src> { + /// Scans a plain string body until the string closes, hits a newline, or + /// reaches EOF, while collecting invalid escape diagnostics as data. + fn scan_plain_body(self, quote: CssStringQuote) -> StringBodyScan { + self.scan_body(quote, false) + } + + /// Scans an interpolation-aware string body until the string closes, hits + /// interpolation, hits a newline, or reaches EOF. + fn scan_interpolated_body(self, quote: CssStringQuote) -> StringBodyScan { + self.scan_body(quote, true) + } + + fn scan_body(mut self, quote: CssStringQuote, stop_at_interpolation: bool) -> StringBodyScan { + let mut invalid_escape_ranges = Vec::new(); + let quote_byte = quote.as_byte(); + + while let Some(byte) = self.cursor.current_byte() { + let position = self.cursor.position(); + + if byte == quote_byte { + return StringBodyScan { + stop: StringBodyScanStop::ClosingQuote { position }, + invalid_escape_ranges, + }; + } + + if stop_at_interpolation && self.cursor.is_at_scss_interpolation() { + return StringBodyScan { + stop: StringBodyScanStop::Interpolation { position }, + invalid_escape_ranges, + }; + } + + match lookup_byte(byte) { + BSL => { + let escape_start = self.cursor.position(); + if self.cursor.consume_string_escape(quote) { + invalid_escape_ranges.push(TextRange::new( + TextSize::from(escape_start as u32), + TextSize::from(self.cursor.position() as u32), + )); + } + } + WHS if is_newline_byte(byte) => { + let len = if byte == b'\r' && self.cursor.peek_byte() == Some(b'\n') { + 2 + } else { + 1 + }; + + return StringBodyScan { + stop: StringBodyScanStop::Newline { position, len }, + invalid_escape_ranges, + }; + } + _ => self.cursor.advance_byte_or_char(byte), + } + } + + StringBodyScan { + stop: StringBodyScanStop::Eof { + position: self.cursor.source_len(), + }, + invalid_escape_ranges, + } + } +} + +/// Scans identifier-like sequences that may mix ordinary identifier fragments +/// with SCSS interpolation parts. +/// +/// The main consumer is function-head classification for forms such as +/// `foo#{1 + 1}(bar)` and `#{name}(bar)`. +#[derive(Debug, Copy, Clone)] +struct ScssInterpolatedIdentifierScanner<'src> { + cursor: CssScanCursor<'src>, +} + +impl<'src> ScssInterpolatedIdentifierScanner<'src> { + /// Creates a speculative scanner over the provided shared scan cursor. + const fn new(cursor: CssScanCursor<'src>) -> Self { + Self { cursor } + } + + /// Returns true when the current position starts an interpolation-containing + /// identifier immediately followed by `(`. + fn is_at_function(mut self) -> bool { + matches!( + self.scan_interpolated_identifier(), + Some(InterpolatedIdentifierFragment::Interpolation) + ) && self.cursor.current_byte() == Some(b'(') + } + + /// Consumes an interpolation-capable identifier and reports whether any of + /// its fragments came from interpolation. + fn scan_interpolated_identifier(&mut self) -> Option { + let first = self.scan_identifier_fragment()?; + let mut saw_interpolation = matches!(first, InterpolatedIdentifierFragment::Interpolation); + + loop { + if self.is_at_identifier_hyphen() { + self.cursor.advance(1); + } else if !self.is_at_identifier_fragment() { + break; + } + + let fragment_start = self.cursor.position(); + let fragment = self.scan_identifier_fragment()?; + debug_assert!( + self.cursor.position() > fragment_start, + "interpolated-identifier fragment scanning must advance the scan cursor", + ); + saw_interpolation |= matches!(fragment, InterpolatedIdentifierFragment::Interpolation); + } + + if saw_interpolation { + Some(InterpolatedIdentifierFragment::Interpolation) + } else { + Some(InterpolatedIdentifierFragment::Identifier) + } + } + + /// Consumes one identifier fragment, either a normal identifier piece or a + /// full SCSS interpolation. + fn scan_identifier_fragment(&mut self) -> Option { + if self.cursor.is_at_scss_interpolation() { + self.cursor + .consume_scss_interpolation() + .then_some(InterpolatedIdentifierFragment::Interpolation) + } else if self.cursor.is_ident_start() { + self.cursor.advance_ident_sequence(); + Some(InterpolatedIdentifierFragment::Identifier) + } else { + None + } + } + + /// Returns true when `position + offset` can continue an + /// interpolation-capable identifier. + fn is_at_identifier_fragment_at(&self, offset: usize) -> bool { + self.cursor.is_at_scss_interpolation_at(offset) || self.cursor.is_ident_start_at(offset) + } + + /// Returns true when the current position can continue an + /// interpolation-capable identifier. + fn is_at_identifier_fragment(&self) -> bool { + self.is_at_identifier_fragment_at(0) + } + + /// Returns true when the current `-` should stay attached to the + /// interpolated identifier instead of being left for surrounding syntax. + fn is_at_identifier_hyphen(&self) -> bool { + if self.cursor.current_byte() != Some(b'-') { + return false; + } + + // Only treat `-` as part of the interpolated identifier when another + // identifier fragment follows it. This keeps trailing `-` boundaries + // available for surrounding syntax such as operators or line breaks. + self.is_at_identifier_fragment_at(1) + } +} + +/// Classifies the first non-trivia token in a URL body. +/// +/// CSS `url(...)` bodies are normally raw literals, but SCSS can place +/// interpolation-containing function calls there, such as +/// `url(foo#{1 + 1}(bar))`. This scanner decides whether the real lexer should +/// emit a raw URL literal or stay on regular tokens so the parser can build the +/// function call. +#[derive(Debug, Copy, Clone)] +struct UrlBodyScanner<'src> { + cursor: CssScanCursor<'src>, +} + +impl<'src> UrlBodyScanner<'src> { + /// Creates a speculative URL-body scanner over the shared scan cursor. + const fn new(cursor: CssScanCursor<'src>) -> Self { + Self { cursor } + } + + /// Consumes a raw-URL escape after `\`, preserving UTF-8 boundaries for + /// escaped non-ASCII characters. + fn consume_url_escape(&mut self) { + debug_assert!(self.cursor.current_byte() == Some(b'\\')); + self.cursor.advance(1); + + match self.cursor.current_byte() { + Some(byte) if byte.is_ascii() => self.cursor.advance(1), + // URL raw values are otherwise byte-oriented, but escaped + // non-ASCII characters must still advance on a UTF-8 boundary so + // the next loop iteration does not slice in the middle of a code + // point. + Some(current) => self.cursor.advance_byte_or_char(current), + None => {} + } + } + + /// Scans a raw URL literal from the current position until `)` or EOF. + fn scan_url_raw_value(&mut self) -> Option { + let current = self.cursor.current_byte()?; + let dispatch = lookup_byte(current); + + // Reuse the lexer's "can a raw URL literal start here?" classification + // before switching into byte-oriented URL scanning. + if !matches!( + dispatch, + IDT | DOL | UNI | PRD | SLH | ZER | DIG | TLD | HAS + ) { + return None; + } + + let start = self.cursor.position(); + + while let Some(current) = self.cursor.current_byte() { + match lookup_byte(current) { + PNC => { + return Some(PendingUrlRawValueScan { + start, + end: self.cursor.position(), + terminated: true, + }); + } + BSL if self.cursor.is_valid_escape_at(1) => self.consume_url_escape(), + _ => self.cursor.advance_byte_or_char(current), + } + } + + Some(PendingUrlRawValueScan { + start, + end: self.cursor.position(), + terminated: false, + }) + } + + /// Classifies the first non-trivia URL-body content for the real lexer. + fn scan_url_body_start(&mut self, scss_exclusive_syntax_allowed: bool) -> UrlBodyStartScan { + self.cursor.skip_url_body_trivia(); + + if scss_exclusive_syntax_allowed && self.cursor.is_at_scss_interpolated_function() { + UrlBodyStartScan::InterpolatedFunction + } else { + self.scan_url_raw_value() + .map_or(UrlBodyStartScan::Other, UrlBodyStartScan::RawValue) + } + } +} + +/// Decodes a CSS hex escape sequence into a Unicode scalar value or the +/// replacement character when the escape is invalid. +#[inline] +fn decode_escaped_code_point(hex: u32) -> char { + match hex { + 0 => REPLACEMENT_CHARACTER, + 55_296..=57_343 => REPLACEMENT_CHARACTER, + 1_114_112.. => REPLACEMENT_CHARACTER, + _ => char::from_u32(hex).unwrap_or(REPLACEMENT_CHARACTER), + } +} diff --git a/crates/biome_css_parser/src/lexer/source_cursor.rs b/crates/biome_css_parser/src/lexer/source_cursor.rs new file mode 100644 index 000000000000..7ea3e32f685f --- /dev/null +++ b/crates/biome_css_parser/src/lexer/source_cursor.rs @@ -0,0 +1,147 @@ +/// Shared source-only navigation primitive for CSS cursor helpers. +#[derive(Debug, Copy, Clone)] +pub(crate) struct SourceCursor<'src> { + source: &'src str, + position: usize, +} + +impl<'src> SourceCursor<'src> { + /// Creates a source cursor at the given byte position. + pub(crate) const fn new(source: &'src str, position: usize) -> Self { + Self { source, position } + } + + /// Returns the backing source text. + pub(crate) const fn source(self) -> &'src str { + self.source + } + + /// Returns the current absolute byte position. + pub(crate) const fn position(self) -> usize { + self.position + } + + /// Sets the current absolute byte position. + pub(crate) fn set_position(&mut self, position: usize) { + debug_assert!(position <= self.source.len()); + self.position = position; + } + + /// Returns the byte at the current position, if any. + pub(crate) fn current_byte(&self) -> Option { + self.source.as_bytes().get(self.position).copied() + } + + /// Returns the byte immediately after the current position, if any. + pub(crate) fn peek_byte(&self) -> Option { + self.byte_at(1) + } + + /// Returns the byte at `position + offset`, if any. + pub(crate) fn byte_at(&self, offset: usize) -> Option { + self.source.as_bytes().get(self.position + offset).copied() + } + + /// Advances the cursor by a known byte count. + pub(crate) fn advance(&mut self, amount: usize) { + self.position += amount; + } + + /// Advances over one ASCII byte or one full UTF-8 code point. + pub(crate) fn advance_byte_or_char(&mut self, current: u8) { + if current.is_ascii() { + self.advance(1); + } else { + self.advance(self.current_char().len_utf8()); + } + } + + /// Returns the character that starts at the current byte position. + /// + /// ## Safety + /// Must be called at a valid non-EOF UTF-8 char boundary. + pub(crate) fn current_char(&self) -> char { + self.char_at(0) + } + + /// Returns the character that starts at `position + offset`. + /// + /// ## Safety + /// The target byte position must be a valid non-EOF UTF-8 char boundary. + pub(crate) fn char_at(&self, offset: usize) -> char { + let position = self.position + offset; + + debug_assert!(position < self.source.len()); + debug_assert!(self.source.is_char_boundary(position)); + + // SAFETY: The lexer guarantees valid UTF-8 input, and callers only + // reach this helper after proving `position` is a non-EOF UTF-8 + // boundary. + let string = unsafe { + std::str::from_utf8_unchecked(self.source.as_bytes().get_unchecked(position..)) + }; + + if let Some(chr) = string.chars().next() { + chr + } else { + // SAFETY: The preconditions above guarantee a character exists + // here. + unsafe { core::hint::unreachable_unchecked() } + } + } + + /// Returns true when a backslash escape starting at `offset` is valid. + pub(crate) fn is_valid_escape_at(&self, offset: usize) -> bool { + self.byte_at(offset) + .is_some_and(|byte| !is_newline_byte(byte)) + } +} + +pub(super) const fn is_newline_byte(byte: u8) -> bool { + matches!(byte, b'\n' | b'\r' | 0x0C) +} + +#[cfg(test)] +mod tests { + use super::SourceCursor; + + #[test] + fn char_access_uses_utf8_boundaries() { + let cursor = SourceCursor::new("aé", 0); + + assert_eq!(cursor.current_char(), 'a'); + assert_eq!(cursor.char_at(1), 'é'); + } + + #[test] + fn advances_over_non_ascii_code_points() { + let mut cursor = SourceCursor::new("éx", 0); + + cursor.advance_byte_or_char(b'\xC3'); + assert_eq!(cursor.position(), 2); + assert_eq!(cursor.current_char(), 'x'); + } + + #[test] + fn set_position_restores_position_and_byte() { + let mut cursor = SourceCursor::new("abc", 0); + + cursor.advance(1); + cursor.advance(1); + + assert_eq!(cursor.position(), 2); + assert_eq!(cursor.current_byte(), Some(b'c')); + + cursor.set_position(1); + + assert_eq!(cursor.position(), 1); + assert_eq!(cursor.current_byte(), Some(b'b')); + } + + #[test] + fn form_feed_is_not_a_valid_escape_target() { + let cursor = SourceCursor::new("\\\u{000C}", 0); + + assert!(!cursor.is_valid_escape_at(1)); + } +} diff --git a/crates/biome_css_parser/src/lexer/tests.rs b/crates/biome_css_parser/src/lexer/tests.rs index 6e26f2620e6f..5cd0a092ed00 100644 --- a/crates/biome_css_parser/src/lexer/tests.rs +++ b/crates/biome_css_parser/src/lexer/tests.rs @@ -1,16 +1,25 @@ #![cfg(test)] #![expect(unused_mut, unused_variables)] -use super::{CssLexer, TextSize}; use crate::lexer::CssLexContext; -use biome_css_syntax::CssSyntaxKind::EOF; use crate::CssParserOptions; -use biome_parser::lexer::Lexer; +use biome_css_syntax::CssFileSource; +use biome_css_syntax::{ + CssSyntaxKind::{self, EOF}, + T, TextRange, +}; +use biome_parser::lexer::{Lexer, LexerWithCheckpoint}; use quickcheck_macros::quickcheck; use std::sync::mpsc::channel; use std::thread; use std::time::Duration; +use super::{ + source_cursor::SourceCursor, + scan_cursor::{CssScanCursor, StringBodyScanStop, UrlBodyStartScan}, + CssLexer, TextSize, +}; + // Assert the result of lexing a piece of source code, // and make sure the tokens yielded are fully lossless and the source can be reconstructed from only the tokens macro_rules! assert_lex { @@ -108,6 +117,589 @@ fn empty() { } } +#[test] +fn rewind_restores_lexer_source_cursor_position() { + let mut lexer = CssLexer::from_str("url(foo)"); + + assert_eq!( + lexer.next_token(CssLexContext::Regular), + CssSyntaxKind::URL_KW + ); + assert_eq!(lexer.position(), 3); + assert_eq!(lexer.current_byte(), Some(b'(')); + + let checkpoint = lexer.checkpoint(); + + assert_eq!(lexer.next_token(CssLexContext::Regular), T!['(']); + assert_eq!(lexer.position(), 4); + assert_eq!(lexer.current_byte(), Some(b'f')); + + lexer.rewind(checkpoint); + + assert_eq!(lexer.position(), 3); + assert_eq!(lexer.current_byte(), Some(b'(')); + assert_eq!(lexer.next_token(CssLexContext::Regular), T!['(']); + assert_eq!( + lexer.current_range(), + TextRange::new(TextSize::from(3), TextSize::from(4)) + ); +} + +#[test] +fn url_body_context_emits_raw_url_value() { + let mut lexer = CssLexer::from_str("url(foo#{$x}.css)"); + + assert_eq!( + lexer.next_token(CssLexContext::Regular), + CssSyntaxKind::URL_KW + ); + assert_eq!(lexer.next_token(CssLexContext::Regular), T!['(']); + + assert_eq!( + lexer.next_token(CssLexContext::UrlBody { + scss_exclusive_syntax_allowed: true, + }), + CssSyntaxKind::CSS_URL_VALUE_RAW_LITERAL + ); + assert_eq!( + lexer.current_range(), + TextRange::new(TextSize::from(4), TextSize::from(16)) + ); + assert_eq!(lexer.next_token(CssLexContext::Regular), T![')']); +} + +#[test] +fn url_body_context_falls_back_for_interpolated_function() { + let mut lexer = CssLexer::from_str("url(#{name}(bar))"); + + assert_eq!( + lexer.next_token(CssLexContext::Regular), + CssSyntaxKind::URL_KW + ); + assert_eq!(lexer.next_token(CssLexContext::Regular), T!['(']); + + assert_eq!( + lexer.next_token(CssLexContext::UrlBody { + scss_exclusive_syntax_allowed: true, + }), + T![#] + ); + assert_eq!( + lexer.current_range(), + TextRange::new(TextSize::from(4), TextSize::from(5)) + ); + + assert_eq!(lexer.next_token(CssLexContext::Regular), T!['{']); + assert_eq!( + lexer.next_token(CssLexContext::Regular), + CssSyntaxKind::IDENT + ); + assert_eq!(lexer.next_token(CssLexContext::Regular), T!['}']); + assert_eq!(lexer.next_token(CssLexContext::Regular), T!['(']); + assert_eq!( + lexer.next_token(CssLexContext::Regular), + CssSyntaxKind::IDENT + ); + assert_eq!(lexer.next_token(CssLexContext::Regular), T![')']); + assert_eq!(lexer.next_token(CssLexContext::Regular), T![')']); +} + +#[test] +fn url_body_context_reuses_pending_raw_scan_after_leading_trivia() { + let mut lexer = CssLexer::from_str("url( foo)"); + + assert_eq!( + lexer.next_token(CssLexContext::Regular), + CssSyntaxKind::URL_KW + ); + assert_eq!(lexer.next_token(CssLexContext::Regular), T!['(']); + + assert_eq!( + lexer.next_token(CssLexContext::UrlBody { + scss_exclusive_syntax_allowed: false, + }), + CssSyntaxKind::WHITESPACE + ); + + assert_eq!( + lexer.next_token(CssLexContext::UrlBody { + scss_exclusive_syntax_allowed: false, + }), + CssSyntaxKind::CSS_URL_VALUE_RAW_LITERAL + ); + assert_eq!( + lexer.current_range(), + TextRange::new(TextSize::from(5), TextSize::from(8)) + ); + + assert_eq!(lexer.next_token(CssLexContext::Regular), T![')']); +} + +#[test] +fn url_body_context_drops_stale_pending_raw_scan_after_regular_fallback() { + let mut lexer = CssLexer::from_str("url( foo)"); + + assert_eq!( + lexer.next_token(CssLexContext::Regular), + CssSyntaxKind::URL_KW + ); + assert_eq!(lexer.next_token(CssLexContext::Regular), T!['(']); + + assert_eq!( + lexer.next_token(CssLexContext::UrlBody { + scss_exclusive_syntax_allowed: false, + }), + CssSyntaxKind::WHITESPACE + ); + + assert_eq!( + lexer.next_token(CssLexContext::Regular), + CssSyntaxKind::IDENT + ); + + assert_eq!( + lexer.next_token(CssLexContext::UrlBody { + scss_exclusive_syntax_allowed: false, + }), + T![')'] + ); +} + +#[test] +fn url_body_context_skips_scss_line_comment_trivia_before_interpolated_function() { + let mut lexer = + CssLexer::from_str("url(// comment\n#{name}(bar))").with_source_type(CssFileSource::scss()); + + assert_eq!( + lexer.next_token(CssLexContext::Regular), + CssSyntaxKind::URL_KW + ); + assert_eq!(lexer.next_token(CssLexContext::Regular), T!['(']); + + assert_eq!( + lexer.next_token(CssLexContext::UrlBody { + scss_exclusive_syntax_allowed: true, + }), + CssSyntaxKind::COMMENT + ); + assert_eq!( + lexer.next_token(CssLexContext::UrlBody { + scss_exclusive_syntax_allowed: true, + }), + CssSyntaxKind::NEWLINE + ); + assert_eq!( + lexer.next_token(CssLexContext::UrlBody { + scss_exclusive_syntax_allowed: true, + }), + T![#] + ); +} + +#[test] +fn url_body_context_preserves_protocol_relative_url_in_scss() { + let mut lexer = + CssLexer::from_str("url(//cdn.example.com/app.css)").with_source_type(CssFileSource::scss()); + + assert_eq!( + lexer.next_token(CssLexContext::Regular), + CssSyntaxKind::URL_KW + ); + assert_eq!(lexer.next_token(CssLexContext::Regular), T!['(']); + + assert_eq!( + lexer.next_token(CssLexContext::UrlBody { + scss_exclusive_syntax_allowed: true, + }), + CssSyntaxKind::CSS_URL_VALUE_RAW_LITERAL + ); + assert_eq!( + lexer.current_range(), + TextRange::new(TextSize::from(4), TextSize::from(29)) + ); + assert_eq!(lexer.next_token(CssLexContext::Regular), T![')']); +} + +#[test] +fn url_body_context_handles_escaped_non_ascii_in_raw_url() { + let mut lexer = CssLexer::from_str("url(foo\\ébar)").with_source_type(CssFileSource::scss()); + + assert_eq!( + lexer.next_token(CssLexContext::Regular), + CssSyntaxKind::URL_KW + ); + assert_eq!(lexer.next_token(CssLexContext::Regular), T!['(']); + + assert_eq!( + lexer.next_token(CssLexContext::UrlBody { + scss_exclusive_syntax_allowed: true, + }), + CssSyntaxKind::CSS_URL_VALUE_RAW_LITERAL + ); + assert_eq!( + lexer.current_range(), + TextRange::new(TextSize::from(4), TextSize::from(13)) + ); + assert_eq!(lexer.next_token(CssLexContext::Regular), T![')']); +} + +#[test] +fn css_scan_cursor_reports_interpolated_function_shape() { + let cursor = CssScanCursor::new(SourceCursor::new("foo#{1 + 1}(bar)", 0), true, true); + + assert!(cursor.is_at_scss_interpolated_function()); +} + +#[test] +fn css_scan_cursor_respects_line_comment_config_in_url_body_scanning() { + let source = "// comment\nfoo)"; + + let scss_cursor = CssScanCursor::new(SourceCursor::new(source, 0), true, true); + let css_cursor = CssScanCursor::new(SourceCursor::new(source, 0), false, false); + + let scss_scan = scss_cursor.scan_url_body_start(false); + let css_scan = css_cursor.scan_url_body_start(false); + + assert!(matches!( + scss_scan, + UrlBodyStartScan::RawValue(scan) if scan.start == 11 + )); + assert!(matches!( + css_scan, + UrlBodyStartScan::RawValue(scan) if scan.start == 0 + )); +} + +#[test] +fn css_scan_cursor_scans_plain_string_without_interpolation_mode() { + let cursor = CssScanCursor::new(SourceCursor::new("\"a#{b}\"", 1), true, true); + let scan = cursor.scan_plain_string_body(super::CssStringQuote::Double); + + assert!(matches!( + scan.stop, + StringBodyScanStop::ClosingQuote { .. } + )); +} + +#[test] +fn css_scan_cursor_treats_form_feed_as_string_newline() { + let cursor = CssScanCursor::new(SourceCursor::new("\"a\u{000C}b\"", 1), true, true); + let scan = cursor.scan_plain_string_body(super::CssStringQuote::Double); + + assert_eq!( + scan.stop, + StringBodyScanStop::Newline { + position: 2, + len: 1, + } + ); + assert!(scan.invalid_escape_ranges.is_empty()); +} + +#[test] +fn css_scan_cursor_treats_backslash_form_feed_as_line_continuation() { + let cursor = CssScanCursor::new(SourceCursor::new("\"a\\\u{000C}b\"", 1), true, true); + let scan = cursor.scan_plain_string_body(super::CssStringQuote::Double); + + assert!(matches!( + scan.stop, + StringBodyScanStop::ClosingQuote { position: 5 } + )); + assert!(scan.invalid_escape_ranges.is_empty()); +} + +#[test] +fn css_scan_cursor_identifier_mode_controls_slash_consumption() { + let mut cursor = CssScanCursor::new(SourceCursor::new("/foo", 0), true, true); + let mut buf = [0u8; 8]; + + let scan = cursor.consume_ident_sequence(&mut buf); + assert_eq!(scan.count, 0); + assert_eq!(scan.stop_byte, Some(b'/')); + assert_eq!(cursor.position(), 0); + + let mut cursor = CssScanCursor::new(SourceCursor::new("/foo", 0), true, true); + let mut buf = [0u8; 8]; + + let scan = cursor.consume_ident_sequence_with_slash(&mut buf); + assert_eq!(scan.count, 4); + assert_eq!(&buf[..scan.count], b"/foo"); + assert_eq!(cursor.position(), 4); +} + +#[test] +fn css_scan_cursor_does_not_treat_backslash_form_feed_as_identifier_escape() { + let mut cursor = CssScanCursor::new(SourceCursor::new("\\\u{000C}foo", 0), true, true); + let mut buf = [0u8; 8]; + + assert!(!cursor.is_ident_start()); + let scan = cursor.consume_ident_sequence(&mut buf); + assert_eq!(scan.count, 0); + assert_eq!(scan.stop_byte, Some(b'\\')); + assert_eq!(cursor.position(), 0); +} + +#[test] +fn css_scan_cursor_consumes_identifier_sequence_with_escape() { + let mut cursor = CssScanCursor::new(SourceCursor::new(r#"\66 oo-bar"#, 0), true, true); + let mut buf = [0u8; 16]; + + let scan = cursor.consume_ident_sequence(&mut buf); + + assert!(scan.count > 0); + assert!(scan.only_ascii_used); + assert_eq!(&buf[..scan.count], b"foo-bar"); + assert_eq!(scan.position, 10); + assert_eq!(scan.stop_byte, None); + assert_eq!(cursor.position(), 10); +} + +#[test] +fn css_scan_cursor_consumes_identifier_sequence_with_slash_mode() { + let mut cursor = CssScanCursor::new(SourceCursor::new("w/2", 0), true, true); + let mut buf = [0u8; 16]; + + let scan = cursor.consume_ident_sequence_with_slash(&mut buf); + + assert_eq!(&buf[..scan.count], b"w/2"); + assert!(scan.only_ascii_used); + assert_eq!(scan.position, 3); + assert_eq!(scan.stop_byte, None); + assert_eq!(cursor.position(), 3); +} + +#[test] +fn css_scan_cursor_consumes_identifier_sequence_tracks_non_ascii_transition() { + let mut cursor = CssScanCursor::new(SourceCursor::new("abécd ", 0), true, true); + let mut buf = [0u8; 16]; + + let scan = cursor.consume_ident_sequence(&mut buf); + + assert_eq!(&buf[..scan.count], b"ab"); + assert!(!scan.only_ascii_used); + assert_eq!(scan.position, 6); + assert_eq!(scan.stop_byte, Some(b' ')); + assert_eq!(cursor.position(), 6); +} + +#[test] +fn css_lexer_consumes_identifier_sequence_with_escape() { + let mut lexer = CssLexer::from_str(r#"\66 oo-bar"#); + let mut buf = [0u8; 16]; + + let (count, only_ascii_used) = + lexer.consume_ident_sequence(&mut buf, false); + + assert!(count > 0); + assert!(only_ascii_used); + assert_eq!(&buf[..count], b"foo-bar"); + assert_eq!(lexer.position(), 10); + assert_eq!(lexer.current_byte(), None); +} + +#[test] +fn css_lexer_consumes_identifier_sequence_with_slash_mode() { + let mut lexer = CssLexer::from_str("w/2"); + let mut buf = [0u8; 16]; + + let (count, only_ascii_used) = + lexer.consume_ident_sequence(&mut buf, true); + + assert_eq!(&buf[..count], b"w/2"); + assert!(only_ascii_used); + assert_eq!(lexer.position(), 3); + assert_eq!(lexer.current_byte(), None); +} + +#[test] +fn css_lexer_consumes_identifier_sequence_stops_before_slash_when_disabled() { + let mut lexer = CssLexer::from_str("w/2"); + let mut buf = [0u8; 16]; + + let (count, only_ascii_used) = + lexer.consume_ident_sequence(&mut buf, false); + + assert_eq!(&buf[..count], b"w"); + assert!(only_ascii_used); + assert_eq!(lexer.position(), 1); + assert_eq!(lexer.current_byte(), Some(b'/')); +} + +#[test] +fn css_lexer_consumes_identifier_sequence_tracks_non_ascii_transition() { + let mut lexer = CssLexer::from_str("abécd "); + let mut buf = [0u8; 16]; + + let (count, only_ascii_used) = + lexer.consume_ident_sequence(&mut buf, false); + + assert_eq!(&buf[..count], b"ab"); + assert!(!only_ascii_used); + assert_eq!(lexer.position(), 6); + assert_eq!(lexer.current_byte(), Some(b' ')); +} + +#[test] +fn css_lexer_tailwind_identifier_sequence_stops_before_hyphen_star_suffix() { + let mut lexer = CssLexer::from_str("--color-*") + .with_options(CssParserOptions::default().allow_tailwind_directives()); + let mut buf = [0u8; 16]; + + let (count, only_ascii_used) = lexer.consume_ident_sequence(&mut buf, false); + + assert_eq!(&buf[..count], b"--color"); + assert!(only_ascii_used); + assert_eq!(lexer.position(), 7); + assert_eq!(lexer.current_byte(), Some(b'-')); +} + +#[test] +fn css_lexer_tailwind_identifier_sequence_keeps_double_hyphen_before_star() { + let mut lexer = CssLexer::from_str("--*") + .with_options(CssParserOptions::default().allow_tailwind_directives()); + let mut buf = [0u8; 16]; + + let (count, only_ascii_used) = lexer.consume_ident_sequence(&mut buf, false); + + assert_eq!(&buf[..count], b"--"); + assert!(only_ascii_used); + assert_eq!(lexer.position(), 2); + assert_eq!(lexer.current_byte(), Some(b'*')); +} + +#[test] +fn css_lexer_tailwind_identifier_sequence_does_not_rewind_into_escaped_hyphen_before_star() { + let mut lexer = CssLexer::from_str(r"--foo\2d*") + .with_options(CssParserOptions::default().allow_tailwind_directives()); + let mut buf = [0u8; 16]; + + let (count, only_ascii_used) = lexer.consume_ident_sequence(&mut buf, false); + + assert_eq!(&buf[..count], b"--foo-"); + assert!(only_ascii_used); + assert_eq!(lexer.position(), 8); + assert_eq!(lexer.current_byte(), Some(b'*')); +} + +#[test] +fn css_lexer_tailwind_identifier_sequence_keeps_count_when_buffer_fills_before_suffix_hyphen() { + let mut lexer = CssLexer::from_str("ab-*") + .with_options(CssParserOptions::default().allow_tailwind_directives()); + let mut buf = [0u8; 2]; + + let (count, only_ascii_used) = lexer.consume_ident_sequence(&mut buf, false); + + assert_eq!(&buf[..count], b"ab"); + assert!(only_ascii_used); + assert_eq!(count, 2); + assert_eq!(lexer.position(), 2); + assert_eq!(lexer.current_byte(), Some(b'-')); +} + +#[test] +fn lexer_scan_cursor_at_detects_interpolated_function_from_offset() { + let lexer = + CssLexer::from_str("xxfoo#{1 + 1}(bar)").with_source_type(CssFileSource::scss()); + + assert!(lexer.scan_cursor_at(2).is_at_scss_interpolated_function()); +} + +#[test] +fn lexer_helper_handles_escaped_quote_inside_string() { + let lexer = CssLexer::from_str(r#"foo#{"a\"b"}(bar)"#).with_source_type(CssFileSource::scss()); + + assert!(lexer.is_at_scss_interpolated_function(0)); +} + +#[test] +fn lexer_helper_handles_non_ascii_escape_inside_string() { + let lexer = CssLexer::from_str(r#"foo#{"a\é"}(bar)"#).with_source_type(CssFileSource::scss()); + + assert!(lexer.is_at_scss_interpolated_function(0)); +} + +#[test] +fn lexer_helper_ignores_line_comment_inside_interpolation_body() { + let lexer = CssLexer::from_str("foo#{// comment with } and \"quote\"\nname}(bar)") + .with_source_type(CssFileSource::scss()); + + assert!(lexer.is_at_scss_interpolated_function(0)); +} + +#[test] +fn same_quote_nested_string_in_interpolation_reuses_cached_first_chunk_scan() { + let mut lexer = + CssLexer::from_str("\"a#{\"b#{c}d\"}e\"").with_source_type(CssFileSource::scss()); + + assert_eq!( + lexer.next_token(CssLexContext::Regular), + CssSyntaxKind::SCSS_STRING_QUOTE + ); + assert!(lexer.has_pending_scss_string_start()); + + assert_eq!( + lexer.next_token(CssLexContext::ScssString(super::CssStringQuote::Double)), + CssSyntaxKind::SCSS_STRING_CONTENT_LITERAL + ); + assert_eq!( + lexer.current_range(), + TextRange::new(TextSize::from(1), TextSize::from(2)) + ); + assert!(!lexer.has_pending_scss_string_start()); + + assert_eq!( + lexer.next_token(CssLexContext::ScssString(super::CssStringQuote::Double)), + T![#] + ); + assert_eq!(lexer.next_token(CssLexContext::Regular), T!['{']); + + assert_eq!( + lexer.next_token(CssLexContext::ScssStringInterpolation( + super::CssStringQuote::Double, + )), + CssSyntaxKind::SCSS_STRING_QUOTE + ); + assert!(lexer.has_pending_scss_string_start()); + + assert_eq!( + lexer.next_token(CssLexContext::ScssString(super::CssStringQuote::Double)), + CssSyntaxKind::SCSS_STRING_CONTENT_LITERAL + ); + assert_eq!( + lexer.current_range(), + TextRange::new(TextSize::from(5), TextSize::from(6)) + ); + assert!(!lexer.has_pending_scss_string_start()); + + assert_eq!( + lexer.next_token(CssLexContext::ScssString(super::CssStringQuote::Double)), + T![#] + ); +} + +#[test] +fn css_scan_cursor_consumes_identifier_escape_as_a_single_part() { + let mut cursor = CssScanCursor::new(SourceCursor::new(r"\61 bc", 0), false, false); + let mut buf = [0u8; 8]; + let scan = cursor.consume_ident_sequence(&mut buf); + + assert_eq!(scan.count, 3); + assert_eq!(&buf[..scan.count], b"abc"); + assert_eq!(cursor.position(), 6); +} + +#[test] +fn css_scan_cursor_scans_raw_url_value_until_closing_paren() { + let cursor = CssScanCursor::new(SourceCursor::new(r"foo\)bar)", 0), false, false); + let scan = cursor + .scan_url_raw_value() + .expect("expected raw url scan"); + + assert_eq!(scan.start, 0); + assert_eq!(scan.end, 8); + assert!(scan.terminated); +} + #[test] fn string() { assert_lex! { diff --git a/crates/biome_css_parser/src/syntax/scss/mod.rs b/crates/biome_css_parser/src/syntax/scss/mod.rs index caa413715578..9ebe6f3e7f7a 100644 --- a/crates/biome_css_parser/src/syntax/scss/mod.rs +++ b/crates/biome_css_parser/src/syntax/scss/mod.rs @@ -52,8 +52,9 @@ pub(crate) use token_sets::{ SCSS_STATEMENT_START_SET, SCSS_VARIABLE_MODIFIER_LIST_END_SET, }; pub(crate) use value::{ - is_at_any_scss_value, is_at_scss_function, is_at_scss_interpolated_function_or_value, - is_at_scss_interpolated_string, is_at_scss_parent_selector_value, is_nth_at_scss_function, - parse_any_scss_value, parse_scss_function, parse_scss_interpolated_function_or_value, - parse_scss_interpolated_string, parse_scss_parent_selector_value, + is_at_any_scss_value, is_at_scss_function, is_at_scss_interpolated_function, + is_at_scss_interpolated_function_or_value, is_at_scss_interpolated_string, + is_at_scss_parent_selector_value, is_nth_at_scss_function, parse_any_scss_value, + parse_scss_function, parse_scss_interpolated_function_or_value, parse_scss_interpolated_string, + parse_scss_parent_selector_value, }; diff --git a/crates/biome_css_parser/src/syntax/scss/value/function.rs b/crates/biome_css_parser/src/syntax/scss/value/function.rs index 8cc54837b1b7..9c003d18b269 100644 --- a/crates/biome_css_parser/src/syntax/scss/value/function.rs +++ b/crates/biome_css_parser/src/syntax/scss/value/function.rs @@ -66,6 +66,17 @@ pub(crate) fn is_at_scss_interpolated_function_or_value(p: &mut CssParser) -> bo is_at_scss_interpolation(p) || is_at_identifier_with_interpolation_suffix(p) } +/// Returns true when the current interpolation-containing identifier shape is +/// immediately followed by `(`. +/// +/// This uses lexer-backed interpolation-aware lookahead so the SCSS parser can +/// distinguish `#{foo}` from `#{foo}(...)` and `foo#{1 + 1}` from +/// `foo#{1 + 1}(...)` without reimplementing that shape check in parser code. +#[inline] +pub(crate) fn is_at_scss_interpolated_function(p: &mut CssParser) -> bool { + is_at_scss_interpolated_function_or_value(p) && p.source().is_at_scss_interpolated_function() +} + /// Parses an SCSS interpolation-led value and upgrades it to a function call /// when the interpolation-shaped name is followed by `(`. /// @@ -96,6 +107,8 @@ pub(crate) fn parse_scss_interpolated_function_or_value(p: &mut CssParser) -> Pa }; let name = if name.kind(p) == SCSS_INTERPOLATION && p.at(T!['(']) { + // A bare interpolation such as `#{foo}(...)` still needs to become an + // interpolated identifier node before we wrap it in `CSS_FUNCTION`. let list = name .precede(p) .complete(p, SCSS_INTERPOLATED_IDENTIFIER_PART_LIST); diff --git a/crates/biome_css_parser/src/syntax/scss/value/mod.rs b/crates/biome_css_parser/src/syntax/scss/value/mod.rs index f38989632c17..33b6250e35c5 100644 --- a/crates/biome_css_parser/src/syntax/scss/value/mod.rs +++ b/crates/biome_css_parser/src/syntax/scss/value/mod.rs @@ -5,8 +5,9 @@ mod parent_selector; pub(crate) use any::{is_at_any_scss_value, parse_any_scss_value}; pub(crate) use function::{ - is_at_scss_function, is_at_scss_interpolated_function_or_value, is_nth_at_scss_function, - parse_scss_function, parse_scss_interpolated_function_or_value, + is_at_scss_function, is_at_scss_interpolated_function, + is_at_scss_interpolated_function_or_value, is_nth_at_scss_function, parse_scss_function, + parse_scss_interpolated_function_or_value, }; pub(crate) use interpolated_string::{ is_at_scss_interpolated_string, parse_scss_interpolated_string, diff --git a/crates/biome_css_parser/src/syntax/value/function/call.rs b/crates/biome_css_parser/src/syntax/value/function/call.rs index 6455f2aba7a9..a89ff396703e 100644 --- a/crates/biome_css_parser/src/syntax/value/function/call.rs +++ b/crates/biome_css_parser/src/syntax/value/function/call.rs @@ -87,17 +87,6 @@ fn is_at_css_if_function_in_context(p: &mut CssParser, context: ValueParsingCont || p.nth_at(2, T!['(']) } -#[inline] -pub(crate) fn is_at_function(p: &mut CssParser) -> bool { - is_at_function_with_context(p, ValueParsingContext::new(p, ValueParsingMode::ScssAware)) -} - -/// Checks if the current position is at a simple function head in CSS-only mode. -#[inline] -pub(crate) fn is_at_css_function(p: &mut CssParser) -> bool { - is_at_function_with_context(p, ValueParsingContext::new(p, ValueParsingMode::CssOnly)) -} - #[inline] fn is_at_function_with_context(p: &mut CssParser, context: ValueParsingContext) -> bool { is_nth_at_function_with_context(p, 0, context) && !is_at_url_function(p) diff --git a/crates/biome_css_parser/src/syntax/value/function/mod.rs b/crates/biome_css_parser/src/syntax/value/function/mod.rs index 4a378f3aa941..77920c081d24 100644 --- a/crates/biome_css_parser/src/syntax/value/function/mod.rs +++ b/crates/biome_css_parser/src/syntax/value/function/mod.rs @@ -4,8 +4,8 @@ mod parameter; mod tailwind; pub(crate) use call::{ - is_at_any_css_function, is_at_css_function, is_at_function, is_nth_at_css_function, - is_nth_at_function, parse_any_function_with_context, parse_css_function, parse_function, + is_at_any_css_function, is_nth_at_css_function, is_nth_at_function, + parse_any_function_with_context, parse_css_function, parse_function, }; pub(crate) use parameter::ParameterList; pub(crate) use tailwind::parse_tailwind_value_theme_reference; diff --git a/crates/biome_css_parser/src/syntax/value/url.rs b/crates/biome_css_parser/src/syntax/value/url.rs index bb58865c4aff..b3872e8e5367 100644 --- a/crates/biome_css_parser/src/syntax/value/url.rs +++ b/crates/biome_css_parser/src/syntax/value/url.rs @@ -3,12 +3,12 @@ use crate::parser::CssParser; use crate::syntax::parse_error::scss_only_syntax_error; use crate::syntax::scss::{ - is_at_scss_function, is_at_scss_interpolated_string, is_nth_at_scss_function, - parse_scss_function, parse_scss_interpolated_string, + is_at_scss_function, is_at_scss_interpolated_function, is_at_scss_interpolated_string, + is_nth_at_scss_function, parse_scss_function, parse_scss_interpolated_function_or_value, + parse_scss_interpolated_string, }; use crate::syntax::value::function::{ - is_at_css_function, is_at_function, is_nth_at_css_function, is_nth_at_function, - parse_css_function, parse_function, + is_nth_at_css_function, is_nth_at_function, parse_css_function, parse_function, }; use crate::syntax::value::parse_error::expected_url_modifier; use crate::syntax::{CssSyntaxFeatures, ValueParsingContext, ValueParsingMode}; @@ -85,15 +85,28 @@ pub(crate) fn parse_url_function_with_context( p.bump_ts(URL_SET); - if is_at_nth_url_function(p, 1, context) { - // we need to check if the next token is a function or not - // to cover the case of `src(var(--foo));` + if is_at_nth_url_modifier_function(p, 1, context) { + // Keep plain function heads on regular tokenization so `src(var(--foo))` + // and similar cases do not get folded into a raw URL literal. p.bump(T!['(']); + } else if context.is_full_scss_parsing_allowed() { + // Full SCSS mode needs URL-body classification so interpolation-based + // function names such as `url(#{name}(bar))` survive lexing. + p.bump_with_context( + T!['('], + CssLexContext::UrlBody { + scss_exclusive_syntax_allowed: true, + }, + ); } else { + // Plain CSS keeps the cheaper raw-URL path. CSS-only SCSS diagnostics + // for interpolation-shaped bodies are not worth paying this cost on + // every `url(...)`. p.bump_with_context(T!['('], CssLexContext::UrlRawValue); - parse_url_value(p).ok(); } + parse_url_value(p).ok(); + UrlModifierList::new(context).parse_list(p); p.expect(T![')']); @@ -101,7 +114,11 @@ pub(crate) fn parse_url_function_with_context( } #[inline] -fn is_at_nth_url_function(p: &mut CssParser, n: usize, context: ValueParsingContext) -> bool { +fn is_at_nth_url_modifier_function( + p: &mut CssParser, + n: usize, + context: ValueParsingContext, +) -> bool { if context.is_scss_exclusive_syntax_allowed() { is_nth_at_scss_function(p, n) || is_nth_at_function(p, n) } else { @@ -114,11 +131,8 @@ fn is_at_url_modifier_function_with_context( p: &mut CssParser, context: ValueParsingContext, ) -> bool { - if context.is_scss_exclusive_syntax_allowed() { - is_at_scss_function(p) || is_at_function(p) - } else { - is_at_css_function(p) - } + is_at_nth_url_modifier_function(p, 0, context) + || (context.is_scss_exclusive_syntax_allowed() && is_at_scss_interpolated_function(p)) } #[inline] @@ -131,6 +145,14 @@ fn parse_url_modifier_function_with_context( CssSyntaxFeatures::Scss.parse_exclusive_syntax(p, parse_scss_function, |p, marker| { scss_only_syntax_error(p, "SCSS qualified function names", marker.range(p)) }) + } else if is_at_scss_interpolated_function(p) { + CssSyntaxFeatures::Scss.parse_exclusive_syntax( + p, + parse_scss_interpolated_function_or_value, + |p, marker| { + scss_only_syntax_error(p, "SCSS interpolated function names", marker.range(p)) + }, + ) } else { parse_function(p) } diff --git a/crates/biome_css_parser/src/token_source.rs b/crates/biome_css_parser/src/token_source.rs index 5c6796732ccc..cd66c3fd0f2e 100644 --- a/crates/biome_css_parser/src/token_source.rs +++ b/crates/biome_css_parser/src/token_source.rs @@ -96,6 +96,19 @@ impl<'src> CssTokenSource<'src> { self.lexer.lexer().has_pending_scss_string_start() } + /// Delegates to the lexer-owned `CssScanCursor` helper without exposing + /// cursor construction or current-token offset plumbing at the + /// token-source layer. + /// + /// The token source derives the start offset from its current buffered + /// token range instead of the underlying lexer's state because the + /// buffered lexer may already be positioned ahead of the parser's current + /// token. + pub(crate) fn is_at_scss_interpolated_function(&self) -> bool { + let start = usize::from(self.current_range().start()); + self.lexer.lexer().is_at_scss_interpolated_function(start) + } + #[inline] fn effective_context(&self, context: CssLexContext) -> CssLexContext { match ( diff --git a/crates/biome_css_parser/tests/css_test_suite/error/scss/value/url-interpolated-function-name.scss b/crates/biome_css_parser/tests/css_test_suite/error/scss/value/url-interpolated-function-name.scss new file mode 100644 index 000000000000..acb4d6f76471 --- /dev/null +++ b/crates/biome_css_parser/tests/css_test_suite/error/scss/value/url-interpolated-function-name.scss @@ -0,0 +1,11 @@ +div { + // Plain `(` branch: regular function heads inside `url(...)` should still + // parse as modifier functions even when recovery is needed. + plain-branch-missing-close: url(join-url("..", "image.png"); + + // UrlBody branch: malformed interpolation inside the function head should + // still preserve the interpolated-function shape for recovery. + interpolated-start-missing-curly: url(#{name(bar)); + interpolated-mid-broken-interpolation: url(foo#{1 + }(bar)); + interpolated-mid-missing-curly: url(foo#{1 + 1(bar)); +} diff --git a/crates/biome_css_parser/tests/css_test_suite/error/scss/value/url-interpolated-function-name.scss.snap b/crates/biome_css_parser/tests/css_test_suite/error/scss/value/url-interpolated-function-name.scss.snap new file mode 100644 index 000000000000..9d28caedddca --- /dev/null +++ b/crates/biome_css_parser/tests/css_test_suite/error/scss/value/url-interpolated-function-name.scss.snap @@ -0,0 +1,431 @@ +--- +source: crates/biome_css_parser/tests/spec_test.rs +assertion_line: 208 +expression: snapshot +--- + +## Input + +```css +div { + // Plain `(` branch: regular function heads inside `url(...)` should still + // parse as modifier functions even when recovery is needed. + plain-branch-missing-close: url(join-url("..", "image.png"); + + // UrlBody branch: malformed interpolation inside the function head should + // still preserve the interpolated-function shape for recovery. + interpolated-start-missing-curly: url(#{name(bar)); + interpolated-mid-broken-interpolation: url(foo#{1 + }(bar)); + interpolated-mid-missing-curly: url(foo#{1 + 1(bar)); +} + +``` + + +## AST + +``` +CssRoot { + bom_token: missing (optional), + items: CssRootItemList [ + CssQualifiedRule { + prelude: CssSelectorList [ + CssCompoundSelector { + nesting_selectors: CssNestedSelectorList [], + simple_selector: CssTypeSelector { + namespace: missing (optional), + ident: CssIdentifier { + value_token: IDENT@0..4 "div" [] [Whitespace(" ")], + }, + }, + sub_selectors: CssSubSelectorList [], + }, + ], + block: CssDeclarationOrRuleBlock { + l_curly_token: L_CURLY@4..5 "{" [] [], + items: CssDeclarationOrRuleList [ + CssDeclarationWithSemicolon { + declaration: CssDeclaration { + property: CssGenericProperty { + name: CssIdentifier { + value_token: IDENT@5..174 "plain-branch-missing-close" [Newline("\n"), Whitespace(" "), Comments("// Plain `(` branch: ..."), Newline("\n"), Whitespace(" "), Comments("// parse as modifier ..."), Newline("\n"), Whitespace(" ")] [], + }, + colon_token: COLON@174..176 ":" [] [Whitespace(" ")], + value: ScssExpression { + items: ScssExpressionItemList [ + CssUrlFunction { + name: URL_KW@176..179 "url" [] [], + l_paren_token: L_PAREN@179..180 "(" [] [], + value: missing (optional), + modifiers: CssUrlModifierList [ + CssFunction { + name: CssIdentifier { + value_token: IDENT@180..188 "join-url" [] [], + }, + l_paren_token: L_PAREN@188..189 "(" [] [], + items: CssParameterList [ + ScssExpression { + items: ScssExpressionItemList [ + CssString { + value_token: CSS_STRING_LITERAL@189..193 "\"..\"" [] [], + }, + ], + }, + COMMA@193..195 "," [] [Whitespace(" ")], + ScssExpression { + items: ScssExpressionItemList [ + CssString { + value_token: CSS_STRING_LITERAL@195..206 "\"image.png\"" [] [], + }, + ], + }, + ], + r_paren_token: R_PAREN@206..207 ")" [] [], + }, + ], + r_paren_token: missing (required), + }, + ], + }, + }, + important: missing (optional), + }, + semicolon_token: SEMICOLON@207..208 ";" [] [], + }, + CssDeclarationWithSemicolon { + declaration: CssDeclaration { + property: CssGenericProperty { + name: CssIdentifier { + value_token: IDENT@208..387 "interpolated-start-missing-curly" [Newline("\n"), Newline("\n"), Whitespace(" "), Comments("// UrlBody branch: ma ..."), Newline("\n"), Whitespace(" "), Comments("// still preserve the ..."), Newline("\n"), Whitespace(" ")] [], + }, + colon_token: COLON@387..389 ":" [] [Whitespace(" ")], + value: ScssExpression { + items: ScssExpressionItemList [ + CssUrlFunction { + name: URL_KW@389..392 "url" [] [], + l_paren_token: L_PAREN@392..393 "(" [] [], + value: CssUrlValueRaw { + value_token: CSS_URL_VALUE_RAW_LITERAL@393..403 "#{name(bar" [] [], + }, + modifiers: CssUrlModifierList [], + r_paren_token: R_PAREN@403..404 ")" [] [], + }, + ], + }, + }, + important: missing (optional), + }, + semicolon_token: missing (optional), + }, + CssBogus { + items: [ + R_PAREN@404..405 ")" [] [], + ], + }, + CssEmptyDeclaration { + semicolon_token: SEMICOLON@405..406 ";" [] [], + }, + CssDeclarationWithSemicolon { + declaration: CssDeclaration { + property: CssGenericProperty { + name: CssIdentifier { + value_token: IDENT@406..446 "interpolated-mid-broken-interpolation" [Newline("\n"), Whitespace(" ")] [], + }, + colon_token: COLON@446..448 ":" [] [Whitespace(" ")], + value: ScssExpression { + items: ScssExpressionItemList [ + CssUrlFunction { + name: URL_KW@448..451 "url" [] [], + l_paren_token: L_PAREN@451..452 "(" [] [], + value: missing (optional), + modifiers: CssUrlModifierList [ + CssFunction { + name: ScssInterpolatedIdentifier { + items: ScssInterpolatedIdentifierPartList [ + CssIdentifier { + value_token: IDENT@452..455 "foo" [] [], + }, + ScssInterpolation { + hash_token: HASH@455..456 "#" [] [], + l_curly_token: L_CURLY@456..457 "{" [] [], + value: ScssExpression { + items: ScssExpressionItemList [ + ScssBinaryExpression { + left: CssNumber { + value_token: CSS_NUMBER_LITERAL@457..459 "1" [] [Whitespace(" ")], + }, + operator: PLUS@459..461 "+" [] [Whitespace(" ")], + right: missing (required), + }, + ], + }, + r_curly_token: R_CURLY@461..462 "}" [] [], + }, + ], + }, + l_paren_token: L_PAREN@462..463 "(" [] [], + items: CssParameterList [ + ScssExpression { + items: ScssExpressionItemList [ + CssIdentifier { + value_token: IDENT@463..466 "bar" [] [], + }, + ], + }, + ], + r_paren_token: R_PAREN@466..467 ")" [] [], + }, + ], + r_paren_token: R_PAREN@467..468 ")" [] [], + }, + ], + }, + }, + important: missing (optional), + }, + semicolon_token: SEMICOLON@468..469 ";" [] [], + }, + CssDeclarationWithSemicolon { + declaration: CssDeclaration { + property: CssGenericProperty { + name: CssIdentifier { + value_token: IDENT@469..502 "interpolated-mid-missing-curly" [Newline("\n"), Whitespace(" ")] [], + }, + colon_token: COLON@502..504 ":" [] [Whitespace(" ")], + value: ScssExpression { + items: ScssExpressionItemList [ + CssUrlFunction { + name: URL_KW@504..507 "url" [] [], + l_paren_token: L_PAREN@507..508 "(" [] [], + value: CssUrlValueRaw { + value_token: CSS_URL_VALUE_RAW_LITERAL@508..522 "foo#{1 + 1(bar" [] [], + }, + modifiers: CssUrlModifierList [], + r_paren_token: R_PAREN@522..523 ")" [] [], + }, + ], + }, + }, + important: missing (optional), + }, + semicolon_token: missing (optional), + }, + CssBogus { + items: [ + R_PAREN@523..524 ")" [] [], + ], + }, + CssEmptyDeclaration { + semicolon_token: SEMICOLON@524..525 ";" [] [], + }, + ], + r_curly_token: R_CURLY@525..527 "}" [Newline("\n")] [], + }, + }, + ], + eof_token: EOF@527..528 "" [Newline("\n")] [], +} +``` + +## CST + +``` +0: CSS_ROOT@0..528 + 0: (empty) + 1: CSS_ROOT_ITEM_LIST@0..527 + 0: CSS_QUALIFIED_RULE@0..527 + 0: CSS_SELECTOR_LIST@0..4 + 0: CSS_COMPOUND_SELECTOR@0..4 + 0: CSS_NESTED_SELECTOR_LIST@0..0 + 1: CSS_TYPE_SELECTOR@0..4 + 0: (empty) + 1: CSS_IDENTIFIER@0..4 + 0: IDENT@0..4 "div" [] [Whitespace(" ")] + 2: CSS_SUB_SELECTOR_LIST@4..4 + 1: CSS_DECLARATION_OR_RULE_BLOCK@4..527 + 0: L_CURLY@4..5 "{" [] [] + 1: CSS_DECLARATION_OR_RULE_LIST@5..525 + 0: CSS_DECLARATION_WITH_SEMICOLON@5..208 + 0: CSS_DECLARATION@5..207 + 0: CSS_GENERIC_PROPERTY@5..207 + 0: CSS_IDENTIFIER@5..174 + 0: IDENT@5..174 "plain-branch-missing-close" [Newline("\n"), Whitespace(" "), Comments("// Plain `(` branch: ..."), Newline("\n"), Whitespace(" "), Comments("// parse as modifier ..."), Newline("\n"), Whitespace(" ")] [] + 1: COLON@174..176 ":" [] [Whitespace(" ")] + 2: SCSS_EXPRESSION@176..207 + 0: SCSS_EXPRESSION_ITEM_LIST@176..207 + 0: CSS_URL_FUNCTION@176..207 + 0: URL_KW@176..179 "url" [] [] + 1: L_PAREN@179..180 "(" [] [] + 2: (empty) + 3: CSS_URL_MODIFIER_LIST@180..207 + 0: CSS_FUNCTION@180..207 + 0: CSS_IDENTIFIER@180..188 + 0: IDENT@180..188 "join-url" [] [] + 1: L_PAREN@188..189 "(" [] [] + 2: CSS_PARAMETER_LIST@189..206 + 0: SCSS_EXPRESSION@189..193 + 0: SCSS_EXPRESSION_ITEM_LIST@189..193 + 0: CSS_STRING@189..193 + 0: CSS_STRING_LITERAL@189..193 "\"..\"" [] [] + 1: COMMA@193..195 "," [] [Whitespace(" ")] + 2: SCSS_EXPRESSION@195..206 + 0: SCSS_EXPRESSION_ITEM_LIST@195..206 + 0: CSS_STRING@195..206 + 0: CSS_STRING_LITERAL@195..206 "\"image.png\"" [] [] + 3: R_PAREN@206..207 ")" [] [] + 4: (empty) + 1: (empty) + 1: SEMICOLON@207..208 ";" [] [] + 1: CSS_DECLARATION_WITH_SEMICOLON@208..404 + 0: CSS_DECLARATION@208..404 + 0: CSS_GENERIC_PROPERTY@208..404 + 0: CSS_IDENTIFIER@208..387 + 0: IDENT@208..387 "interpolated-start-missing-curly" [Newline("\n"), Newline("\n"), Whitespace(" "), Comments("// UrlBody branch: ma ..."), Newline("\n"), Whitespace(" "), Comments("// still preserve the ..."), Newline("\n"), Whitespace(" ")] [] + 1: COLON@387..389 ":" [] [Whitespace(" ")] + 2: SCSS_EXPRESSION@389..404 + 0: SCSS_EXPRESSION_ITEM_LIST@389..404 + 0: CSS_URL_FUNCTION@389..404 + 0: URL_KW@389..392 "url" [] [] + 1: L_PAREN@392..393 "(" [] [] + 2: CSS_URL_VALUE_RAW@393..403 + 0: CSS_URL_VALUE_RAW_LITERAL@393..403 "#{name(bar" [] [] + 3: CSS_URL_MODIFIER_LIST@403..403 + 4: R_PAREN@403..404 ")" [] [] + 1: (empty) + 1: (empty) + 2: CSS_BOGUS@404..405 + 0: R_PAREN@404..405 ")" [] [] + 3: CSS_EMPTY_DECLARATION@405..406 + 0: SEMICOLON@405..406 ";" [] [] + 4: CSS_DECLARATION_WITH_SEMICOLON@406..469 + 0: CSS_DECLARATION@406..468 + 0: CSS_GENERIC_PROPERTY@406..468 + 0: CSS_IDENTIFIER@406..446 + 0: IDENT@406..446 "interpolated-mid-broken-interpolation" [Newline("\n"), Whitespace(" ")] [] + 1: COLON@446..448 ":" [] [Whitespace(" ")] + 2: SCSS_EXPRESSION@448..468 + 0: SCSS_EXPRESSION_ITEM_LIST@448..468 + 0: CSS_URL_FUNCTION@448..468 + 0: URL_KW@448..451 "url" [] [] + 1: L_PAREN@451..452 "(" [] [] + 2: (empty) + 3: CSS_URL_MODIFIER_LIST@452..467 + 0: CSS_FUNCTION@452..467 + 0: SCSS_INTERPOLATED_IDENTIFIER@452..462 + 0: SCSS_INTERPOLATED_IDENTIFIER_PART_LIST@452..462 + 0: CSS_IDENTIFIER@452..455 + 0: IDENT@452..455 "foo" [] [] + 1: SCSS_INTERPOLATION@455..462 + 0: HASH@455..456 "#" [] [] + 1: L_CURLY@456..457 "{" [] [] + 2: SCSS_EXPRESSION@457..461 + 0: SCSS_EXPRESSION_ITEM_LIST@457..461 + 0: SCSS_BINARY_EXPRESSION@457..461 + 0: CSS_NUMBER@457..459 + 0: CSS_NUMBER_LITERAL@457..459 "1" [] [Whitespace(" ")] + 1: PLUS@459..461 "+" [] [Whitespace(" ")] + 2: (empty) + 3: R_CURLY@461..462 "}" [] [] + 1: L_PAREN@462..463 "(" [] [] + 2: CSS_PARAMETER_LIST@463..466 + 0: SCSS_EXPRESSION@463..466 + 0: SCSS_EXPRESSION_ITEM_LIST@463..466 + 0: CSS_IDENTIFIER@463..466 + 0: IDENT@463..466 "bar" [] [] + 3: R_PAREN@466..467 ")" [] [] + 4: R_PAREN@467..468 ")" [] [] + 1: (empty) + 1: SEMICOLON@468..469 ";" [] [] + 5: CSS_DECLARATION_WITH_SEMICOLON@469..523 + 0: CSS_DECLARATION@469..523 + 0: CSS_GENERIC_PROPERTY@469..523 + 0: CSS_IDENTIFIER@469..502 + 0: IDENT@469..502 "interpolated-mid-missing-curly" [Newline("\n"), Whitespace(" ")] [] + 1: COLON@502..504 ":" [] [Whitespace(" ")] + 2: SCSS_EXPRESSION@504..523 + 0: SCSS_EXPRESSION_ITEM_LIST@504..523 + 0: CSS_URL_FUNCTION@504..523 + 0: URL_KW@504..507 "url" [] [] + 1: L_PAREN@507..508 "(" [] [] + 2: CSS_URL_VALUE_RAW@508..522 + 0: CSS_URL_VALUE_RAW_LITERAL@508..522 "foo#{1 + 1(bar" [] [] + 3: CSS_URL_MODIFIER_LIST@522..522 + 4: R_PAREN@522..523 ")" [] [] + 1: (empty) + 1: (empty) + 6: CSS_BOGUS@523..524 + 0: R_PAREN@523..524 ")" [] [] + 7: CSS_EMPTY_DECLARATION@524..525 + 0: SEMICOLON@524..525 ";" [] [] + 2: R_CURLY@525..527 "}" [Newline("\n")] [] + 2: EOF@527..528 "" [Newline("\n")] [] + +``` + +## Diagnostics + +``` +url-interpolated-function-name.scss:4:62 parse ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Unexpected value or character. + + 2 │ // Plain `(` branch: regular function heads inside `url(...)` should still + 3 │ // parse as modifier functions even when recovery is needed. + > 4 │ plain-branch-missing-close: url(join-url("..", "image.png"); + │ ^ + 5 │ + 6 │ // UrlBody branch: malformed interpolation inside the function head should + + i Expected one of: + + - function + - identifier + +url-interpolated-function-name.scss:8:52 parse ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × expected `;` but instead found `)` + + 6 │ // UrlBody branch: malformed interpolation inside the function head should + 7 │ // still preserve the interpolated-function shape for recovery. + > 8 │ interpolated-start-missing-curly: url(#{name(bar)); + │ ^ + 9 │ interpolated-mid-broken-interpolation: url(foo#{1 + }(bar)); + 10 │ interpolated-mid-missing-curly: url(foo#{1 + 1(bar)); + + i Remove ) + +url-interpolated-function-name.scss:9:55 parse ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Unexpected value or character. + + 7 │ // still preserve the interpolated-function shape for recovery. + 8 │ interpolated-start-missing-curly: url(#{name(bar)); + > 9 │ interpolated-mid-broken-interpolation: url(foo#{1 + }(bar)); + │ ^ + 10 │ interpolated-mid-missing-curly: url(foo#{1 + 1(bar)); + 11 │ } + + i Expected one of: + + - identifier + - string + - number + - dimension + - ratio + - custom property + - function + +url-interpolated-function-name.scss:10:54 parse ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × expected `;` but instead found `)` + + 8 │ interpolated-start-missing-curly: url(#{name(bar)); + 9 │ interpolated-mid-broken-interpolation: url(foo#{1 + }(bar)); + > 10 │ interpolated-mid-missing-curly: url(foo#{1 + 1(bar)); + │ ^ + 11 │ } + 12 │ + + i Remove ) + +``` diff --git a/crates/biome_css_parser/tests/css_test_suite/ok/scss/value/interpolated-minus-line-break.scss b/crates/biome_css_parser/tests/css_test_suite/ok/scss/value/interpolated-minus-line-break.scss new file mode 100644 index 000000000000..c76035986b04 --- /dev/null +++ b/crates/biome_css_parser/tests/css_test_suite/ok/scss/value/interpolated-minus-line-break.scss @@ -0,0 +1,4 @@ +a { + value: #{$a}- + #{$b}; +} diff --git a/crates/biome_css_parser/tests/css_test_suite/ok/scss/value/interpolated-minus-line-break.scss.snap b/crates/biome_css_parser/tests/css_test_suite/ok/scss/value/interpolated-minus-line-break.scss.snap new file mode 100644 index 000000000000..b4a61086e2fb --- /dev/null +++ b/crates/biome_css_parser/tests/css_test_suite/ok/scss/value/interpolated-minus-line-break.scss.snap @@ -0,0 +1,150 @@ +--- +source: crates/biome_css_parser/tests/spec_test.rs +expression: snapshot +--- + +## Input + +```css +a { + value: #{$a}- + #{$b}; +} + +``` + + +## AST + +``` +CssRoot { + bom_token: missing (optional), + items: CssRootItemList [ + CssQualifiedRule { + prelude: CssSelectorList [ + CssCompoundSelector { + nesting_selectors: CssNestedSelectorList [], + simple_selector: CssTypeSelector { + namespace: missing (optional), + ident: CssIdentifier { + value_token: IDENT@0..2 "a" [] [Whitespace(" ")], + }, + }, + sub_selectors: CssSubSelectorList [], + }, + ], + block: CssDeclarationOrRuleBlock { + l_curly_token: L_CURLY@2..3 "{" [] [], + items: CssDeclarationOrRuleList [ + CssDeclarationWithSemicolon { + declaration: CssDeclaration { + property: CssGenericProperty { + name: CssIdentifier { + value_token: IDENT@3..11 "value" [Newline("\n"), Whitespace(" ")] [], + }, + colon_token: COLON@11..13 ":" [] [Whitespace(" ")], + value: ScssExpression { + items: ScssExpressionItemList [ + ScssBinaryExpression { + left: ScssInterpolation { + hash_token: HASH@13..14 "#" [] [], + l_curly_token: L_CURLY@14..15 "{" [] [], + value: ScssExpression { + items: ScssExpressionItemList [ + ScssIdentifier { + dollar_token: DOLLAR@15..16 "$" [] [], + name: CssIdentifier { + value_token: IDENT@16..17 "a" [] [], + }, + }, + ], + }, + r_curly_token: R_CURLY@17..18 "}" [] [], + }, + operator: MINUS@18..19 "-" [] [], + right: ScssInterpolation { + hash_token: HASH@19..25 "#" [Newline("\n"), Whitespace(" ")] [], + l_curly_token: L_CURLY@25..26 "{" [] [], + value: ScssExpression { + items: ScssExpressionItemList [ + ScssIdentifier { + dollar_token: DOLLAR@26..27 "$" [] [], + name: CssIdentifier { + value_token: IDENT@27..28 "b" [] [], + }, + }, + ], + }, + r_curly_token: R_CURLY@28..29 "}" [] [], + }, + }, + ], + }, + }, + important: missing (optional), + }, + semicolon_token: SEMICOLON@29..30 ";" [] [], + }, + ], + r_curly_token: R_CURLY@30..32 "}" [Newline("\n")] [], + }, + }, + ], + eof_token: EOF@32..33 "" [Newline("\n")] [], +} +``` + +## CST + +``` +0: CSS_ROOT@0..33 + 0: (empty) + 1: CSS_ROOT_ITEM_LIST@0..32 + 0: CSS_QUALIFIED_RULE@0..32 + 0: CSS_SELECTOR_LIST@0..2 + 0: CSS_COMPOUND_SELECTOR@0..2 + 0: CSS_NESTED_SELECTOR_LIST@0..0 + 1: CSS_TYPE_SELECTOR@0..2 + 0: (empty) + 1: CSS_IDENTIFIER@0..2 + 0: IDENT@0..2 "a" [] [Whitespace(" ")] + 2: CSS_SUB_SELECTOR_LIST@2..2 + 1: CSS_DECLARATION_OR_RULE_BLOCK@2..32 + 0: L_CURLY@2..3 "{" [] [] + 1: CSS_DECLARATION_OR_RULE_LIST@3..30 + 0: CSS_DECLARATION_WITH_SEMICOLON@3..30 + 0: CSS_DECLARATION@3..29 + 0: CSS_GENERIC_PROPERTY@3..29 + 0: CSS_IDENTIFIER@3..11 + 0: IDENT@3..11 "value" [Newline("\n"), Whitespace(" ")] [] + 1: COLON@11..13 ":" [] [Whitespace(" ")] + 2: SCSS_EXPRESSION@13..29 + 0: SCSS_EXPRESSION_ITEM_LIST@13..29 + 0: SCSS_BINARY_EXPRESSION@13..29 + 0: SCSS_INTERPOLATION@13..18 + 0: HASH@13..14 "#" [] [] + 1: L_CURLY@14..15 "{" [] [] + 2: SCSS_EXPRESSION@15..17 + 0: SCSS_EXPRESSION_ITEM_LIST@15..17 + 0: SCSS_IDENTIFIER@15..17 + 0: DOLLAR@15..16 "$" [] [] + 1: CSS_IDENTIFIER@16..17 + 0: IDENT@16..17 "a" [] [] + 3: R_CURLY@17..18 "}" [] [] + 1: MINUS@18..19 "-" [] [] + 2: SCSS_INTERPOLATION@19..29 + 0: HASH@19..25 "#" [Newline("\n"), Whitespace(" ")] [] + 1: L_CURLY@25..26 "{" [] [] + 2: SCSS_EXPRESSION@26..28 + 0: SCSS_EXPRESSION_ITEM_LIST@26..28 + 0: SCSS_IDENTIFIER@26..28 + 0: DOLLAR@26..27 "$" [] [] + 1: CSS_IDENTIFIER@27..28 + 0: IDENT@27..28 "b" [] [] + 3: R_CURLY@28..29 "}" [] [] + 1: (empty) + 1: SEMICOLON@29..30 ";" [] [] + 2: R_CURLY@30..32 "}" [Newline("\n")] [] + 2: EOF@32..33 "" [Newline("\n")] [] + +``` diff --git a/crates/biome_css_parser/tests/css_test_suite/ok/scss/value/url.scss b/crates/biome_css_parser/tests/css_test_suite/ok/scss/value/url.scss index 008f118a7cb5..9cbe626004f0 100644 --- a/crates/biome_css_parser/tests/css_test_suite/ok/scss/value/url.scss +++ b/crates/biome_css_parser/tests/css_test_suite/ok/scss/value/url.scss @@ -15,9 +15,14 @@ div { // TODO(interpolation support): trailing-spaces: url(a#{b}c ); expr1: url(join-url("..", "image.png")); expr2: url(1 + 2); + expr4: url(foo#{1 + 1}(bar)); + expr5: url(#{name}(bar)); + expr6: url(#{prefix}-#{suffix}(bar)); modifier: url("a.png" color.adjust($c, $lightness: 10%)); format: url("a.png" format(color.adjust($c, $lightness: 10%))); - // TODO(interpolation support): expr3: url(a#{b}"c"); + modifier-interpolated-mid: url("a.png" foo#{1 + 1}(bar)); + modifier-interpolated-start: url("a.png" #{format}(woff2)); + expr3: url(a#{b}"c"); } // TODO(interpolation support): @import url(//fonts.googleapis.com/css?family=#{get-font-family( diff --git a/crates/biome_css_parser/tests/css_test_suite/ok/scss/value/url.scss.snap b/crates/biome_css_parser/tests/css_test_suite/ok/scss/value/url.scss.snap index ab49d34c494e..0cd6e8495d44 100644 --- a/crates/biome_css_parser/tests/css_test_suite/ok/scss/value/url.scss.snap +++ b/crates/biome_css_parser/tests/css_test_suite/ok/scss/value/url.scss.snap @@ -1,5 +1,6 @@ --- source: crates/biome_css_parser/tests/spec_test.rs +assertion_line: 208 expression: snapshot --- @@ -23,9 +24,14 @@ div { // TODO(interpolation support): trailing-spaces: url(a#{b}c ); expr1: url(join-url("..", "image.png")); expr2: url(1 + 2); + expr4: url(foo#{1 + 1}(bar)); + expr5: url(#{name}(bar)); + expr6: url(#{prefix}-#{suffix}(bar)); modifier: url("a.png" color.adjust($c, $lightness: 10%)); format: url("a.png" format(color.adjust($c, $lightness: 10%))); - // TODO(interpolation support): expr3: url(a#{b}"c"); + modifier-interpolated-mid: url("a.png" foo#{1 + 1}(bar)); + modifier-interpolated-start: url("a.png" #{format}(woff2)); + expr3: url(a#{b}"c"); } // TODO(interpolation support): @import url(//fonts.googleapis.com/css?family=#{get-font-family( @@ -459,56 +465,239 @@ CssRoot { declaration: CssDeclaration { property: CssGenericProperty { name: CssIdentifier { - value_token: IDENT@1880..1891 "modifier" [Newline("\n"), Whitespace(" ")] [], + value_token: IDENT@1880..1888 "expr4" [Newline("\n"), Whitespace(" ")] [], }, - colon_token: COLON@1891..1893 ":" [] [Whitespace(" ")], + colon_token: COLON@1888..1890 ":" [] [Whitespace(" ")], value: ScssExpression { items: ScssExpressionItemList [ CssUrlFunction { - name: URL_KW@1893..1896 "url" [] [], - l_paren_token: L_PAREN@1896..1897 "(" [] [], + name: URL_KW@1890..1893 "url" [] [], + l_paren_token: L_PAREN@1893..1894 "(" [] [], + value: missing (optional), + modifiers: CssUrlModifierList [ + CssFunction { + name: ScssInterpolatedIdentifier { + items: ScssInterpolatedIdentifierPartList [ + CssIdentifier { + value_token: IDENT@1894..1897 "foo" [] [], + }, + ScssInterpolation { + hash_token: HASH@1897..1898 "#" [] [], + l_curly_token: L_CURLY@1898..1899 "{" [] [], + value: ScssExpression { + items: ScssExpressionItemList [ + ScssBinaryExpression { + left: CssNumber { + value_token: CSS_NUMBER_LITERAL@1899..1901 "1" [] [Whitespace(" ")], + }, + operator: PLUS@1901..1903 "+" [] [Whitespace(" ")], + right: CssNumber { + value_token: CSS_NUMBER_LITERAL@1903..1904 "1" [] [], + }, + }, + ], + }, + r_curly_token: R_CURLY@1904..1905 "}" [] [], + }, + ], + }, + l_paren_token: L_PAREN@1905..1906 "(" [] [], + items: CssParameterList [ + ScssExpression { + items: ScssExpressionItemList [ + CssIdentifier { + value_token: IDENT@1906..1909 "bar" [] [], + }, + ], + }, + ], + r_paren_token: R_PAREN@1909..1910 ")" [] [], + }, + ], + r_paren_token: R_PAREN@1910..1911 ")" [] [], + }, + ], + }, + }, + important: missing (optional), + }, + semicolon_token: SEMICOLON@1911..1912 ";" [] [], + }, + CssDeclarationWithSemicolon { + declaration: CssDeclaration { + property: CssGenericProperty { + name: CssIdentifier { + value_token: IDENT@1912..1920 "expr5" [Newline("\n"), Whitespace(" ")] [], + }, + colon_token: COLON@1920..1922 ":" [] [Whitespace(" ")], + value: ScssExpression { + items: ScssExpressionItemList [ + CssUrlFunction { + name: URL_KW@1922..1925 "url" [] [], + l_paren_token: L_PAREN@1925..1926 "(" [] [], + value: missing (optional), + modifiers: CssUrlModifierList [ + CssFunction { + name: ScssInterpolatedIdentifier { + items: ScssInterpolatedIdentifierPartList [ + ScssInterpolation { + hash_token: HASH@1926..1927 "#" [] [], + l_curly_token: L_CURLY@1927..1928 "{" [] [], + value: ScssExpression { + items: ScssExpressionItemList [ + CssIdentifier { + value_token: IDENT@1928..1932 "name" [] [], + }, + ], + }, + r_curly_token: R_CURLY@1932..1933 "}" [] [], + }, + ], + }, + l_paren_token: L_PAREN@1933..1934 "(" [] [], + items: CssParameterList [ + ScssExpression { + items: ScssExpressionItemList [ + CssIdentifier { + value_token: IDENT@1934..1937 "bar" [] [], + }, + ], + }, + ], + r_paren_token: R_PAREN@1937..1938 ")" [] [], + }, + ], + r_paren_token: R_PAREN@1938..1939 ")" [] [], + }, + ], + }, + }, + important: missing (optional), + }, + semicolon_token: SEMICOLON@1939..1940 ";" [] [], + }, + CssDeclarationWithSemicolon { + declaration: CssDeclaration { + property: CssGenericProperty { + name: CssIdentifier { + value_token: IDENT@1940..1948 "expr6" [Newline("\n"), Whitespace(" ")] [], + }, + colon_token: COLON@1948..1950 ":" [] [Whitespace(" ")], + value: ScssExpression { + items: ScssExpressionItemList [ + CssUrlFunction { + name: URL_KW@1950..1953 "url" [] [], + l_paren_token: L_PAREN@1953..1954 "(" [] [], + value: missing (optional), + modifiers: CssUrlModifierList [ + CssFunction { + name: ScssInterpolatedIdentifier { + items: ScssInterpolatedIdentifierPartList [ + ScssInterpolation { + hash_token: HASH@1954..1955 "#" [] [], + l_curly_token: L_CURLY@1955..1956 "{" [] [], + value: ScssExpression { + items: ScssExpressionItemList [ + CssIdentifier { + value_token: IDENT@1956..1962 "prefix" [] [], + }, + ], + }, + r_curly_token: R_CURLY@1962..1963 "}" [] [], + }, + ScssInterpolatedIdentifierHyphen { + minus_token: MINUS@1963..1964 "-" [] [], + }, + ScssInterpolation { + hash_token: HASH@1964..1965 "#" [] [], + l_curly_token: L_CURLY@1965..1966 "{" [] [], + value: ScssExpression { + items: ScssExpressionItemList [ + CssIdentifier { + value_token: IDENT@1966..1972 "suffix" [] [], + }, + ], + }, + r_curly_token: R_CURLY@1972..1973 "}" [] [], + }, + ], + }, + l_paren_token: L_PAREN@1973..1974 "(" [] [], + items: CssParameterList [ + ScssExpression { + items: ScssExpressionItemList [ + CssIdentifier { + value_token: IDENT@1974..1977 "bar" [] [], + }, + ], + }, + ], + r_paren_token: R_PAREN@1977..1978 ")" [] [], + }, + ], + r_paren_token: R_PAREN@1978..1979 ")" [] [], + }, + ], + }, + }, + important: missing (optional), + }, + semicolon_token: SEMICOLON@1979..1980 ";" [] [], + }, + CssDeclarationWithSemicolon { + declaration: CssDeclaration { + property: CssGenericProperty { + name: CssIdentifier { + value_token: IDENT@1980..1991 "modifier" [Newline("\n"), Whitespace(" ")] [], + }, + colon_token: COLON@1991..1993 ":" [] [Whitespace(" ")], + value: ScssExpression { + items: ScssExpressionItemList [ + CssUrlFunction { + name: URL_KW@1993..1996 "url" [] [], + l_paren_token: L_PAREN@1996..1997 "(" [] [], value: CssString { - value_token: CSS_STRING_LITERAL@1897..1905 "\"a.png\"" [] [Whitespace(" ")], + value_token: CSS_STRING_LITERAL@1997..2005 "\"a.png\"" [] [Whitespace(" ")], }, modifiers: CssUrlModifierList [ CssFunction { name: ScssQualifiedName { module: CssIdentifier { - value_token: IDENT@1905..1910 "color" [] [], + value_token: IDENT@2005..2010 "color" [] [], }, - dot_token: DOT@1910..1911 "." [] [], + dot_token: DOT@2010..2011 "." [] [], member: CssIdentifier { - value_token: IDENT@1911..1917 "adjust" [] [], + value_token: IDENT@2011..2017 "adjust" [] [], }, }, - l_paren_token: L_PAREN@1917..1918 "(" [] [], + l_paren_token: L_PAREN@2017..2018 "(" [] [], items: CssParameterList [ ScssExpression { items: ScssExpressionItemList [ ScssIdentifier { - dollar_token: DOLLAR@1918..1919 "$" [] [], + dollar_token: DOLLAR@2018..2019 "$" [] [], name: CssIdentifier { - value_token: IDENT@1919..1920 "c" [] [], + value_token: IDENT@2019..2020 "c" [] [], }, }, ], }, - COMMA@1920..1922 "," [] [Whitespace(" ")], + COMMA@2020..2022 "," [] [Whitespace(" ")], ScssExpression { items: ScssExpressionItemList [ ScssKeywordArgument { name: ScssIdentifier { - dollar_token: DOLLAR@1922..1923 "$" [] [], + dollar_token: DOLLAR@2022..2023 "$" [] [], name: CssIdentifier { - value_token: IDENT@1923..1932 "lightness" [] [], + value_token: IDENT@2023..2032 "lightness" [] [], }, }, - colon_token: COLON@1932..1934 ":" [] [Whitespace(" ")], + colon_token: COLON@2032..2034 ":" [] [Whitespace(" ")], value: ScssExpression { items: ScssExpressionItemList [ CssPercentage { - value_token: CSS_NUMBER_LITERAL@1934..1936 "10" [] [], - percent_token: PERCENT@1936..1937 "%" [] [], + value_token: CSS_NUMBER_LITERAL@2034..2036 "10" [] [], + percent_token: PERCENT@2036..2037 "%" [] [], }, ], }, @@ -516,80 +705,80 @@ CssRoot { ], }, ], - r_paren_token: R_PAREN@1937..1938 ")" [] [], + r_paren_token: R_PAREN@2037..2038 ")" [] [], }, ], - r_paren_token: R_PAREN@1938..1939 ")" [] [], + r_paren_token: R_PAREN@2038..2039 ")" [] [], }, ], }, }, important: missing (optional), }, - semicolon_token: SEMICOLON@1939..1940 ";" [] [], + semicolon_token: SEMICOLON@2039..2040 ";" [] [], }, CssDeclarationWithSemicolon { declaration: CssDeclaration { property: CssGenericProperty { name: CssIdentifier { - value_token: IDENT@1940..1949 "format" [Newline("\n"), Whitespace(" ")] [], + value_token: IDENT@2040..2049 "format" [Newline("\n"), Whitespace(" ")] [], }, - colon_token: COLON@1949..1951 ":" [] [Whitespace(" ")], + colon_token: COLON@2049..2051 ":" [] [Whitespace(" ")], value: ScssExpression { items: ScssExpressionItemList [ CssUrlFunction { - name: URL_KW@1951..1954 "url" [] [], - l_paren_token: L_PAREN@1954..1955 "(" [] [], + name: URL_KW@2051..2054 "url" [] [], + l_paren_token: L_PAREN@2054..2055 "(" [] [], value: CssString { - value_token: CSS_STRING_LITERAL@1955..1963 "\"a.png\"" [] [Whitespace(" ")], + value_token: CSS_STRING_LITERAL@2055..2063 "\"a.png\"" [] [Whitespace(" ")], }, modifiers: CssUrlModifierList [ CssFunction { name: CssIdentifier { - value_token: IDENT@1963..1969 "format" [] [], + value_token: IDENT@2063..2069 "format" [] [], }, - l_paren_token: L_PAREN@1969..1970 "(" [] [], + l_paren_token: L_PAREN@2069..2070 "(" [] [], items: CssParameterList [ ScssExpression { items: ScssExpressionItemList [ CssFunction { name: ScssQualifiedName { module: CssIdentifier { - value_token: IDENT@1970..1975 "color" [] [], + value_token: IDENT@2070..2075 "color" [] [], }, - dot_token: DOT@1975..1976 "." [] [], + dot_token: DOT@2075..2076 "." [] [], member: CssIdentifier { - value_token: IDENT@1976..1982 "adjust" [] [], + value_token: IDENT@2076..2082 "adjust" [] [], }, }, - l_paren_token: L_PAREN@1982..1983 "(" [] [], + l_paren_token: L_PAREN@2082..2083 "(" [] [], items: CssParameterList [ ScssExpression { items: ScssExpressionItemList [ ScssIdentifier { - dollar_token: DOLLAR@1983..1984 "$" [] [], + dollar_token: DOLLAR@2083..2084 "$" [] [], name: CssIdentifier { - value_token: IDENT@1984..1985 "c" [] [], + value_token: IDENT@2084..2085 "c" [] [], }, }, ], }, - COMMA@1985..1987 "," [] [Whitespace(" ")], + COMMA@2085..2087 "," [] [Whitespace(" ")], ScssExpression { items: ScssExpressionItemList [ ScssKeywordArgument { name: ScssIdentifier { - dollar_token: DOLLAR@1987..1988 "$" [] [], + dollar_token: DOLLAR@2087..2088 "$" [] [], name: CssIdentifier { - value_token: IDENT@1988..1997 "lightness" [] [], + value_token: IDENT@2088..2097 "lightness" [] [], }, }, - colon_token: COLON@1997..1999 ":" [] [Whitespace(" ")], + colon_token: COLON@2097..2099 ":" [] [Whitespace(" ")], value: ScssExpression { items: ScssExpressionItemList [ CssPercentage { - value_token: CSS_NUMBER_LITERAL@1999..2001 "10" [] [], - percent_token: PERCENT@2001..2002 "%" [] [], + value_token: CSS_NUMBER_LITERAL@2099..2101 "10" [] [], + percent_token: PERCENT@2101..2102 "%" [] [], }, ], }, @@ -597,39 +786,183 @@ CssRoot { ], }, ], - r_paren_token: R_PAREN@2002..2003 ")" [] [], + r_paren_token: R_PAREN@2102..2103 ")" [] [], + }, + ], + }, + ], + r_paren_token: R_PAREN@2103..2104 ")" [] [], + }, + ], + r_paren_token: R_PAREN@2104..2105 ")" [] [], + }, + ], + }, + }, + important: missing (optional), + }, + semicolon_token: SEMICOLON@2105..2106 ";" [] [], + }, + CssDeclarationWithSemicolon { + declaration: CssDeclaration { + property: CssGenericProperty { + name: CssIdentifier { + value_token: IDENT@2106..2134 "modifier-interpolated-mid" [Newline("\n"), Whitespace(" ")] [], + }, + colon_token: COLON@2134..2136 ":" [] [Whitespace(" ")], + value: ScssExpression { + items: ScssExpressionItemList [ + CssUrlFunction { + name: URL_KW@2136..2139 "url" [] [], + l_paren_token: L_PAREN@2139..2140 "(" [] [], + value: CssString { + value_token: CSS_STRING_LITERAL@2140..2148 "\"a.png\"" [] [Whitespace(" ")], + }, + modifiers: CssUrlModifierList [ + CssFunction { + name: ScssInterpolatedIdentifier { + items: ScssInterpolatedIdentifierPartList [ + CssIdentifier { + value_token: IDENT@2148..2151 "foo" [] [], + }, + ScssInterpolation { + hash_token: HASH@2151..2152 "#" [] [], + l_curly_token: L_CURLY@2152..2153 "{" [] [], + value: ScssExpression { + items: ScssExpressionItemList [ + ScssBinaryExpression { + left: CssNumber { + value_token: CSS_NUMBER_LITERAL@2153..2155 "1" [] [Whitespace(" ")], + }, + operator: PLUS@2155..2157 "+" [] [Whitespace(" ")], + right: CssNumber { + value_token: CSS_NUMBER_LITERAL@2157..2158 "1" [] [], + }, + }, + ], + }, + r_curly_token: R_CURLY@2158..2159 "}" [] [], + }, + ], + }, + l_paren_token: L_PAREN@2159..2160 "(" [] [], + items: CssParameterList [ + ScssExpression { + items: ScssExpressionItemList [ + CssIdentifier { + value_token: IDENT@2160..2163 "bar" [] [], + }, + ], + }, + ], + r_paren_token: R_PAREN@2163..2164 ")" [] [], + }, + ], + r_paren_token: R_PAREN@2164..2165 ")" [] [], + }, + ], + }, + }, + important: missing (optional), + }, + semicolon_token: SEMICOLON@2165..2166 ";" [] [], + }, + CssDeclarationWithSemicolon { + declaration: CssDeclaration { + property: CssGenericProperty { + name: CssIdentifier { + value_token: IDENT@2166..2196 "modifier-interpolated-start" [Newline("\n"), Whitespace(" ")] [], + }, + colon_token: COLON@2196..2198 ":" [] [Whitespace(" ")], + value: ScssExpression { + items: ScssExpressionItemList [ + CssUrlFunction { + name: URL_KW@2198..2201 "url" [] [], + l_paren_token: L_PAREN@2201..2202 "(" [] [], + value: CssString { + value_token: CSS_STRING_LITERAL@2202..2210 "\"a.png\"" [] [Whitespace(" ")], + }, + modifiers: CssUrlModifierList [ + CssFunction { + name: ScssInterpolatedIdentifier { + items: ScssInterpolatedIdentifierPartList [ + ScssInterpolation { + hash_token: HASH@2210..2211 "#" [] [], + l_curly_token: L_CURLY@2211..2212 "{" [] [], + value: ScssExpression { + items: ScssExpressionItemList [ + CssIdentifier { + value_token: IDENT@2212..2218 "format" [] [], + }, + ], + }, + r_curly_token: R_CURLY@2218..2219 "}" [] [], + }, + ], + }, + l_paren_token: L_PAREN@2219..2220 "(" [] [], + items: CssParameterList [ + ScssExpression { + items: ScssExpressionItemList [ + CssIdentifier { + value_token: IDENT@2220..2225 "woff2" [] [], }, ], }, ], - r_paren_token: R_PAREN@2003..2004 ")" [] [], + r_paren_token: R_PAREN@2225..2226 ")" [] [], }, ], - r_paren_token: R_PAREN@2004..2005 ")" [] [], + r_paren_token: R_PAREN@2226..2227 ")" [] [], }, ], }, }, important: missing (optional), }, - semicolon_token: SEMICOLON@2005..2006 ";" [] [], + semicolon_token: SEMICOLON@2227..2228 ";" [] [], + }, + CssDeclarationWithSemicolon { + declaration: CssDeclaration { + property: CssGenericProperty { + name: CssIdentifier { + value_token: IDENT@2228..2236 "expr3" [Newline("\n"), Whitespace(" ")] [], + }, + colon_token: COLON@2236..2238 ":" [] [Whitespace(" ")], + value: ScssExpression { + items: ScssExpressionItemList [ + CssUrlFunction { + name: URL_KW@2238..2241 "url" [] [], + l_paren_token: L_PAREN@2241..2242 "(" [] [], + value: CssUrlValueRaw { + value_token: CSS_URL_VALUE_RAW_LITERAL@2242..2250 "a#{b}\"c\"" [] [], + }, + modifiers: CssUrlModifierList [], + r_paren_token: R_PAREN@2250..2251 ")" [] [], + }, + ], + }, + }, + important: missing (optional), + }, + semicolon_token: SEMICOLON@2251..2252 ";" [] [], }, ], - r_curly_token: R_CURLY@2006..2064 "}" [Newline("\n"), Whitespace(" "), Comments("// TODO(interpolation ..."), Newline("\n")] [], + r_curly_token: R_CURLY@2252..2254 "}" [Newline("\n")] [], }, }, ], - eof_token: EOF@2064..2163 "" [Newline("\n"), Newline("\n"), Comments("// TODO(interpolation ..."), Newline("\n")] [], + eof_token: EOF@2254..2353 "" [Newline("\n"), Newline("\n"), Comments("// TODO(interpolation ..."), Newline("\n")] [], } ``` ## CST ``` -0: CSS_ROOT@0..2163 +0: CSS_ROOT@0..2353 0: (empty) - 1: CSS_ROOT_ITEM_LIST@0..2064 - 0: CSS_QUALIFIED_RULE@0..2064 + 1: CSS_ROOT_ITEM_LIST@0..2254 + 0: CSS_QUALIFIED_RULE@0..2254 0: CSS_SELECTOR_LIST@0..4 0: CSS_COMPOUND_SELECTOR@0..4 0: CSS_NESTED_SELECTOR_LIST@0..0 @@ -638,9 +971,9 @@ CssRoot { 1: CSS_IDENTIFIER@0..4 0: IDENT@0..4 "div" [] [Whitespace(" ")] 2: CSS_SUB_SELECTOR_LIST@4..4 - 1: CSS_DECLARATION_OR_RULE_BLOCK@4..2064 + 1: CSS_DECLARATION_OR_RULE_BLOCK@4..2254 0: L_CURLY@4..5 "{" [] [] - 1: CSS_DECLARATION_OR_RULE_LIST@5..2006 + 1: CSS_DECLARATION_OR_RULE_LIST@5..2252 0: CSS_DECLARATION_WITH_SEMICOLON@5..32 0: CSS_DECLARATION@5..31 0: CSS_GENERIC_PROPERTY@5..31 @@ -908,109 +1241,320 @@ CssRoot { 4: R_PAREN@1878..1879 ")" [] [] 1: (empty) 1: SEMICOLON@1879..1880 ";" [] [] - 12: CSS_DECLARATION_WITH_SEMICOLON@1880..1940 - 0: CSS_DECLARATION@1880..1939 - 0: CSS_GENERIC_PROPERTY@1880..1939 - 0: CSS_IDENTIFIER@1880..1891 - 0: IDENT@1880..1891 "modifier" [Newline("\n"), Whitespace(" ")] [] - 1: COLON@1891..1893 ":" [] [Whitespace(" ")] - 2: SCSS_EXPRESSION@1893..1939 - 0: SCSS_EXPRESSION_ITEM_LIST@1893..1939 - 0: CSS_URL_FUNCTION@1893..1939 - 0: URL_KW@1893..1896 "url" [] [] - 1: L_PAREN@1896..1897 "(" [] [] - 2: CSS_STRING@1897..1905 - 0: CSS_STRING_LITERAL@1897..1905 "\"a.png\"" [] [Whitespace(" ")] - 3: CSS_URL_MODIFIER_LIST@1905..1938 - 0: CSS_FUNCTION@1905..1938 - 0: SCSS_QUALIFIED_NAME@1905..1917 - 0: CSS_IDENTIFIER@1905..1910 - 0: IDENT@1905..1910 "color" [] [] - 1: DOT@1910..1911 "." [] [] - 2: CSS_IDENTIFIER@1911..1917 - 0: IDENT@1911..1917 "adjust" [] [] - 1: L_PAREN@1917..1918 "(" [] [] - 2: CSS_PARAMETER_LIST@1918..1937 - 0: SCSS_EXPRESSION@1918..1920 - 0: SCSS_EXPRESSION_ITEM_LIST@1918..1920 - 0: SCSS_IDENTIFIER@1918..1920 - 0: DOLLAR@1918..1919 "$" [] [] - 1: CSS_IDENTIFIER@1919..1920 - 0: IDENT@1919..1920 "c" [] [] - 1: COMMA@1920..1922 "," [] [Whitespace(" ")] - 2: SCSS_EXPRESSION@1922..1937 - 0: SCSS_EXPRESSION_ITEM_LIST@1922..1937 - 0: SCSS_KEYWORD_ARGUMENT@1922..1937 - 0: SCSS_IDENTIFIER@1922..1932 - 0: DOLLAR@1922..1923 "$" [] [] - 1: CSS_IDENTIFIER@1923..1932 - 0: IDENT@1923..1932 "lightness" [] [] - 1: COLON@1932..1934 ":" [] [Whitespace(" ")] - 2: SCSS_EXPRESSION@1934..1937 - 0: SCSS_EXPRESSION_ITEM_LIST@1934..1937 - 0: CSS_PERCENTAGE@1934..1937 - 0: CSS_NUMBER_LITERAL@1934..1936 "10" [] [] - 1: PERCENT@1936..1937 "%" [] [] + 12: CSS_DECLARATION_WITH_SEMICOLON@1880..1912 + 0: CSS_DECLARATION@1880..1911 + 0: CSS_GENERIC_PROPERTY@1880..1911 + 0: CSS_IDENTIFIER@1880..1888 + 0: IDENT@1880..1888 "expr4" [Newline("\n"), Whitespace(" ")] [] + 1: COLON@1888..1890 ":" [] [Whitespace(" ")] + 2: SCSS_EXPRESSION@1890..1911 + 0: SCSS_EXPRESSION_ITEM_LIST@1890..1911 + 0: CSS_URL_FUNCTION@1890..1911 + 0: URL_KW@1890..1893 "url" [] [] + 1: L_PAREN@1893..1894 "(" [] [] + 2: (empty) + 3: CSS_URL_MODIFIER_LIST@1894..1910 + 0: CSS_FUNCTION@1894..1910 + 0: SCSS_INTERPOLATED_IDENTIFIER@1894..1905 + 0: SCSS_INTERPOLATED_IDENTIFIER_PART_LIST@1894..1905 + 0: CSS_IDENTIFIER@1894..1897 + 0: IDENT@1894..1897 "foo" [] [] + 1: SCSS_INTERPOLATION@1897..1905 + 0: HASH@1897..1898 "#" [] [] + 1: L_CURLY@1898..1899 "{" [] [] + 2: SCSS_EXPRESSION@1899..1904 + 0: SCSS_EXPRESSION_ITEM_LIST@1899..1904 + 0: SCSS_BINARY_EXPRESSION@1899..1904 + 0: CSS_NUMBER@1899..1901 + 0: CSS_NUMBER_LITERAL@1899..1901 "1" [] [Whitespace(" ")] + 1: PLUS@1901..1903 "+" [] [Whitespace(" ")] + 2: CSS_NUMBER@1903..1904 + 0: CSS_NUMBER_LITERAL@1903..1904 "1" [] [] + 3: R_CURLY@1904..1905 "}" [] [] + 1: L_PAREN@1905..1906 "(" [] [] + 2: CSS_PARAMETER_LIST@1906..1909 + 0: SCSS_EXPRESSION@1906..1909 + 0: SCSS_EXPRESSION_ITEM_LIST@1906..1909 + 0: CSS_IDENTIFIER@1906..1909 + 0: IDENT@1906..1909 "bar" [] [] + 3: R_PAREN@1909..1910 ")" [] [] + 4: R_PAREN@1910..1911 ")" [] [] + 1: (empty) + 1: SEMICOLON@1911..1912 ";" [] [] + 13: CSS_DECLARATION_WITH_SEMICOLON@1912..1940 + 0: CSS_DECLARATION@1912..1939 + 0: CSS_GENERIC_PROPERTY@1912..1939 + 0: CSS_IDENTIFIER@1912..1920 + 0: IDENT@1912..1920 "expr5" [Newline("\n"), Whitespace(" ")] [] + 1: COLON@1920..1922 ":" [] [Whitespace(" ")] + 2: SCSS_EXPRESSION@1922..1939 + 0: SCSS_EXPRESSION_ITEM_LIST@1922..1939 + 0: CSS_URL_FUNCTION@1922..1939 + 0: URL_KW@1922..1925 "url" [] [] + 1: L_PAREN@1925..1926 "(" [] [] + 2: (empty) + 3: CSS_URL_MODIFIER_LIST@1926..1938 + 0: CSS_FUNCTION@1926..1938 + 0: SCSS_INTERPOLATED_IDENTIFIER@1926..1933 + 0: SCSS_INTERPOLATED_IDENTIFIER_PART_LIST@1926..1933 + 0: SCSS_INTERPOLATION@1926..1933 + 0: HASH@1926..1927 "#" [] [] + 1: L_CURLY@1927..1928 "{" [] [] + 2: SCSS_EXPRESSION@1928..1932 + 0: SCSS_EXPRESSION_ITEM_LIST@1928..1932 + 0: CSS_IDENTIFIER@1928..1932 + 0: IDENT@1928..1932 "name" [] [] + 3: R_CURLY@1932..1933 "}" [] [] + 1: L_PAREN@1933..1934 "(" [] [] + 2: CSS_PARAMETER_LIST@1934..1937 + 0: SCSS_EXPRESSION@1934..1937 + 0: SCSS_EXPRESSION_ITEM_LIST@1934..1937 + 0: CSS_IDENTIFIER@1934..1937 + 0: IDENT@1934..1937 "bar" [] [] 3: R_PAREN@1937..1938 ")" [] [] 4: R_PAREN@1938..1939 ")" [] [] 1: (empty) 1: SEMICOLON@1939..1940 ";" [] [] - 13: CSS_DECLARATION_WITH_SEMICOLON@1940..2006 - 0: CSS_DECLARATION@1940..2005 - 0: CSS_GENERIC_PROPERTY@1940..2005 - 0: CSS_IDENTIFIER@1940..1949 - 0: IDENT@1940..1949 "format" [Newline("\n"), Whitespace(" ")] [] - 1: COLON@1949..1951 ":" [] [Whitespace(" ")] - 2: SCSS_EXPRESSION@1951..2005 - 0: SCSS_EXPRESSION_ITEM_LIST@1951..2005 - 0: CSS_URL_FUNCTION@1951..2005 - 0: URL_KW@1951..1954 "url" [] [] - 1: L_PAREN@1954..1955 "(" [] [] - 2: CSS_STRING@1955..1963 - 0: CSS_STRING_LITERAL@1955..1963 "\"a.png\"" [] [Whitespace(" ")] - 3: CSS_URL_MODIFIER_LIST@1963..2004 - 0: CSS_FUNCTION@1963..2004 - 0: CSS_IDENTIFIER@1963..1969 - 0: IDENT@1963..1969 "format" [] [] - 1: L_PAREN@1969..1970 "(" [] [] - 2: CSS_PARAMETER_LIST@1970..2003 - 0: SCSS_EXPRESSION@1970..2003 - 0: SCSS_EXPRESSION_ITEM_LIST@1970..2003 - 0: CSS_FUNCTION@1970..2003 - 0: SCSS_QUALIFIED_NAME@1970..1982 - 0: CSS_IDENTIFIER@1970..1975 - 0: IDENT@1970..1975 "color" [] [] - 1: DOT@1975..1976 "." [] [] - 2: CSS_IDENTIFIER@1976..1982 - 0: IDENT@1976..1982 "adjust" [] [] - 1: L_PAREN@1982..1983 "(" [] [] - 2: CSS_PARAMETER_LIST@1983..2002 - 0: SCSS_EXPRESSION@1983..1985 - 0: SCSS_EXPRESSION_ITEM_LIST@1983..1985 - 0: SCSS_IDENTIFIER@1983..1985 - 0: DOLLAR@1983..1984 "$" [] [] - 1: CSS_IDENTIFIER@1984..1985 - 0: IDENT@1984..1985 "c" [] [] - 1: COMMA@1985..1987 "," [] [Whitespace(" ")] - 2: SCSS_EXPRESSION@1987..2002 - 0: SCSS_EXPRESSION_ITEM_LIST@1987..2002 - 0: SCSS_KEYWORD_ARGUMENT@1987..2002 - 0: SCSS_IDENTIFIER@1987..1997 - 0: DOLLAR@1987..1988 "$" [] [] - 1: CSS_IDENTIFIER@1988..1997 - 0: IDENT@1988..1997 "lightness" [] [] - 1: COLON@1997..1999 ":" [] [Whitespace(" ")] - 2: SCSS_EXPRESSION@1999..2002 - 0: SCSS_EXPRESSION_ITEM_LIST@1999..2002 - 0: CSS_PERCENTAGE@1999..2002 - 0: CSS_NUMBER_LITERAL@1999..2001 "10" [] [] - 1: PERCENT@2001..2002 "%" [] [] - 3: R_PAREN@2002..2003 ")" [] [] - 3: R_PAREN@2003..2004 ")" [] [] - 4: R_PAREN@2004..2005 ")" [] [] + 14: CSS_DECLARATION_WITH_SEMICOLON@1940..1980 + 0: CSS_DECLARATION@1940..1979 + 0: CSS_GENERIC_PROPERTY@1940..1979 + 0: CSS_IDENTIFIER@1940..1948 + 0: IDENT@1940..1948 "expr6" [Newline("\n"), Whitespace(" ")] [] + 1: COLON@1948..1950 ":" [] [Whitespace(" ")] + 2: SCSS_EXPRESSION@1950..1979 + 0: SCSS_EXPRESSION_ITEM_LIST@1950..1979 + 0: CSS_URL_FUNCTION@1950..1979 + 0: URL_KW@1950..1953 "url" [] [] + 1: L_PAREN@1953..1954 "(" [] [] + 2: (empty) + 3: CSS_URL_MODIFIER_LIST@1954..1978 + 0: CSS_FUNCTION@1954..1978 + 0: SCSS_INTERPOLATED_IDENTIFIER@1954..1973 + 0: SCSS_INTERPOLATED_IDENTIFIER_PART_LIST@1954..1973 + 0: SCSS_INTERPOLATION@1954..1963 + 0: HASH@1954..1955 "#" [] [] + 1: L_CURLY@1955..1956 "{" [] [] + 2: SCSS_EXPRESSION@1956..1962 + 0: SCSS_EXPRESSION_ITEM_LIST@1956..1962 + 0: CSS_IDENTIFIER@1956..1962 + 0: IDENT@1956..1962 "prefix" [] [] + 3: R_CURLY@1962..1963 "}" [] [] + 1: SCSS_INTERPOLATED_IDENTIFIER_HYPHEN@1963..1964 + 0: MINUS@1963..1964 "-" [] [] + 2: SCSS_INTERPOLATION@1964..1973 + 0: HASH@1964..1965 "#" [] [] + 1: L_CURLY@1965..1966 "{" [] [] + 2: SCSS_EXPRESSION@1966..1972 + 0: SCSS_EXPRESSION_ITEM_LIST@1966..1972 + 0: CSS_IDENTIFIER@1966..1972 + 0: IDENT@1966..1972 "suffix" [] [] + 3: R_CURLY@1972..1973 "}" [] [] + 1: L_PAREN@1973..1974 "(" [] [] + 2: CSS_PARAMETER_LIST@1974..1977 + 0: SCSS_EXPRESSION@1974..1977 + 0: SCSS_EXPRESSION_ITEM_LIST@1974..1977 + 0: CSS_IDENTIFIER@1974..1977 + 0: IDENT@1974..1977 "bar" [] [] + 3: R_PAREN@1977..1978 ")" [] [] + 4: R_PAREN@1978..1979 ")" [] [] + 1: (empty) + 1: SEMICOLON@1979..1980 ";" [] [] + 15: CSS_DECLARATION_WITH_SEMICOLON@1980..2040 + 0: CSS_DECLARATION@1980..2039 + 0: CSS_GENERIC_PROPERTY@1980..2039 + 0: CSS_IDENTIFIER@1980..1991 + 0: IDENT@1980..1991 "modifier" [Newline("\n"), Whitespace(" ")] [] + 1: COLON@1991..1993 ":" [] [Whitespace(" ")] + 2: SCSS_EXPRESSION@1993..2039 + 0: SCSS_EXPRESSION_ITEM_LIST@1993..2039 + 0: CSS_URL_FUNCTION@1993..2039 + 0: URL_KW@1993..1996 "url" [] [] + 1: L_PAREN@1996..1997 "(" [] [] + 2: CSS_STRING@1997..2005 + 0: CSS_STRING_LITERAL@1997..2005 "\"a.png\"" [] [Whitespace(" ")] + 3: CSS_URL_MODIFIER_LIST@2005..2038 + 0: CSS_FUNCTION@2005..2038 + 0: SCSS_QUALIFIED_NAME@2005..2017 + 0: CSS_IDENTIFIER@2005..2010 + 0: IDENT@2005..2010 "color" [] [] + 1: DOT@2010..2011 "." [] [] + 2: CSS_IDENTIFIER@2011..2017 + 0: IDENT@2011..2017 "adjust" [] [] + 1: L_PAREN@2017..2018 "(" [] [] + 2: CSS_PARAMETER_LIST@2018..2037 + 0: SCSS_EXPRESSION@2018..2020 + 0: SCSS_EXPRESSION_ITEM_LIST@2018..2020 + 0: SCSS_IDENTIFIER@2018..2020 + 0: DOLLAR@2018..2019 "$" [] [] + 1: CSS_IDENTIFIER@2019..2020 + 0: IDENT@2019..2020 "c" [] [] + 1: COMMA@2020..2022 "," [] [Whitespace(" ")] + 2: SCSS_EXPRESSION@2022..2037 + 0: SCSS_EXPRESSION_ITEM_LIST@2022..2037 + 0: SCSS_KEYWORD_ARGUMENT@2022..2037 + 0: SCSS_IDENTIFIER@2022..2032 + 0: DOLLAR@2022..2023 "$" [] [] + 1: CSS_IDENTIFIER@2023..2032 + 0: IDENT@2023..2032 "lightness" [] [] + 1: COLON@2032..2034 ":" [] [Whitespace(" ")] + 2: SCSS_EXPRESSION@2034..2037 + 0: SCSS_EXPRESSION_ITEM_LIST@2034..2037 + 0: CSS_PERCENTAGE@2034..2037 + 0: CSS_NUMBER_LITERAL@2034..2036 "10" [] [] + 1: PERCENT@2036..2037 "%" [] [] + 3: R_PAREN@2037..2038 ")" [] [] + 4: R_PAREN@2038..2039 ")" [] [] + 1: (empty) + 1: SEMICOLON@2039..2040 ";" [] [] + 16: CSS_DECLARATION_WITH_SEMICOLON@2040..2106 + 0: CSS_DECLARATION@2040..2105 + 0: CSS_GENERIC_PROPERTY@2040..2105 + 0: CSS_IDENTIFIER@2040..2049 + 0: IDENT@2040..2049 "format" [Newline("\n"), Whitespace(" ")] [] + 1: COLON@2049..2051 ":" [] [Whitespace(" ")] + 2: SCSS_EXPRESSION@2051..2105 + 0: SCSS_EXPRESSION_ITEM_LIST@2051..2105 + 0: CSS_URL_FUNCTION@2051..2105 + 0: URL_KW@2051..2054 "url" [] [] + 1: L_PAREN@2054..2055 "(" [] [] + 2: CSS_STRING@2055..2063 + 0: CSS_STRING_LITERAL@2055..2063 "\"a.png\"" [] [Whitespace(" ")] + 3: CSS_URL_MODIFIER_LIST@2063..2104 + 0: CSS_FUNCTION@2063..2104 + 0: CSS_IDENTIFIER@2063..2069 + 0: IDENT@2063..2069 "format" [] [] + 1: L_PAREN@2069..2070 "(" [] [] + 2: CSS_PARAMETER_LIST@2070..2103 + 0: SCSS_EXPRESSION@2070..2103 + 0: SCSS_EXPRESSION_ITEM_LIST@2070..2103 + 0: CSS_FUNCTION@2070..2103 + 0: SCSS_QUALIFIED_NAME@2070..2082 + 0: CSS_IDENTIFIER@2070..2075 + 0: IDENT@2070..2075 "color" [] [] + 1: DOT@2075..2076 "." [] [] + 2: CSS_IDENTIFIER@2076..2082 + 0: IDENT@2076..2082 "adjust" [] [] + 1: L_PAREN@2082..2083 "(" [] [] + 2: CSS_PARAMETER_LIST@2083..2102 + 0: SCSS_EXPRESSION@2083..2085 + 0: SCSS_EXPRESSION_ITEM_LIST@2083..2085 + 0: SCSS_IDENTIFIER@2083..2085 + 0: DOLLAR@2083..2084 "$" [] [] + 1: CSS_IDENTIFIER@2084..2085 + 0: IDENT@2084..2085 "c" [] [] + 1: COMMA@2085..2087 "," [] [Whitespace(" ")] + 2: SCSS_EXPRESSION@2087..2102 + 0: SCSS_EXPRESSION_ITEM_LIST@2087..2102 + 0: SCSS_KEYWORD_ARGUMENT@2087..2102 + 0: SCSS_IDENTIFIER@2087..2097 + 0: DOLLAR@2087..2088 "$" [] [] + 1: CSS_IDENTIFIER@2088..2097 + 0: IDENT@2088..2097 "lightness" [] [] + 1: COLON@2097..2099 ":" [] [Whitespace(" ")] + 2: SCSS_EXPRESSION@2099..2102 + 0: SCSS_EXPRESSION_ITEM_LIST@2099..2102 + 0: CSS_PERCENTAGE@2099..2102 + 0: CSS_NUMBER_LITERAL@2099..2101 "10" [] [] + 1: PERCENT@2101..2102 "%" [] [] + 3: R_PAREN@2102..2103 ")" [] [] + 3: R_PAREN@2103..2104 ")" [] [] + 4: R_PAREN@2104..2105 ")" [] [] + 1: (empty) + 1: SEMICOLON@2105..2106 ";" [] [] + 17: CSS_DECLARATION_WITH_SEMICOLON@2106..2166 + 0: CSS_DECLARATION@2106..2165 + 0: CSS_GENERIC_PROPERTY@2106..2165 + 0: CSS_IDENTIFIER@2106..2134 + 0: IDENT@2106..2134 "modifier-interpolated-mid" [Newline("\n"), Whitespace(" ")] [] + 1: COLON@2134..2136 ":" [] [Whitespace(" ")] + 2: SCSS_EXPRESSION@2136..2165 + 0: SCSS_EXPRESSION_ITEM_LIST@2136..2165 + 0: CSS_URL_FUNCTION@2136..2165 + 0: URL_KW@2136..2139 "url" [] [] + 1: L_PAREN@2139..2140 "(" [] [] + 2: CSS_STRING@2140..2148 + 0: CSS_STRING_LITERAL@2140..2148 "\"a.png\"" [] [Whitespace(" ")] + 3: CSS_URL_MODIFIER_LIST@2148..2164 + 0: CSS_FUNCTION@2148..2164 + 0: SCSS_INTERPOLATED_IDENTIFIER@2148..2159 + 0: SCSS_INTERPOLATED_IDENTIFIER_PART_LIST@2148..2159 + 0: CSS_IDENTIFIER@2148..2151 + 0: IDENT@2148..2151 "foo" [] [] + 1: SCSS_INTERPOLATION@2151..2159 + 0: HASH@2151..2152 "#" [] [] + 1: L_CURLY@2152..2153 "{" [] [] + 2: SCSS_EXPRESSION@2153..2158 + 0: SCSS_EXPRESSION_ITEM_LIST@2153..2158 + 0: SCSS_BINARY_EXPRESSION@2153..2158 + 0: CSS_NUMBER@2153..2155 + 0: CSS_NUMBER_LITERAL@2153..2155 "1" [] [Whitespace(" ")] + 1: PLUS@2155..2157 "+" [] [Whitespace(" ")] + 2: CSS_NUMBER@2157..2158 + 0: CSS_NUMBER_LITERAL@2157..2158 "1" [] [] + 3: R_CURLY@2158..2159 "}" [] [] + 1: L_PAREN@2159..2160 "(" [] [] + 2: CSS_PARAMETER_LIST@2160..2163 + 0: SCSS_EXPRESSION@2160..2163 + 0: SCSS_EXPRESSION_ITEM_LIST@2160..2163 + 0: CSS_IDENTIFIER@2160..2163 + 0: IDENT@2160..2163 "bar" [] [] + 3: R_PAREN@2163..2164 ")" [] [] + 4: R_PAREN@2164..2165 ")" [] [] + 1: (empty) + 1: SEMICOLON@2165..2166 ";" [] [] + 18: CSS_DECLARATION_WITH_SEMICOLON@2166..2228 + 0: CSS_DECLARATION@2166..2227 + 0: CSS_GENERIC_PROPERTY@2166..2227 + 0: CSS_IDENTIFIER@2166..2196 + 0: IDENT@2166..2196 "modifier-interpolated-start" [Newline("\n"), Whitespace(" ")] [] + 1: COLON@2196..2198 ":" [] [Whitespace(" ")] + 2: SCSS_EXPRESSION@2198..2227 + 0: SCSS_EXPRESSION_ITEM_LIST@2198..2227 + 0: CSS_URL_FUNCTION@2198..2227 + 0: URL_KW@2198..2201 "url" [] [] + 1: L_PAREN@2201..2202 "(" [] [] + 2: CSS_STRING@2202..2210 + 0: CSS_STRING_LITERAL@2202..2210 "\"a.png\"" [] [Whitespace(" ")] + 3: CSS_URL_MODIFIER_LIST@2210..2226 + 0: CSS_FUNCTION@2210..2226 + 0: SCSS_INTERPOLATED_IDENTIFIER@2210..2219 + 0: SCSS_INTERPOLATED_IDENTIFIER_PART_LIST@2210..2219 + 0: SCSS_INTERPOLATION@2210..2219 + 0: HASH@2210..2211 "#" [] [] + 1: L_CURLY@2211..2212 "{" [] [] + 2: SCSS_EXPRESSION@2212..2218 + 0: SCSS_EXPRESSION_ITEM_LIST@2212..2218 + 0: CSS_IDENTIFIER@2212..2218 + 0: IDENT@2212..2218 "format" [] [] + 3: R_CURLY@2218..2219 "}" [] [] + 1: L_PAREN@2219..2220 "(" [] [] + 2: CSS_PARAMETER_LIST@2220..2225 + 0: SCSS_EXPRESSION@2220..2225 + 0: SCSS_EXPRESSION_ITEM_LIST@2220..2225 + 0: CSS_IDENTIFIER@2220..2225 + 0: IDENT@2220..2225 "woff2" [] [] + 3: R_PAREN@2225..2226 ")" [] [] + 4: R_PAREN@2226..2227 ")" [] [] + 1: (empty) + 1: SEMICOLON@2227..2228 ";" [] [] + 19: CSS_DECLARATION_WITH_SEMICOLON@2228..2252 + 0: CSS_DECLARATION@2228..2251 + 0: CSS_GENERIC_PROPERTY@2228..2251 + 0: CSS_IDENTIFIER@2228..2236 + 0: IDENT@2228..2236 "expr3" [Newline("\n"), Whitespace(" ")] [] + 1: COLON@2236..2238 ":" [] [Whitespace(" ")] + 2: SCSS_EXPRESSION@2238..2251 + 0: SCSS_EXPRESSION_ITEM_LIST@2238..2251 + 0: CSS_URL_FUNCTION@2238..2251 + 0: URL_KW@2238..2241 "url" [] [] + 1: L_PAREN@2241..2242 "(" [] [] + 2: CSS_URL_VALUE_RAW@2242..2250 + 0: CSS_URL_VALUE_RAW_LITERAL@2242..2250 "a#{b}\"c\"" [] [] + 3: CSS_URL_MODIFIER_LIST@2250..2250 + 4: R_PAREN@2250..2251 ")" [] [] 1: (empty) - 1: SEMICOLON@2005..2006 ";" [] [] - 2: R_CURLY@2006..2064 "}" [Newline("\n"), Whitespace(" "), Comments("// TODO(interpolation ..."), Newline("\n")] [] - 2: EOF@2064..2163 "" [Newline("\n"), Newline("\n"), Comments("// TODO(interpolation ..."), Newline("\n")] [] + 1: SEMICOLON@2251..2252 ";" [] [] + 2: R_CURLY@2252..2254 "}" [Newline("\n")] [] + 2: EOF@2254..2353 "" [Newline("\n"), Newline("\n"), Comments("// TODO(interpolation ..."), Newline("\n")] [] ```