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"];
391
392pub(crate) fn is_valid_data_attr_name(name: &str) -> bool {
395 if RESERVED_DATA_ATTR_NAMES.contains(&name) {
396 return false;
397 }
398 let mut chars = name.chars();
399 match chars.next() {
400 Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() => {}
401 _ => return false,
402 }
403 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
404}
405
406pub(crate) const INTERNAL_PREFIX: &str = "\u{0}ggplot-rs:";
417
418pub(crate) const DATA_ATTR_INTERNAL_PREFIX: &str = "\u{0}ggplot-rs:data-attr:";
425
426pub(crate) const TOOLTIP_INTERNAL_NAME: &str = "\u{0}ggplot-rs:tooltip";
432
433pub(crate) fn validated_data_attr_internal_name(data_attr_target: &str) -> Option<String> {
441 let suffix = data_attr_target.strip_prefix("data-")?;
442 if is_valid_data_attr_name(suffix) {
443 Some(format!("{DATA_ATTR_INTERNAL_PREFIX}{data_attr_target}"))
444 } else {
445 None
446 }
447}
448
449#[cfg(test)]
450mod data_attr_name_tests {
451 use super::{is_valid_data_attr_name, validated_data_attr_internal_name};
452
453 #[test]
454 fn accepts_lowercase_alnum_and_hyphen() {
455 for ok in ["x", "row", "data-ish", "a1", "0a", "fvalue"] {
456 assert!(is_valid_data_attr_name(ok), "{ok} should be valid");
457 }
458 }
459
460 #[test]
461 fn rejects_empty_uppercase_and_other_punctuation() {
462 for bad in ["", "-x", "X", "a_b", "a.b", "a b", "déjà"] {
463 assert!(!is_valid_data_attr_name(bad), "{bad} should be invalid");
464 }
465 }
466
467 #[test]
468 fn rejects_the_reserved_part_name() {
469 assert!(!is_valid_data_attr_name("part"));
470 assert_eq!(validated_data_attr_internal_name("data-part"), None);
471 }
472
473 #[test]
474 fn rejects_a_malformed_injection_attempt_even_with_the_data_prefix() {
475 assert_eq!(
477 validated_data_attr_internal_name("data-a onmouseover=alert(1) b"),
478 None
479 );
480 }
481
482 #[test]
483 fn valid_target_maps_to_the_internal_prefix_plus_itself() {
484 let internal = validated_data_attr_internal_name("data-orig").unwrap();
485 assert!(internal.starts_with(super::DATA_ATTR_INTERNAL_PREFIX));
486 assert!(internal.ends_with("data-orig"));
487 }
488}
489
490#[cfg(test)]
491mod aesthetic_and_aes_tests {
492 use super::{Aes, Aesthetic};
493
494 #[test]
498 fn col_name_for_tooltip_and_data_attr() {
499 assert_eq!(Aesthetic::Tooltip.col_name(), "tooltip");
500 assert_eq!(
501 Aesthetic::DataAttr("data-orig".to_string()).col_name(),
502 "data-orig"
503 );
504 }
505
506 #[test]
507 fn data_attr_warning_text_distinguishes_invalid_from_reserved() {
508 let invalid = Aes::new().data_attr("Not Valid!", "col");
509 assert_eq!(invalid.warnings.len(), 1);
510 assert!(invalid.warnings[0].contains("invalid"));
511 assert!(!invalid.warnings[0].contains("reserved"));
512
513 let reserved = Aes::new().data_attr("part", "col");
514 assert_eq!(reserved.warnings.len(), 1);
515 assert!(reserved.warnings[0].contains("reserved"));
516 }
517
518 #[test]
519 fn after_stat_linewidth_maps_a_stat_computed_column() {
520 let aes = Aes::new().after_stat_linewidth("weight");
521 assert_eq!(aes.get_mapping(&Aesthetic::Linewidth), Some("weight"));
522 }
523}