pub mod expr;
pub mod mapping;
pub use mapping::{apply_after_stat, resolve_mappings};
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum Aesthetic {
X,
Y,
Color,
Fill,
Size,
Shape,
Alpha,
Linetype,
Group,
Ymin,
Ymax,
Xmin,
Xmax,
Label,
Weight,
Xend,
Yend,
Angle,
Radius,
Linewidth,
Tooltip,
DataAttr(String),
Open,
High,
Low,
Close,
Xintercept,
Yintercept,
Slope,
Intercept,
Lower,
Middle,
Upper,
Outliers,
}
impl Aesthetic {
pub fn col_name(&self) -> &str {
match self {
Aesthetic::X => "x",
Aesthetic::Y => "y",
Aesthetic::Color => "color",
Aesthetic::Fill => "fill",
Aesthetic::Size => "size",
Aesthetic::Shape => "shape",
Aesthetic::Alpha => "alpha",
Aesthetic::Linetype => "linetype",
Aesthetic::Group => "group",
Aesthetic::Ymin => "ymin",
Aesthetic::Ymax => "ymax",
Aesthetic::Xmin => "xmin",
Aesthetic::Xmax => "xmax",
Aesthetic::Label => "label",
Aesthetic::Weight => "weight",
Aesthetic::Xend => "xend",
Aesthetic::Yend => "yend",
Aesthetic::Angle => "angle",
Aesthetic::Radius => "radius",
Aesthetic::Linewidth => "linewidth",
Aesthetic::Tooltip => "tooltip",
Aesthetic::DataAttr(name) => name,
Aesthetic::Open => "open",
Aesthetic::High => "high",
Aesthetic::Low => "low",
Aesthetic::Close => "close",
Aesthetic::Xintercept => "xintercept",
Aesthetic::Yintercept => "yintercept",
Aesthetic::Slope => "slope",
Aesthetic::Intercept => "intercept",
Aesthetic::Lower => "lower",
Aesthetic::Middle => "middle",
Aesthetic::Upper => "upper",
Aesthetic::Outliers => "outliers",
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum MappingStage {
BeforeStat,
AfterStat,
}
#[derive(Clone, Debug)]
pub struct AesMapping {
pub column: String,
pub aesthetic: Aesthetic,
pub stage: MappingStage,
}
#[derive(Clone, Debug)]
pub struct AfterScaleSpec {
pub target: Aesthetic,
pub source: Aesthetic,
pub lightness: f64,
}
#[derive(Clone, Debug, Default)]
pub struct Aes {
pub mappings: Vec<AesMapping>,
pub after_scale: Vec<AfterScaleSpec>,
pub(crate) warnings: Vec<String>,
}
impl Aes {
pub fn new() -> Self {
Self::default()
}
fn push(mut self, col: &str, aesthetic: Aesthetic) -> Self {
self.mappings.push(AesMapping {
column: col.to_string(),
aesthetic,
stage: MappingStage::BeforeStat,
});
self
}
fn push_after_stat(mut self, col: &str, aesthetic: Aesthetic) -> Self {
self.mappings.push(AesMapping {
column: col.to_string(),
aesthetic,
stage: MappingStage::AfterStat,
});
self
}
pub fn after_scale_fill_from_color(mut self, lightness: f64) -> Self {
self.after_scale.push(AfterScaleSpec {
target: Aesthetic::Fill,
source: Aesthetic::Color,
lightness,
});
self
}
pub fn after_scale_color_from_fill(mut self, lightness: f64) -> Self {
self.after_scale.push(AfterScaleSpec {
target: Aesthetic::Color,
source: Aesthetic::Fill,
lightness,
});
self
}
pub fn stage(mut self, aesthetic: Aesthetic, start: &str, after_stat: &str) -> Self {
self.mappings.push(AesMapping {
column: start.to_string(),
aesthetic: aesthetic.clone(),
stage: MappingStage::BeforeStat,
});
self.mappings.push(AesMapping {
column: after_stat.to_string(),
aesthetic,
stage: MappingStage::AfterStat,
});
self
}
pub fn x(self, col: &str) -> Self {
self.push(col, Aesthetic::X)
}
pub fn y(self, col: &str) -> Self {
self.push(col, Aesthetic::Y)
}
pub fn color(self, col: &str) -> Self {
self.push(col, Aesthetic::Color)
}
pub fn fill(self, col: &str) -> Self {
self.push(col, Aesthetic::Fill)
}
pub fn size(self, col: &str) -> Self {
self.push(col, Aesthetic::Size)
}
pub fn shape(self, col: &str) -> Self {
self.push(col, Aesthetic::Shape)
}
pub fn alpha(self, col: &str) -> Self {
self.push(col, Aesthetic::Alpha)
}
pub fn group(self, col: &str) -> Self {
self.push(col, Aesthetic::Group)
}
pub fn ymin(self, col: &str) -> Self {
self.push(col, Aesthetic::Ymin)
}
pub fn ymax(self, col: &str) -> Self {
self.push(col, Aesthetic::Ymax)
}
pub fn label(self, col: &str) -> Self {
self.push(col, Aesthetic::Label)
}
pub fn weight(self, col: &str) -> Self {
self.push(col, Aesthetic::Weight)
}
pub fn xend(self, col: &str) -> Self {
self.push(col, Aesthetic::Xend)
}
pub fn yend(self, col: &str) -> Self {
self.push(col, Aesthetic::Yend)
}
pub fn xmin(self, col: &str) -> Self {
self.push(col, Aesthetic::Xmin)
}
pub fn xmax(self, col: &str) -> Self {
self.push(col, Aesthetic::Xmax)
}
pub fn angle(self, col: &str) -> Self {
self.push(col, Aesthetic::Angle)
}
pub fn radius(self, col: &str) -> Self {
self.push(col, Aesthetic::Radius)
}
pub fn linetype(self, col: &str) -> Self {
self.push(col, Aesthetic::Linetype)
}
pub fn linewidth(self, col: &str) -> Self {
self.push(col, Aesthetic::Linewidth)
}
pub fn lower(self, col: &str) -> Self {
self.push(col, Aesthetic::Lower)
}
pub fn middle(self, col: &str) -> Self {
self.push(col, Aesthetic::Middle)
}
pub fn upper(self, col: &str) -> Self {
self.push(col, Aesthetic::Upper)
}
pub fn outliers(self, col: &str) -> Self {
self.push(col, Aesthetic::Outliers)
}
pub fn tooltip(self, col: &str) -> Self {
self.push(col, Aesthetic::Tooltip)
}
pub fn data_attr(mut self, name: &str, col: &str) -> Self {
if !is_valid_data_attr_name(name) {
let reason = if RESERVED_DATA_ATTR_NAMES.contains(&name) {
"reserved (written by ggplot-rs itself)"
} else {
"invalid (must match [a-z0-9][a-z0-9-]*)"
};
self.warnings
.push(format!("aes: data_attr name {name:?} is {reason}; skipped"));
return self;
}
self.push(col, Aesthetic::DataAttr(format!("data-{name}")))
}
pub fn open(self, col: &str) -> Self {
self.push(col, Aesthetic::Open)
}
pub fn high(self, col: &str) -> Self {
self.push(col, Aesthetic::High)
}
pub fn low(self, col: &str) -> Self {
self.push(col, Aesthetic::Low)
}
pub fn close(self, col: &str) -> Self {
self.push(col, Aesthetic::Close)
}
pub fn xintercept(self, col: &str) -> Self {
self.push(col, Aesthetic::Xintercept)
}
pub fn yintercept(self, col: &str) -> Self {
self.push(col, Aesthetic::Yintercept)
}
pub fn slope(self, col: &str) -> Self {
self.push(col, Aesthetic::Slope)
}
pub fn intercept(self, col: &str) -> Self {
self.push(col, Aesthetic::Intercept)
}
pub fn after_stat_y(self, col: &str) -> Self {
self.push_after_stat(col, Aesthetic::Y)
}
pub fn after_stat_x(self, col: &str) -> Self {
self.push_after_stat(col, Aesthetic::X)
}
pub fn after_stat_fill(self, col: &str) -> Self {
self.push_after_stat(col, Aesthetic::Fill)
}
pub fn after_stat_color(self, col: &str) -> Self {
self.push_after_stat(col, Aesthetic::Color)
}
pub fn after_stat_size(self, col: &str) -> Self {
self.push_after_stat(col, Aesthetic::Size)
}
pub fn after_stat_alpha(self, col: &str) -> Self {
self.push_after_stat(col, Aesthetic::Alpha)
}
pub fn after_stat_linewidth(self, col: &str) -> Self {
self.push_after_stat(col, Aesthetic::Linewidth)
}
pub fn get_mapping(&self, aes: &Aesthetic) -> Option<&str> {
self.mappings
.iter()
.find(|m| m.aesthetic == *aes)
.map(|m| m.column.as_str())
}
pub fn merge(&self, other: &Aes) -> Aes {
let mut result = self.clone();
for m in &other.mappings {
result
.mappings
.retain(|existing| existing.aesthetic != m.aesthetic);
result.mappings.push(m.clone());
}
result.warnings.extend(other.warnings.iter().cloned());
result
}
}
const RESERVED_DATA_ATTR_NAMES: &[&str] = &["part", "geom", "fvalue", "fx"];
pub(crate) fn is_valid_data_attr_name(name: &str) -> bool {
if RESERVED_DATA_ATTR_NAMES.contains(&name) {
return false;
}
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() => {}
_ => return false,
}
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
pub(crate) const INTERNAL_PREFIX: &str = "\u{0}ggplot-rs:";
pub(crate) const DATA_ATTR_INTERNAL_PREFIX: &str = "\u{0}ggplot-rs:data-attr:";
pub(crate) const TOOLTIP_INTERNAL_NAME: &str = "\u{0}ggplot-rs:tooltip";
pub(crate) const BOXPLOT_YMIN_FINAL: &str = "\u{0}ggplot-rs:ymin_final";
pub(crate) const BOXPLOT_YMAX_FINAL: &str = "\u{0}ggplot-rs:ymax_final";
pub(crate) fn validated_data_attr_internal_name(data_attr_target: &str) -> Option<String> {
let suffix = data_attr_target.strip_prefix("data-")?;
if is_valid_data_attr_name(suffix) {
Some(format!("{DATA_ATTR_INTERNAL_PREFIX}{data_attr_target}"))
} else {
None
}
}
#[cfg(test)]
mod data_attr_name_tests {
use super::{is_valid_data_attr_name, validated_data_attr_internal_name};
#[test]
fn accepts_lowercase_alnum_and_hyphen() {
for ok in ["x", "row", "data-ish", "a1", "0a", "value"] {
assert!(is_valid_data_attr_name(ok), "{ok} should be valid");
}
}
#[test]
fn rejects_empty_uppercase_and_other_punctuation() {
for bad in ["", "-x", "X", "a_b", "a.b", "a b", "déjà"] {
assert!(!is_valid_data_attr_name(bad), "{bad} should be invalid");
}
}
#[test]
fn rejects_the_reserved_part_name() {
assert!(!is_valid_data_attr_name("part"));
assert_eq!(validated_data_attr_internal_name("data-part"), None);
}
#[test]
fn rejects_the_reserved_geom_fvalue_and_fx_names() {
for name in ["geom", "fvalue", "fx"] {
assert!(!is_valid_data_attr_name(name), "{name} should be reserved");
assert_eq!(
validated_data_attr_internal_name(&format!("data-{name}")),
None
);
}
}
#[test]
fn rejects_a_malformed_injection_attempt_even_with_the_data_prefix() {
assert_eq!(
validated_data_attr_internal_name("data-a onmouseover=alert(1) b"),
None
);
}
#[test]
fn valid_target_maps_to_the_internal_prefix_plus_itself() {
let internal = validated_data_attr_internal_name("data-orig").unwrap();
assert!(internal.starts_with(super::DATA_ATTR_INTERNAL_PREFIX));
assert!(internal.ends_with("data-orig"));
}
}
#[cfg(test)]
mod aesthetic_and_aes_tests {
use super::{Aes, Aesthetic};
#[test]
fn col_name_for_tooltip_and_data_attr() {
assert_eq!(Aesthetic::Tooltip.col_name(), "tooltip");
assert_eq!(
Aesthetic::DataAttr("data-orig".to_string()).col_name(),
"data-orig"
);
}
#[test]
fn data_attr_warning_text_distinguishes_invalid_from_reserved() {
let invalid = Aes::new().data_attr("Not Valid!", "col");
assert_eq!(invalid.warnings.len(), 1);
assert!(invalid.warnings[0].contains("invalid"));
assert!(!invalid.warnings[0].contains("reserved"));
let reserved = Aes::new().data_attr("part", "col");
assert_eq!(reserved.warnings.len(), 1);
assert!(reserved.warnings[0].contains("reserved"));
}
#[test]
fn after_stat_linewidth_maps_a_stat_computed_column() {
let aes = Aes::new().after_stat_linewidth("weight");
assert_eq!(aes.get_mapping(&Aesthetic::Linewidth), Some("weight"));
}
}