1pub mod expr;
2pub mod mapping;
3
4pub use mapping::{apply_after_stat, resolve_mappings};
5
6#[derive(Clone, Debug, PartialEq, Eq, Hash)]
8pub enum Aesthetic {
9 X,
10 Y,
11 Color,
12 Fill,
13 Size,
14 Shape,
15 Alpha,
16 Linetype,
17 Group,
18 Ymin,
19 Ymax,
20 Xmin,
21 Xmax,
22 Label,
23 Weight,
24 Xend,
25 Yend,
26 Angle,
27 Radius,
28 Linewidth,
30 Tooltip,
35 DataAttr(String),
43 Open,
46 High,
47 Low,
48 Close,
49 Xintercept,
53 Yintercept,
54 Slope,
55 Intercept,
56}
57
58impl Aesthetic {
59 pub fn col_name(&self) -> &str {
61 match self {
62 Aesthetic::X => "x",
63 Aesthetic::Y => "y",
64 Aesthetic::Color => "color",
65 Aesthetic::Fill => "fill",
66 Aesthetic::Size => "size",
67 Aesthetic::Shape => "shape",
68 Aesthetic::Alpha => "alpha",
69 Aesthetic::Linetype => "linetype",
70 Aesthetic::Group => "group",
71 Aesthetic::Ymin => "ymin",
72 Aesthetic::Ymax => "ymax",
73 Aesthetic::Xmin => "xmin",
74 Aesthetic::Xmax => "xmax",
75 Aesthetic::Label => "label",
76 Aesthetic::Weight => "weight",
77 Aesthetic::Xend => "xend",
78 Aesthetic::Yend => "yend",
79 Aesthetic::Angle => "angle",
80 Aesthetic::Radius => "radius",
81 Aesthetic::Linewidth => "linewidth",
82 Aesthetic::Tooltip => "tooltip",
83 Aesthetic::DataAttr(name) => name,
84 Aesthetic::Open => "open",
85 Aesthetic::High => "high",
86 Aesthetic::Low => "low",
87 Aesthetic::Close => "close",
88 Aesthetic::Xintercept => "xintercept",
89 Aesthetic::Yintercept => "yintercept",
90 Aesthetic::Slope => "slope",
91 Aesthetic::Intercept => "intercept",
92 }
93 }
94}
95
96#[derive(Clone, Debug, PartialEq)]
98pub enum MappingStage {
99 BeforeStat,
101 AfterStat,
103}
104
105#[derive(Clone, Debug)]
107pub struct AesMapping {
108 pub column: String,
109 pub aesthetic: Aesthetic,
110 pub stage: MappingStage,
111}
112
113#[derive(Clone, Debug)]
117pub struct AfterScaleSpec {
118 pub target: Aesthetic,
119 pub source: Aesthetic,
120 pub lightness: f64,
121}
122
123#[derive(Clone, Debug, Default)]
125pub struct Aes {
126 pub mappings: Vec<AesMapping>,
127 pub after_scale: Vec<AfterScaleSpec>,
129 pub(crate) warnings: Vec<String>,
133}
134
135impl Aes {
136 pub fn new() -> Self {
137 Self::default()
138 }
139
140 fn push(mut self, col: &str, aesthetic: Aesthetic) -> Self {
141 self.mappings.push(AesMapping {
142 column: col.to_string(),
143 aesthetic,
144 stage: MappingStage::BeforeStat,
145 });
146 self
147 }
148
149 fn push_after_stat(mut self, col: &str, aesthetic: Aesthetic) -> Self {
150 self.mappings.push(AesMapping {
151 column: col.to_string(),
152 aesthetic,
153 stage: MappingStage::AfterStat,
154 });
155 self
156 }
157
158 pub fn after_scale_fill_from_color(mut self, lightness: f64) -> Self {
164 self.after_scale.push(AfterScaleSpec {
165 target: Aesthetic::Fill,
166 source: Aesthetic::Color,
167 lightness,
168 });
169 self
170 }
171
172 pub fn after_scale_color_from_fill(mut self, lightness: f64) -> Self {
175 self.after_scale.push(AfterScaleSpec {
176 target: Aesthetic::Color,
177 source: Aesthetic::Fill,
178 lightness,
179 });
180 self
181 }
182
183 pub fn stage(mut self, aesthetic: Aesthetic, start: &str, after_stat: &str) -> Self {
186 self.mappings.push(AesMapping {
187 column: start.to_string(),
188 aesthetic: aesthetic.clone(),
189 stage: MappingStage::BeforeStat,
190 });
191 self.mappings.push(AesMapping {
192 column: after_stat.to_string(),
193 aesthetic,
194 stage: MappingStage::AfterStat,
195 });
196 self
197 }
198
199 pub fn x(self, col: &str) -> Self {
200 self.push(col, Aesthetic::X)
201 }
202 pub fn y(self, col: &str) -> Self {
203 self.push(col, Aesthetic::Y)
204 }
205 pub fn color(self, col: &str) -> Self {
206 self.push(col, Aesthetic::Color)
207 }
208 pub fn fill(self, col: &str) -> Self {
209 self.push(col, Aesthetic::Fill)
210 }
211 pub fn size(self, col: &str) -> Self {
212 self.push(col, Aesthetic::Size)
213 }
214 pub fn shape(self, col: &str) -> Self {
215 self.push(col, Aesthetic::Shape)
216 }
217 pub fn alpha(self, col: &str) -> Self {
218 self.push(col, Aesthetic::Alpha)
219 }
220 pub fn group(self, col: &str) -> Self {
221 self.push(col, Aesthetic::Group)
222 }
223 pub fn ymin(self, col: &str) -> Self {
224 self.push(col, Aesthetic::Ymin)
225 }
226 pub fn ymax(self, col: &str) -> Self {
227 self.push(col, Aesthetic::Ymax)
228 }
229 pub fn label(self, col: &str) -> Self {
230 self.push(col, Aesthetic::Label)
231 }
232 pub fn weight(self, col: &str) -> Self {
233 self.push(col, Aesthetic::Weight)
234 }
235 pub fn xend(self, col: &str) -> Self {
236 self.push(col, Aesthetic::Xend)
237 }
238 pub fn yend(self, col: &str) -> Self {
239 self.push(col, Aesthetic::Yend)
240 }
241 pub fn xmin(self, col: &str) -> Self {
242 self.push(col, Aesthetic::Xmin)
243 }
244 pub fn xmax(self, col: &str) -> Self {
245 self.push(col, Aesthetic::Xmax)
246 }
247 pub fn angle(self, col: &str) -> Self {
248 self.push(col, Aesthetic::Angle)
249 }
250 pub fn radius(self, col: &str) -> Self {
251 self.push(col, Aesthetic::Radius)
252 }
253 pub fn linetype(self, col: &str) -> Self {
254 self.push(col, Aesthetic::Linetype)
255 }
256 pub fn linewidth(self, col: &str) -> Self {
258 self.push(col, Aesthetic::Linewidth)
259 }
260
261 pub fn tooltip(self, col: &str) -> Self {
266 self.push(col, Aesthetic::Tooltip)
267 }
268
269 pub fn data_attr(mut self, name: &str, col: &str) -> Self {
278 if !is_valid_data_attr_name(name) {
279 let reason = if RESERVED_DATA_ATTR_NAMES.contains(&name) {
280 "reserved (written by ggplot-rs itself)"
281 } else {
282 "invalid (must match [a-z0-9][a-z0-9-]*)"
283 };
284 self.warnings
285 .push(format!("aes: data_attr name {name:?} is {reason}; skipped"));
286 return self;
287 }
288 self.push(col, Aesthetic::DataAttr(format!("data-{name}")))
289 }
290 pub fn open(self, col: &str) -> Self {
292 self.push(col, Aesthetic::Open)
293 }
294 pub fn high(self, col: &str) -> Self {
296 self.push(col, Aesthetic::High)
297 }
298 pub fn low(self, col: &str) -> Self {
300 self.push(col, Aesthetic::Low)
301 }
302 pub fn close(self, col: &str) -> Self {
304 self.push(col, Aesthetic::Close)
305 }
306 pub fn xintercept(self, col: &str) -> Self {
308 self.push(col, Aesthetic::Xintercept)
309 }
310 pub fn yintercept(self, col: &str) -> Self {
312 self.push(col, Aesthetic::Yintercept)
313 }
314 pub fn slope(self, col: &str) -> Self {
316 self.push(col, Aesthetic::Slope)
317 }
318 pub fn intercept(self, col: &str) -> Self {
320 self.push(col, Aesthetic::Intercept)
321 }
322
323 pub fn after_stat_y(self, col: &str) -> Self {
329 self.push_after_stat(col, Aesthetic::Y)
330 }
331
332 pub fn after_stat_x(self, col: &str) -> Self {
334 self.push_after_stat(col, Aesthetic::X)
335 }
336
337 pub fn after_stat_fill(self, col: &str) -> Self {
339 self.push_after_stat(col, Aesthetic::Fill)
340 }
341
342 pub fn after_stat_color(self, col: &str) -> Self {
344 self.push_after_stat(col, Aesthetic::Color)
345 }
346
347 pub fn after_stat_size(self, col: &str) -> Self {
349 self.push_after_stat(col, Aesthetic::Size)
350 }
351
352 pub fn after_stat_alpha(self, col: &str) -> Self {
354 self.push_after_stat(col, Aesthetic::Alpha)
355 }
356
357 pub fn after_stat_linewidth(self, col: &str) -> Self {
359 self.push_after_stat(col, Aesthetic::Linewidth)
360 }
361
362 pub fn get_mapping(&self, aes: &Aesthetic) -> Option<&str> {
364 self.mappings
365 .iter()
366 .find(|m| m.aesthetic == *aes)
367 .map(|m| m.column.as_str())
368 }
369
370 pub fn merge(&self, other: &Aes) -> Aes {
372 let mut result = self.clone();
373 for m in &other.mappings {
374 result
376 .mappings
377 .retain(|existing| existing.aesthetic != m.aesthetic);
378 result.mappings.push(m.clone());
379 }
380 result.warnings.extend(other.warnings.iter().cloned());
381 result
382 }
383}
384
385const RESERVED_DATA_ATTR_NAMES: &[&str] = &["part", "geom", "fvalue", "fx"];
394
395pub(crate) fn is_valid_data_attr_name(name: &str) -> bool {
398 if RESERVED_DATA_ATTR_NAMES.contains(&name) {
399 return false;
400 }
401 let mut chars = name.chars();
402 match chars.next() {
403 Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() => {}
404 _ => return false,
405 }
406 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
407}
408
409pub(crate) const INTERNAL_PREFIX: &str = "\u{0}ggplot-rs:";
420
421pub(crate) const DATA_ATTR_INTERNAL_PREFIX: &str = "\u{0}ggplot-rs:data-attr:";
428
429pub(crate) const TOOLTIP_INTERNAL_NAME: &str = "\u{0}ggplot-rs:tooltip";
435
436pub(crate) fn validated_data_attr_internal_name(data_attr_target: &str) -> Option<String> {
444 let suffix = data_attr_target.strip_prefix("data-")?;
445 if is_valid_data_attr_name(suffix) {
446 Some(format!("{DATA_ATTR_INTERNAL_PREFIX}{data_attr_target}"))
447 } else {
448 None
449 }
450}
451
452#[cfg(test)]
453mod data_attr_name_tests {
454 use super::{is_valid_data_attr_name, validated_data_attr_internal_name};
455
456 #[test]
457 fn accepts_lowercase_alnum_and_hyphen() {
458 for ok in ["x", "row", "data-ish", "a1", "0a", "value"] {
459 assert!(is_valid_data_attr_name(ok), "{ok} should be valid");
460 }
461 }
462
463 #[test]
464 fn rejects_empty_uppercase_and_other_punctuation() {
465 for bad in ["", "-x", "X", "a_b", "a.b", "a b", "déjà"] {
466 assert!(!is_valid_data_attr_name(bad), "{bad} should be invalid");
467 }
468 }
469
470 #[test]
471 fn rejects_the_reserved_part_name() {
472 assert!(!is_valid_data_attr_name("part"));
473 assert_eq!(validated_data_attr_internal_name("data-part"), None);
474 }
475
476 #[test]
477 fn rejects_the_reserved_geom_fvalue_and_fx_names() {
478 for name in ["geom", "fvalue", "fx"] {
482 assert!(!is_valid_data_attr_name(name), "{name} should be reserved");
483 assert_eq!(
484 validated_data_attr_internal_name(&format!("data-{name}")),
485 None
486 );
487 }
488 }
489
490 #[test]
491 fn rejects_a_malformed_injection_attempt_even_with_the_data_prefix() {
492 assert_eq!(
494 validated_data_attr_internal_name("data-a onmouseover=alert(1) b"),
495 None
496 );
497 }
498
499 #[test]
500 fn valid_target_maps_to_the_internal_prefix_plus_itself() {
501 let internal = validated_data_attr_internal_name("data-orig").unwrap();
502 assert!(internal.starts_with(super::DATA_ATTR_INTERNAL_PREFIX));
503 assert!(internal.ends_with("data-orig"));
504 }
505}
506
507#[cfg(test)]
508mod aesthetic_and_aes_tests {
509 use super::{Aes, Aesthetic};
510
511 #[test]
515 fn col_name_for_tooltip_and_data_attr() {
516 assert_eq!(Aesthetic::Tooltip.col_name(), "tooltip");
517 assert_eq!(
518 Aesthetic::DataAttr("data-orig".to_string()).col_name(),
519 "data-orig"
520 );
521 }
522
523 #[test]
524 fn data_attr_warning_text_distinguishes_invalid_from_reserved() {
525 let invalid = Aes::new().data_attr("Not Valid!", "col");
526 assert_eq!(invalid.warnings.len(), 1);
527 assert!(invalid.warnings[0].contains("invalid"));
528 assert!(!invalid.warnings[0].contains("reserved"));
529
530 let reserved = Aes::new().data_attr("part", "col");
531 assert_eq!(reserved.warnings.len(), 1);
532 assert!(reserved.warnings[0].contains("reserved"));
533 }
534
535 #[test]
536 fn after_stat_linewidth_maps_a_stat_computed_column() {
537 let aes = Aes::new().after_stat_linewidth("weight");
538 assert_eq!(aes.get_mapping(&Aesthetic::Linewidth), Some("weight"));
539 }
540}