From c35af8e46f3c57fdd3ba98cf92e63f2dd2064660 Mon Sep 17 00:00:00 2001 From: Florian Dieminger Date: Tue, 7 Jan 2025 17:36:03 +0100 Subject: [PATCH] feat(baseline): compute asterisk (#77) * feat(baseline): compute asterisk * refactor --- crates/rari-data/src/baseline.rs | 237 ++++++++++++++++++++++++++---- crates/rari-doc/src/baseline.rs | 4 +- crates/rari-doc/src/pages/json.rs | 6 +- 3 files changed, 210 insertions(+), 37 deletions(-) diff --git a/crates/rari-data/src/baseline.rs b/crates/rari-data/src/baseline.rs index a75017c5..c79462f3 100644 --- a/crates/rari-data/src/baseline.rs +++ b/crates/rari-data/src/baseline.rs @@ -4,6 +4,7 @@ use std::marker::PhantomData; use std::path::Path; use indexmap::IndexMap; +use rari_utils::concat_strs; use rari_utils::io::read_to_string; use schemars::JsonSchema; use serde::de::{self, value, SeqAccess, Visitor}; @@ -13,9 +14,24 @@ use url::Url; use crate::error::Error; +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct Baseline<'a> { + #[serde(flatten)] + pub support: &'a SupportStatusWithByKey, + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub asterisk: bool, +} + #[derive(Deserialize, Serialize, Clone, Debug)] pub struct WebFeatures { pub features: IndexMap, + pub bcd_keys: Vec, +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct KeyStatus { + bcd_key: String, + feature: String, } #[derive(Deserialize, Serialize, Clone, Debug)] @@ -23,49 +39,180 @@ pub struct DirtyWebFeatures { pub features: IndexMap, } +#[inline] +fn spaced(bcd_key: &str) -> String { + bcd_key.replace('.', " ") +} + +#[inline] +fn unspaced(bcd_key: &str) -> String { + bcd_key.replace(' ', ".") +} + impl WebFeatures { pub fn from_file(path: &Path) -> Result { let json_str = read_to_string(path)?; let dirty_map: DirtyWebFeatures = serde_json::from_str(&json_str)?; - let map = WebFeatures { - features: dirty_map - .features - .into_iter() - .filter_map(|(k, v)| { - serde_json::from_value::(v) - .inspect_err(|e| { - tracing::error!("Error serializing baseline for {}: {}", k, &e) - }) - .ok() - .map(|v| (k, v)) + let features: IndexMap = dirty_map + .features + .into_iter() + .filter_map(|(k, v)| { + serde_json::from_value::(v) + .inspect_err(|e| { + tracing::error!("Error serializing baseline for {}: {}", k, &e) + }) + .ok() + .map(|v| (k, v)) + }) + .collect(); + // bcd_keys is a sorted by KeyStatus.bcd_key + // We replace "." with " " so the sorting is stable as in: + // http headers Content-Security-Policy + // http headers Content-Security-Policy base-uri + // http headers Content-Security-Policy child-src + // http headers Content-Security-Policy-Report-Only + // + // instead of: + // http.headers.Content-Security-Policy + // http.headers.Content-Security-Policy-Report-Only + // http.headers.Content-Security-Policy.base-uri + // http.headers.Content-Security-Policy.child-src + // + // This allows to simple return ranges when looking for keys prefixed with + // `http headers Content-Security-Policy` + let mut bcd_keys: Vec = features + .iter() + .flat_map(|(feature, fd)| { + fd.compat_features.iter().map(|bcd_key| KeyStatus { + bcd_key: spaced(bcd_key), + feature: feature.clone(), }) - .collect(), - }; + }) + .collect(); + bcd_keys.sort_by(|a, b| a.bcd_key.cmp(&b.bcd_key)); + bcd_keys.dedup_by(|a, b| a.bcd_key == b.bcd_key); + + let map = WebFeatures { features, bcd_keys }; Ok(map) } - pub fn feature_status(&self, bcd_key: &str) -> Option<&SupportStatusWithByKey> { - self.features.values().find_map(|feature_data| { - if let Some(ref status) = feature_data.status { - if feature_data - .compat_features + pub fn sub_keys(&self, bcd_key: &str) -> &[KeyStatus] { + let suffix = concat_strs!(bcd_key, " "); + if let Ok(start) = self + .bcd_keys + .binary_search_by_key(&bcd_key, |ks| &ks.bcd_key) + { + if start < self.bcd_keys.len() { + if let Some(end) = self.bcd_keys[start + 1..] .iter() - .any(|key| key == bcd_key) + .position(|ks| !ks.bcd_key.starts_with(&suffix)) { - if feature_data.discouraged.is_some() { - return None; + return &self.bcd_keys[start + 1..start + 1 + end]; + } + } + } + &[] + } + + // Compute status according to: + // https://github.com/mdn/yari/issues/11546#issuecomment-2531611136 + pub fn feature_status(&self, bcd_key: &str) -> Option { + let bcd_key_spaced = &spaced(bcd_key); + if let Some(status) = self.feature_status_internal(bcd_key_spaced) { + let sub_keys = self.sub_keys(bcd_key_spaced); + let sub_status = sub_keys + .iter() + .map(|sub_key| { + self.feature_status_internal_with_feature_name( + &sub_key.bcd_key, + &sub_key.feature, + ) + .and_then(|status| status.baseline) + }) + .collect::>(); + if sub_status + .iter() + .all(|baseline| baseline == &status.baseline) + { + return Some(Baseline { + support: status, + asterisk: false, + }); + } + match status.baseline { + Some(BaselineHighLow::False(false)) => { + let Support { + chrome, + chrome_android, + firefox, + firefox_android, + safari, + safari_ios, + .. + } = &status.support; + if chrome == chrome_android + && firefox == firefox_android + && safari == safari_ios + { + return Some(Baseline { + support: status, + asterisk: false, + }); } - if let Some(by_key) = &status.by_compat_key { - if let Some(key_status) = by_key.get(bcd_key) { - if key_status.baseline == status.baseline { - return Some(status); - } - } + } + Some(BaselineHighLow::Low) => { + if sub_status + .iter() + .all(|ss| matches!(ss, Some(BaselineHighLow::Low | BaselineHighLow::High))) + { + return Some(Baseline { + support: status, + asterisk: false, + }); } } + _ => {} } + Some(Baseline { + support: status, + asterisk: true, + }) + } else { None - }) + } + } + + fn feature_status_internal(&self, bcd_key_spaced: &str) -> Option<&SupportStatusWithByKey> { + if let Ok(i) = self + .bcd_keys + .binary_search_by(|ks| ks.bcd_key.as_str().cmp(bcd_key_spaced)) + { + let feature_name = &self.bcd_keys[i].feature; + return self.feature_status_internal_with_feature_name(bcd_key_spaced, feature_name); + } + None + } + + fn feature_status_internal_with_feature_name( + &self, + bcd_key_spaced: &str, + feature_name: &str, + ) -> Option<&SupportStatusWithByKey> { + if let Some(feature_data) = self.features.get(feature_name) { + if feature_data.discouraged.is_some() { + return None; + } + if let Some(ref status) = feature_data.status { + if let Some(by_key) = &status.by_compat_key { + if let Some(key_status) = by_key.get(&unspaced(bcd_key_spaced)) { + if key_status.baseline == status.baseline { + return Some(status); + } + } + } + } + } + None } } @@ -112,7 +259,15 @@ pub struct FeatureData { pub snapshot: Vec, /** Whether developers are formally discouraged from using this feature */ #[serde(skip_serializing_if = "Option::is_none")] - pub discouraged: Option, + pub discouraged: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct Discouraged { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + according_to: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + alternatives: Vec, } #[derive(Deserialize, Serialize, Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] @@ -126,7 +281,25 @@ pub enum BrowserIdentifier { Safari, SafariIos, } - +#[derive( + Deserialize, Serialize, Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, JsonSchema, +)] +pub struct Support { + #[serde(default, skip_serializing_if = "Option::is_none")] + chrome: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + chrome_android: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + edge: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + firefox: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + firefox_android: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + safari: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + safari_ios: Option, +} #[derive(Deserialize, Serialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum BaselineHighLow { @@ -148,7 +321,7 @@ pub struct SupportStatus { #[serde(skip_serializing_if = "Option::is_none")] pub baseline_high_date: Option, /// Browser versions that most-recently introduced the feature - pub support: BTreeMap, + pub support: Support, } #[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] @@ -163,7 +336,7 @@ pub struct SupportStatusWithByKey { #[serde(skip_serializing_if = "Option::is_none")] pub baseline_high_date: Option, /// Browser versions that most-recently introduced the feature - pub support: BTreeMap, + pub support: Support, #[serde(default, skip_serializing)] pub by_compat_key: Option>, } diff --git a/crates/rari-doc/src/baseline.rs b/crates/rari-doc/src/baseline.rs index 72b315ba..d5d65246 100644 --- a/crates/rari-doc/src/baseline.rs +++ b/crates/rari-doc/src/baseline.rs @@ -5,7 +5,7 @@ //! support status for specific browser compatibility keys. use std::sync::LazyLock; -use rari_data::baseline::{SupportStatusWithByKey, WebFeatures}; +use rari_data::baseline::{Baseline, WebFeatures}; use rari_types::globals::data_dir; use tracing::warn; @@ -36,7 +36,7 @@ static WEB_FEATURES: LazyLock> = LazyLock::new(|| { /// /// * `Option<&'static SupportStatusWithByKey>` - Returns `Some(&SupportStatusWithByKey)` if the key is found, /// or `None` if the key is not found or `WEB_FEATURES` is not initialized. -pub(crate) fn get_baseline(browser_compat: &[String]) -> Option<&'static SupportStatusWithByKey> { +pub(crate) fn get_baseline<'a>(browser_compat: &[String]) -> Option> { if let Some(ref web_features) = *WEB_FEATURES { return match &browser_compat { &[bcd_key] => web_features.feature_status(bcd_key.as_str()), diff --git a/crates/rari-doc/src/pages/json.rs b/crates/rari-doc/src/pages/json.rs index 2d174d53..dd4d1b31 100644 --- a/crates/rari-doc/src/pages/json.rs +++ b/crates/rari-doc/src/pages/json.rs @@ -7,7 +7,7 @@ use std::path::PathBuf; use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc}; -use rari_data::baseline::SupportStatusWithByKey; +use rari_data::baseline::Baseline; use rari_types::fm_types::PageType; use rari_types::locale::{Locale, Native}; use schemars::JsonSchema; @@ -264,7 +264,7 @@ pub struct JsonDoc { pub title: String, pub toc: Vec, #[serde(skip_serializing_if = "Option::is_none")] - pub baseline: Option<&'static SupportStatusWithByKey>, + pub baseline: Option>, #[serde(rename = "browserCompat", skip_serializing_if = "Vec::is_empty")] pub browser_compat: Vec, #[serde(rename = "pageType")] @@ -349,7 +349,7 @@ pub struct JsonDocMetadata { pub summary: Option, pub title: String, #[serde(skip_serializing_if = "Option::is_none")] - pub baseline: Option<&'static SupportStatusWithByKey>, + pub baseline: Option>, #[serde(rename = "browserCompat", skip_serializing_if = "Vec::is_empty")] pub browser_compat: Vec, #[serde(rename = "pageType")]