/* * Copyright (c) 2018-2022, Andreas Kling * Copyright (c) 2020-2021, the SerenityOS developers. * Copyright (c) 2021-2026, Sam Atkins * Copyright (c) 2021, Tobias Christiansen * Copyright (c) 2022, MacDue * * SPDX-License-Identifier: BSD-2-Clause */ #include #include #include #include #include #include #include #include #include #include #include #include namespace Web::CSS::Parser { Vector> Parser::parse_as_media_query_list() { return parse_a_media_query_list(m_token_stream); } template Vector> Parser::parse_a_media_query_list(TokenStream& tokens) { // https://www.w3.org/TR/mediaqueries-4/#mq-list // AD-HOC: Ignore whitespace-only queries // to make `@media {..}` equivalent to `@media all {..}` tokens.discard_whitespace(); if (!tokens.has_next_token()) return {}; auto comma_separated_lists = parse_a_comma_separated_list_of_component_values(tokens); AK::Vector> media_queries; for (auto& media_query_parts : comma_separated_lists) { auto stream = TokenStream(media_query_parts); media_queries.append(parse_media_query(stream)); } return media_queries; } RefPtr Parser::parse_as_media_query() { // https://www.w3.org/TR/cssom-1/#parse-a-media-query auto media_query_list = parse_as_media_query_list(); if (media_query_list.is_empty()) return MediaQuery::create_not_all(); if (media_query_list.size() == 1) return media_query_list.first(); return nullptr; } // ``, https://www.w3.org/TR/mediaqueries-4/#typedef-media-query NonnullRefPtr Parser::parse_media_query(TokenStream& tokens) { // ` = // | [ not | only ]? [ and ]?` // `[ not | only ]?`, Returns whether to negate the query auto parse_initial_modifier = [](auto& tokens) -> Optional { auto transaction = tokens.begin_transaction(); tokens.discard_whitespace(); auto& token = tokens.consume_a_token(); if (!token.is(Token::Type::Ident)) return {}; auto ident = token.token().ident(); if (ident.equals_ignoring_ascii_case("not"sv)) { transaction.commit(); return true; } if (ident.equals_ignoring_ascii_case("only"sv)) { transaction.commit(); return false; } return {}; }; auto invalid_media_query = [&](String&& description) { // "A media query that does not match the grammar in the previous section must be replaced by `not all` // during parsing." - https://www.w3.org/TR/mediaqueries-5/#error-handling ErrorReporter::the().report(InvalidQueryError { .query_type = "@media"_fly_string, .value_string = tokens.dump_string(), .description = move(description), }); return MediaQuery::create_not_all(); }; auto media_query = MediaQuery::create(); tokens.discard_whitespace(); // `` if (auto media_condition = parse_media_condition(tokens)) { tokens.discard_whitespace(); if (tokens.has_next_token()) return invalid_media_query("Trailing tokens after "_string); media_query->m_media_condition = media_condition.release_nonnull(); return media_query; } // `[ not | only ]?` if (auto modifier = parse_initial_modifier(tokens); modifier.has_value()) { media_query->m_negated = modifier.value(); tokens.discard_whitespace(); } // `` if (auto media_type = parse_media_type(tokens); media_type.has_value()) { media_query->m_media_type = media_type.release_value(); tokens.discard_whitespace(); } else { // https://drafts.csswg.org/mediaqueries-4/#error-handling // A media query that does not match the grammar in the previous section must be replaced by not all during parsing. return invalid_media_query("Doesn't match ``"_string); } if (!tokens.has_next_token()) return media_query; // `[ and ]?` if (auto const& maybe_and = tokens.consume_a_token(); maybe_and.is_ident("and"sv)) { if (auto media_condition = parse_media_condition(tokens)) { // "or" is disallowed at the top level if (is(*media_condition)) return invalid_media_query("Contains top-level `or`"_string); tokens.discard_whitespace(); if (tokens.has_next_token()) return invalid_media_query("Trailing tokens after ``"_string); media_query->m_media_condition = move(media_condition); return media_query; } return invalid_media_query("Missing `` after `and`"_string); } return invalid_media_query("Trailing tokens after ``"_string); } // ``, https://www.w3.org/TR/mediaqueries-4/#typedef-media-condition OwnPtr Parser::parse_media_condition(TokenStream& tokens) { return parse_boolean_expression(tokens, MatchResult::Unknown, [this](TokenStream& outer_tokens) -> OwnPtr { auto transaction = outer_tokens.begin_transaction(); outer_tokens.discard_whitespace(); if (!(outer_tokens.next_token().is_block() && outer_tokens.next_token().block().is_paren())) return nullptr; auto const& block = outer_tokens.consume_a_token().block(); TokenStream inner_tokens { block.value }; if (auto maybe_media_feature = parse_media_feature(inner_tokens)) { transaction.commit(); return maybe_media_feature; } return nullptr; }); } enum class FeatureNameType : u8 { Normal, Min, Max, }; template struct FeatureName { FeatureNameType type; FeatureID id; }; // ` = '<' '='? // = '>' '='? // = '=' // = | | ` static Optional parse_feature_comparison(TokenStream& tokens) { auto transaction = tokens.begin_transaction(); tokens.discard_whitespace(); auto& first = tokens.consume_a_token(); if (first.is(Token::Type::Delim)) { auto first_delim = first.token().delim(); if (first_delim == '=') { transaction.commit(); return FeatureComparison::Equal; } if (first_delim == '<') { auto& second = tokens.next_token(); if (second.is_delim('=')) { tokens.discard_a_token(); transaction.commit(); return FeatureComparison::LessThanOrEqual; } transaction.commit(); return FeatureComparison::LessThan; } if (first_delim == '>') { auto& second = tokens.next_token(); if (second.is_delim('=')) { tokens.discard_a_token(); transaction.commit(); return FeatureComparison::GreaterThanOrEqual; } transaction.commit(); return FeatureComparison::GreaterThan; } } return {}; } template static OwnPtr parse_query_feature(TokenStream& inner_tokens, FeatureNameFromString feature_name_from_string, ParseFeatureValue parse_feature_value, AllowsRangeSyntax allows_range_syntax) { auto transaction = inner_tokens.begin_transaction(); // ` = ` auto parse_feature_name = [&](auto& tokens, bool allow_min_max_prefix) -> Optional> { auto transaction = tokens.begin_transaction(); auto& token = tokens.consume_a_token(); if (token.is(Token::Type::Ident)) { auto name = token.token().ident(); if (auto id = feature_name_from_string(name); id.has_value()) { transaction.commit(); return FeatureName { FeatureNameType::Normal, id.value() }; } if (allow_min_max_prefix && (name.starts_with_bytes("min-"sv, CaseSensitivity::CaseInsensitive) || name.starts_with_bytes("max-"sv, CaseSensitivity::CaseInsensitive))) { auto adjusted_name = name.bytes_as_string_view().substring_view(4); if (auto id = feature_name_from_string(adjusted_name); id.has_value() && allows_range_syntax(id.value())) { transaction.commit(); return FeatureName { name.starts_with_bytes("min-"sv, CaseSensitivity::CaseInsensitive) ? FeatureNameType::Min : FeatureNameType::Max, id.value() }; } } } return {}; }; auto parse_feature_boolean = [&](auto& tokens) -> OwnPtr { auto transaction = tokens.begin_transaction(); tokens.discard_whitespace(); if (auto maybe_name = parse_feature_name(tokens, false); maybe_name.has_value()) { tokens.discard_whitespace(); if (!tokens.has_next_token()) { transaction.commit(); return Feature::boolean(maybe_name->id); } } return {}; }; auto parse_feature_plain = [&](auto& tokens) -> OwnPtr { auto transaction = tokens.begin_transaction(); tokens.discard_whitespace(); if (auto maybe_name = parse_feature_name(tokens, true); maybe_name.has_value()) { tokens.discard_whitespace(); if (tokens.consume_a_token().is(Token::Type::Colon)) { tokens.discard_whitespace(); if (auto maybe_value = parse_feature_value(maybe_name->id, tokens); maybe_value.has_value()) { tokens.discard_whitespace(); if (!tokens.has_next_token()) { transaction.commit(); switch (maybe_name->type) { case FeatureNameType::Normal: return Feature::plain(maybe_name->id, maybe_value.release_value()); case FeatureNameType::Min: return Feature::min(maybe_name->id, maybe_value.release_value()); case FeatureNameType::Max: return Feature::max(maybe_name->id, maybe_value.release_value()); } VERIFY_NOT_REACHED(); } } } } return {}; }; // ` = // | // | // | ` auto parse_feature_range = [&](auto& tokens) -> OwnPtr { auto transaction = tokens.begin_transaction(); tokens.discard_whitespace(); // ` ` // NOTE: We have to check for first, since all s will also parse as . if (auto maybe_name = parse_feature_name(tokens, false); maybe_name.has_value() && allows_range_syntax(maybe_name->id)) { tokens.discard_whitespace(); if (auto maybe_comparison = parse_feature_comparison(tokens); maybe_comparison.has_value()) { tokens.discard_whitespace(); if (auto maybe_value = parse_feature_value(maybe_name->id, tokens); maybe_value.has_value()) { tokens.discard_whitespace(); if (!tokens.has_next_token() && !maybe_value->is_ident()) { transaction.commit(); return Feature::half_range(maybe_name->id, maybe_comparison.release_value(), maybe_value.release_value()); } } } } // ` // | // | ` // NOTE: To parse the first value, we need to first find and parse the so we know what value types to parse. // To allow for to be any number of tokens long, we scan forward until we find a comparison, and then // treat the next non-whitespace token as the , which should be correct as long as they don't add a value // type that can include a comparison in it. :^) Optional> maybe_name; { // This transaction is never committed, we just use it to rewind automatically. auto temp_transaction = tokens.begin_transaction(); while (tokens.has_next_token() && !maybe_name.has_value()) { if (auto maybe_comparison = parse_feature_comparison(tokens); maybe_comparison.has_value()) { // We found a comparison, so the next non-whitespace token should be the tokens.discard_whitespace(); maybe_name = parse_feature_name(tokens, false); break; } tokens.discard_a_token(); tokens.discard_whitespace(); } } if (maybe_name.has_value() && allows_range_syntax(maybe_name->id)) { if (auto maybe_left_value = parse_feature_value(maybe_name->id, tokens); maybe_left_value.has_value()) { tokens.discard_whitespace(); if (auto maybe_left_comparison = parse_feature_comparison(tokens); maybe_left_comparison.has_value()) { tokens.discard_whitespace(); tokens.discard_a_token(); // The which we already parsed above. tokens.discard_whitespace(); if (!tokens.has_next_token()) { transaction.commit(); return Feature::half_range(maybe_left_value.release_value(), maybe_left_comparison.release_value(), maybe_name->id); } if (auto maybe_right_comparison = parse_feature_comparison(tokens); maybe_right_comparison.has_value()) { tokens.discard_whitespace(); if (auto maybe_right_value = parse_feature_value(maybe_name->id, tokens); maybe_right_value.has_value()) { tokens.discard_whitespace(); // For this to be valid, the following must be true: // - Comparisons must either both be >/>= or both be is_ident() && !maybe_right_value->is_ident()) { transaction.commit(); return Feature::range(maybe_left_value.release_value(), left_comparison, maybe_name->id, right_comparison, maybe_right_value.release_value()); } } } } } } return {}; }; if (auto maybe_feature_boolean = parse_feature_boolean(inner_tokens)) { inner_tokens.discard_whitespace(); if (inner_tokens.has_next_token()) return nullptr; transaction.commit(); return maybe_feature_boolean.release_nonnull(); } if (auto maybe_feature_plain = parse_feature_plain(inner_tokens)) { inner_tokens.discard_whitespace(); if (inner_tokens.has_next_token()) return nullptr; transaction.commit(); return maybe_feature_plain.release_nonnull(); } if (auto maybe_feature_range = parse_feature_range(inner_tokens)) { inner_tokens.discard_whitespace(); if (inner_tokens.has_next_token()) return nullptr; transaction.commit(); return maybe_feature_range.release_nonnull(); } return {}; } // ``, https://drafts.csswg.org/mediaqueries-5/#typedef-media-feature OwnPtr Parser::parse_media_feature(TokenStream& inner_tokens) { return parse_query_feature( inner_tokens, [](StringView name) { return media_feature_id_from_string(name); }, [this](MediaFeatureID id, auto& tokens) { return parse_media_feature_value(id, tokens); }, [](MediaFeatureID id) { return media_feature_type_is_range(id); }); } // ``, https://drafts.csswg.org/css-conditional-5/#size-container OwnPtr Parser::parse_size_feature(TokenStream& inner_tokens) { return parse_query_feature( inner_tokens, [](StringView name) { return size_feature_id_from_string(name); }, [this](SizeFeatureID id, auto& tokens) { return parse_size_feature_value(id, tokens); }, [](SizeFeatureID id) { return size_feature_type_is_range(id); }); } Optional Parser::parse_media_type(TokenStream& tokens) { auto transaction = tokens.begin_transaction(); tokens.discard_whitespace(); auto const& token = tokens.consume_a_token(); if (!token.is(Token::Type::Ident)) return {}; // https://drafts.csswg.org/mediaqueries-3/#error-handling // "However, an exception is made for media types ‘layer’, ‘not’, ‘and’, ‘only’, and ‘or’. Even though they do match // the IDENT production, they must not be treated as unknown media types, but rather trigger the malformed query clause." if (token.is_ident("layer"sv) || token.is_ident("not"sv) || token.is_ident("and"sv) || token.is_ident("only"sv) || token.is_ident("or"sv)) return {}; transaction.commit(); auto const& ident = token.token().ident(); return MediaQuery::MediaType { .name = ident, .known_type = media_type_from_string(ident), }; } static bool is_feature_value_token(ComponentValue const& component_value) { if (!component_value.is_token()) return true; switch (component_value.token().type()) { case Token::Type::Ident: case Token::Type::Function: case Token::Type::AtKeyword: case Token::Type::Hash: case Token::Type::String: case Token::Type::BadString: case Token::Type::Url: case Token::Type::BadUrl: case Token::Type::Number: case Token::Type::Percentage: case Token::Type::Dimension: case Token::Type::Whitespace: case Token::Type::Comma: return true; case Token::Type::Delim: // FIXME: What list of delimiters should we actually allow here? return !first_is_one_of(component_value.token().delim(), static_cast('<'), static_cast('>'), static_cast('=')); case Token::Type::Invalid: case Token::Type::EndOfFile: case Token::Type::CDO: case Token::Type::CDC: case Token::Type::Colon: case Token::Type::Semicolon: case Token::Type::OpenSquare: case Token::Type::CloseSquare: case Token::Type::OpenParen: case Token::Type::CloseParen: case Token::Type::OpenCurly: case Token::Type::CloseCurly: return false; } VERIFY_NOT_REACHED(); } template Optional Parser::parse_feature_value(FeatureID feature, TokenStream& tokens, FeatureAcceptsKeyword feature_accepts_keyword, FeatureAcceptsType feature_accepts_type) { { auto transaction = tokens.begin_transaction(); auto value = [&](FeatureID feature, TokenStream& tokens) -> Optional { auto context_guard = push_temporary_value_parsing_context(SpecialContext::MediaCondition); // One branch for each member of the QueryValueType enum: // Identifiers if (tokens.next_token().is(Token::Type::Ident)) { auto transaction = tokens.begin_transaction(); tokens.discard_whitespace(); auto keyword = parse_keyword_value(tokens); if (keyword && feature_accepts_keyword(feature, keyword->to_keyword())) { transaction.commit(); return FeatureValue(FeatureValue::Type::Ident, keyword.release_nonnull()); } } // Boolean ( in the spec: a 1 or 0) if (feature_accepts_type(feature, QueryValueType::Boolean)) { auto transaction = tokens.begin_transaction(); tokens.discard_whitespace(); if (auto integer = parse_integer_value(tokens, infinite_integer_range)) { if (integer->is_calculated() || first_is_one_of(integer->as_integer().integer(), 0, 1)) { transaction.commit(); return FeatureValue(FeatureValue::Type::Integer, integer.release_nonnull()); } } } // Integer if (feature_accepts_type(feature, QueryValueType::Integer)) { auto transaction = tokens.begin_transaction(); if (auto integer = parse_integer_value(tokens, infinite_integer_range)) { transaction.commit(); return FeatureValue(FeatureValue::Type::Integer, integer.release_nonnull()); } } // Length if (feature_accepts_type(feature, QueryValueType::Length)) { auto transaction = tokens.begin_transaction(); tokens.discard_whitespace(); if (auto length = parse_length_value(tokens, infinite_range)) { transaction.commit(); return FeatureValue(FeatureValue::Type::Length, length.release_nonnull()); } // https://drafts.csswg.org/mediaqueries-5/#typedef-mf-value // = | | | // // https://drafts.csswg.org/css-values-4/#lengths // "For zero lengths the unit identifier is optional" // // https://drafts.csswg.org/css-values-4/#zero-value // "Values of '0' can be written without units, even if the // value type doesn't allow 'unitless zeroes'." if (tokens.has_next_token()) { auto const& token = tokens.next_token(); if (auto calc = parse_calculated_value(token, { .accepted_ranges_by_type = { { ValueType::Number, infinite_range } } }); calc && calc->as_calculated().resolves_to_number()) { if (auto resolved_number = calc->as_calculated().resolve_number({}); resolved_number.has_value() && *resolved_number == 0) { tokens.discard_a_token(); transaction.commit(); return FeatureValue(FeatureValue::Type::Length, LengthStyleValue::create(Length::make_px(0))); } } } } // Ratio if (feature_accepts_type(feature, QueryValueType::Ratio)) { auto transaction = tokens.begin_transaction(); tokens.discard_whitespace(); if (auto ratio = parse_ratio_value(tokens)) { transaction.commit(); return FeatureValue(FeatureValue::Type::Ratio, ratio.release_nonnull()); } } // Resolution if (feature_accepts_type(feature, QueryValueType::Resolution)) { auto transaction = tokens.begin_transaction(); tokens.discard_whitespace(); if (auto resolution = parse_resolution_value(tokens, infinite_range)) { transaction.commit(); return FeatureValue(FeatureValue::Type::Resolution, resolution.release_nonnull()); } } return {}; }(feature, tokens); if (value.has_value()) { tokens.discard_whitespace(); // Only returned the value if there are no trailing tokens. // Otherwise, the transaction gets reverted and we consume all the value tokens below. if (!is_feature_value_token(tokens.next_token())) { transaction.commit(); return value.release_value(); } } } // Parsing failed somehow, so wrap all the tokens into an "unknown" FeatureValue if possible. auto transaction = tokens.begin_transaction(); tokens.discard_whitespace(); Vector unknown_tokens; // Consume any tokens that could be part of a value. while (tokens.has_next_token()) { if (is_feature_value_token(tokens.next_token())) { unknown_tokens.append(tokens.consume_a_token()); } else { break; } } if (!unknown_tokens.is_empty()) { transaction.commit(); ErrorReporter::the().report(InvalidValueError { .value_type = ""_fly_string, .value_string = MUST(String::join(""sv, unknown_tokens)), .description = "Unrecognized type"_string, }); // NB: We only use this for serialization so the substitution function presence is irrelevant and we can just // set it to empty. return FeatureValue(FeatureValue::Type::Unknown, move(UnresolvedStyleValue::create(move(unknown_tokens), {}))); } return {}; } // ``, https://www.w3.org/TR/mediaqueries-4/#typedef-mf-value Optional Parser::parse_media_feature_value(MediaFeatureID feature, TokenStream& tokens) { return parse_feature_value( feature, tokens, [](MediaFeatureID feature, Keyword keyword) { return media_feature_accepts_keyword(feature, keyword); }, [](MediaFeatureID feature, QueryValueType type) { return media_feature_accepts_type(feature, type); }); } Optional Parser::parse_size_feature_value(SizeFeatureID feature, TokenStream& tokens) { auto size_feature_accepts_keyword = [](SizeFeatureID feature, Keyword keyword) { return feature == SizeFeatureID::Orientation && first_is_one_of(keyword, Keyword::Landscape, Keyword::Portrait); }; auto size_feature_accepts_type = [](SizeFeatureID feature, QueryValueType type) { switch (type) { case QueryValueType::Length: return first_is_one_of(feature, SizeFeatureID::BlockSize, SizeFeatureID::Height, SizeFeatureID::InlineSize, SizeFeatureID::Width); case QueryValueType::Ratio: return feature == SizeFeatureID::AspectRatio; default: return false; } }; return parse_feature_value(feature, tokens, size_feature_accepts_keyword, size_feature_accepts_type); } template GC::Ptr Parser::convert_to_media_rule(AtRule const& rule, Nested nested) { m_rule_context.append(RuleContext::AtMedia); ScopeGuard guard = [&] { [[maybe_unused]] auto last = m_rule_context.take_last(); VERIFY(last == RuleContext::AtMedia); }; // https://drafts.csswg.org/css-conditional-3/#at-media // @media { // // } if (!rule.is_block_rule) { ErrorReporter::the().report(CSS::Parser::InvalidRuleError { .rule_name = "@media"_fly_string, .prelude = MUST(String::join(""sv, rule.prelude)), .description = "Expected a block."_string, }); return nullptr; } auto media_query_tokens = TokenStream { rule.prelude }; auto media_query_list = parse_a_media_query_list(media_query_tokens); auto media_list = MediaList::create(realm(), move(media_query_list)); GC::RootVector> child_rules; for (auto const& child : rule.child_rules_and_lists_of_declarations) { child.visit( [&](Rule const& rule) { if (auto child_rule = convert_to_rule(rule, nested)) child_rules.append(*child_rule); }, [&](Vector const& declarations) { child_rules.append(NestedDeclarationsRule::create(realm(), *this, declarations)); }); } auto rule_list = CSSRuleList::create(realm(), child_rules); return CSSMediaRule::create(realm(), media_list, rule_list); } template GC::Ptr Parser::convert_to_media_rule(AtRule const&, Parser::Nested); template GC::Ptr Parser::convert_to_media_rule(AtRule const&, Parser::Nested); }