ggplot_rs/aes/mod.rs
1pub mod expr;
2pub mod mapping;
3
4pub use mapping::{apply_after_stat, resolve_mappings};
5
6/// All supported aesthetic channels.
7#[derive(Clone, Debug, PartialEq, Eq, Hash)]
8#[non_exhaustive]
9pub enum Aesthetic {
10 X,
11 Y,
12 Color,
13 Fill,
14 Size,
15 Shape,
16 Alpha,
17 Linetype,
18 Group,
19 Ymin,
20 Ymax,
21 Xmin,
22 Xmax,
23 Label,
24 Weight,
25 Xend,
26 Yend,
27 Angle,
28 /// Per-row horizontal justification for `geom_text` (R's `hjust`
29 /// aesthetic): `0` left, `0.5` centre, `1` right; below `0` / above `1`
30 /// moves the text further by that share of its own width. No scale.
31 Hjust,
32 /// Per-row vertical justification for `geom_text` (R's `vjust`): `0`
33 /// above the point, `0.5` centred, `1` below; beyond moves it further by
34 /// that share of its height. No scale.
35 Vjust,
36 Radius,
37 /// Stroke width for segment/curve/path/line (R's `linewidth`).
38 Linewidth,
39 /// Per-row font face for `geom_text`/`geom_label` (#228, R's
40 /// `fontface`): `"plain"`/`"normal"`, `"bold"`, `"italic"`/`"oblique"`
41 /// (case-insensitive), the same strings `theme()`'s `face` accepts. An
42 /// unparseable value falls back to the geom's own fixed `fontface`
43 /// (`GeomText`/`GeomLabel`'s `.with_fontface(..)`, default `Plain`) —
44 /// no build warning, since the value is only known per row. Carries its
45 /// value straight through to [`crate::render::backend::TextStyle::face`]
46 /// — no scale, no legend (like `Tooltip`/`Class`).
47 Fontface,
48 /// A custom `<title>` hover tooltip, replacing the geom's own. Routed
49 /// through [`crate::aes::TOOLTIP_INTERNAL_NAME`] in `resolve_mappings`,
50 /// the same way `DataAttr` is, so an unrelated raw column literally
51 /// named `"tooltip"` can never be mistaken for a mapped one.
52 Tooltip,
53 /// A custom `data-<name>` attribute on the mark. The `String` is the
54 /// full attribute name (`"data-<name>"`). [`Aes::data_attr`] validates
55 /// `name` against `[a-z0-9][a-z0-9-]*` (excluding reserved names) before
56 /// constructing this; a `DataAttr` built directly — bypassing that
57 /// builder — is re-validated in `resolve_mappings`
58 /// (`aes::validated_data_attr_internal_name`), the single authoritative
59 /// gate regardless of how the mapping was created.
60 DataAttr(String),
61 /// CSS class tokens on the mark (#221, [`Aes::class`]): a
62 /// space-separated list, each token matching `[A-Za-z][A-Za-z0-9_-]*`
63 /// (invalid tokens are dropped with a build warning). Routed through
64 /// [`CLASS_INTERNAL_NAME`] the same way `Tooltip` is. No scale, no
65 /// legend.
66 Class,
67 /// A caller-named group wrapper (#221, [`Aes::group_wrap`]): every mark
68 /// whose row has the same value — across all layers that map the same
69 /// name — is drawn inside one `<g data-<name>="<value>">`. The `String`
70 /// is the full attribute name (`"data-<name>"`), validated like
71 /// [`DataAttr`](Self::DataAttr)'s. No scale, no legend.
72 GroupWrap(String),
73 /// Financial OHLC aesthetics (`geom_candlestick` / `geom_ohlc`); they
74 /// share the y position scale.
75 Open,
76 High,
77 Low,
78 Close,
79 /// Reference-line aesthetics (`geom_hline` / `geom_vline` /
80 /// `geom_abline`). `xintercept`/`yintercept` share (and train) the x / y
81 /// position scale, as in ggplot2; `slope`/`intercept` get no scale.
82 Xintercept,
83 Yintercept,
84 Slope,
85 Intercept,
86 /// Boxplot `stat="identity"` aesthetics (B3-5; ggplot2 extension, D4):
87 /// the box's lower/middle/upper hinges. Live on the y scale (D-style,
88 /// like `ymin`/`ymax`) — [`crate::scale::ScaleSet::ensure_scale`] gives
89 /// them no scale of their own.
90 Lower,
91 Middle,
92 Upper,
93 /// A comma-separated list of outlier values, optionally bracketed
94 /// (`"7, 9"` or `"[7, 9]"`) — ggplot2 extension, D4. Unparsable parts
95 /// are skipped with one warning. Lives on the y scale, like `Lower`/
96 /// `Middle`/`Upper`.
97 Outliers,
98 /// Edge-list aesthetics for flow / network charts (`geom_sankey`): the
99 /// source and target node of each link. Node names, not positions — no
100 /// scale, no legend.
101 From,
102 To,
103 /// Hierarchy parent of a row (`geom_treemap`): rows sharing a subgroup
104 /// are tiled together inside one group rectangle. A label, not a
105 /// position — no scale, no legend.
106 Subgroup,
107 /// Set combination of a row (`geom_venn`): one set name (`"A"`) or
108 /// several joined by `&` (`"A&B"`). A label — no scale, no legend.
109 Sets,
110}
111
112impl Aesthetic {
113 /// The canonical column name used in the working DataFrame after aes evaluation.
114 pub fn col_name(&self) -> &str {
115 match self {
116 Aesthetic::X => "x",
117 Aesthetic::Y => "y",
118 Aesthetic::Color => "color",
119 Aesthetic::Fill => "fill",
120 Aesthetic::Size => "size",
121 Aesthetic::Shape => "shape",
122 Aesthetic::Alpha => "alpha",
123 Aesthetic::Linetype => "linetype",
124 Aesthetic::Group => "group",
125 Aesthetic::Ymin => "ymin",
126 Aesthetic::Ymax => "ymax",
127 Aesthetic::Xmin => "xmin",
128 Aesthetic::Xmax => "xmax",
129 Aesthetic::Label => "label",
130 Aesthetic::Weight => "weight",
131 Aesthetic::Xend => "xend",
132 Aesthetic::Yend => "yend",
133 Aesthetic::Angle => "angle",
134 Aesthetic::Hjust => "hjust",
135 Aesthetic::Vjust => "vjust",
136 Aesthetic::Radius => "radius",
137 Aesthetic::Linewidth => "linewidth",
138 Aesthetic::Fontface => "fontface",
139 Aesthetic::Tooltip => "tooltip",
140 Aesthetic::DataAttr(name) => name,
141 Aesthetic::Class => "class",
142 Aesthetic::GroupWrap(name) => name,
143 Aesthetic::Open => "open",
144 Aesthetic::High => "high",
145 Aesthetic::Low => "low",
146 Aesthetic::Close => "close",
147 Aesthetic::Xintercept => "xintercept",
148 Aesthetic::Yintercept => "yintercept",
149 Aesthetic::Slope => "slope",
150 Aesthetic::Intercept => "intercept",
151 Aesthetic::Lower => "lower",
152 Aesthetic::Middle => "middle",
153 Aesthetic::Upper => "upper",
154 Aesthetic::Outliers => "outliers",
155 Aesthetic::From => "from",
156 Aesthetic::To => "to",
157 Aesthetic::Subgroup => "subgroup",
158 Aesthetic::Sets => "sets",
159 }
160 }
161}
162
163/// When an aesthetic mapping should be resolved.
164#[derive(Clone, Debug, PartialEq)]
165pub enum MappingStage {
166 /// Resolve before stat computation (default — maps from raw data columns).
167 BeforeStat,
168 /// Resolve after stat computation (maps from stat-computed columns like `density`, `count`).
169 AfterStat,
170}
171
172/// Maps a source column to an aesthetic channel.
173#[derive(Clone, Debug)]
174pub struct AesMapping {
175 pub column: String,
176 pub aesthetic: Aesthetic,
177 pub stage: MappingStage,
178}
179
180/// A post-scale aesthetic derivation (R's `after_scale`): the `target` color
181/// aesthetic is set to the `source` aesthetic's *mapped* color, adjusted in
182/// lightness (`+` toward white, `-` toward black, in `-1.0..=1.0`).
183#[derive(Clone, Debug)]
184pub struct AfterScaleSpec {
185 pub target: Aesthetic,
186 pub source: Aesthetic,
187 pub lightness: f64,
188}
189
190/// Builder for aesthetic mappings.
191#[derive(Clone, Debug, Default)]
192pub struct Aes {
193 pub mappings: Vec<AesMapping>,
194 /// Post-scale color derivations (`after_scale`).
195 pub after_scale: Vec<AfterScaleSpec>,
196 /// Non-fatal build warnings (e.g. an invalid [`data_attr`](Self::data_attr)
197 /// name), merged into [`GGPlot`](crate::plot::GGPlot)'s warnings by
198 /// `aes()`/`layer_aes()`.
199 pub(crate) warnings: Vec<String>,
200}
201
202impl Aes {
203 pub fn new() -> Self {
204 Self::default()
205 }
206
207 fn push(mut self, col: &str, aesthetic: Aesthetic) -> Self {
208 self.mappings.push(AesMapping {
209 column: col.to_string(),
210 aesthetic,
211 stage: MappingStage::BeforeStat,
212 });
213 self
214 }
215
216 fn push_after_stat(mut self, col: &str, aesthetic: Aesthetic) -> Self {
217 self.mappings.push(AesMapping {
218 column: col.to_string(),
219 aesthetic,
220 stage: MappingStage::AfterStat,
221 });
222 self
223 }
224
225 // ─── after_scale() — post-scale color derivation ────────────
226
227 /// Set `fill` to the mapped `color` with adjusted lightness (R's
228 /// `aes(fill = after_scale(...))`). `lightness` in `-1.0..=1.0`: positive
229 /// lightens toward white, negative darkens toward black.
230 pub fn after_scale_fill_from_color(mut self, lightness: f64) -> Self {
231 self.after_scale.push(AfterScaleSpec {
232 target: Aesthetic::Fill,
233 source: Aesthetic::Color,
234 lightness,
235 });
236 self
237 }
238
239 /// Set `color` to the mapped `fill` with adjusted lightness — useful for a
240 /// darker border around a filled shape (`color = after_scale(darken(fill))`).
241 pub fn after_scale_color_from_fill(mut self, lightness: f64) -> Self {
242 self.after_scale.push(AfterScaleSpec {
243 target: Aesthetic::Color,
244 source: Aesthetic::Fill,
245 lightness,
246 });
247 self
248 }
249
250 /// `stage(start, after_stat)`: map `aesthetic` from `start` before the stat
251 /// and re-map it from `after_stat` afterwards (R's `stage()`).
252 pub fn stage(mut self, aesthetic: Aesthetic, start: &str, after_stat: &str) -> Self {
253 self.mappings.push(AesMapping {
254 column: start.to_string(),
255 aesthetic: aesthetic.clone(),
256 stage: MappingStage::BeforeStat,
257 });
258 self.mappings.push(AesMapping {
259 column: after_stat.to_string(),
260 aesthetic,
261 stage: MappingStage::AfterStat,
262 });
263 self
264 }
265
266 pub fn x(self, col: &str) -> Self {
267 self.push(col, Aesthetic::X)
268 }
269 pub fn y(self, col: &str) -> Self {
270 self.push(col, Aesthetic::Y)
271 }
272 pub fn color(self, col: &str) -> Self {
273 self.push(col, Aesthetic::Color)
274 }
275 pub fn fill(self, col: &str) -> Self {
276 self.push(col, Aesthetic::Fill)
277 }
278 pub fn size(self, col: &str) -> Self {
279 self.push(col, Aesthetic::Size)
280 }
281 pub fn shape(self, col: &str) -> Self {
282 self.push(col, Aesthetic::Shape)
283 }
284 pub fn alpha(self, col: &str) -> Self {
285 self.push(col, Aesthetic::Alpha)
286 }
287 pub fn group(self, col: &str) -> Self {
288 self.push(col, Aesthetic::Group)
289 }
290 pub fn ymin(self, col: &str) -> Self {
291 self.push(col, Aesthetic::Ymin)
292 }
293 pub fn ymax(self, col: &str) -> Self {
294 self.push(col, Aesthetic::Ymax)
295 }
296 pub fn label(self, col: &str) -> Self {
297 self.push(col, Aesthetic::Label)
298 }
299 pub fn weight(self, col: &str) -> Self {
300 self.push(col, Aesthetic::Weight)
301 }
302 pub fn xend(self, col: &str) -> Self {
303 self.push(col, Aesthetic::Xend)
304 }
305 pub fn yend(self, col: &str) -> Self {
306 self.push(col, Aesthetic::Yend)
307 }
308 pub fn xmin(self, col: &str) -> Self {
309 self.push(col, Aesthetic::Xmin)
310 }
311 pub fn xmax(self, col: &str) -> Self {
312 self.push(col, Aesthetic::Xmax)
313 }
314 pub fn angle(self, col: &str) -> Self {
315 self.push(col, Aesthetic::Angle)
316 }
317 /// Per-row `hjust` for `geom_text` from `col` (numbers; see
318 /// [`Aesthetic::Hjust`]). A missing value keeps the geom's own `hjust`.
319 pub fn hjust(self, col: &str) -> Self {
320 self.push(col, Aesthetic::Hjust)
321 }
322 /// Per-row `vjust` for `geom_text` from `col` (see [`Aesthetic::Vjust`]).
323 pub fn vjust(self, col: &str) -> Self {
324 self.push(col, Aesthetic::Vjust)
325 }
326 pub fn radius(self, col: &str) -> Self {
327 self.push(col, Aesthetic::Radius)
328 }
329 pub fn linetype(self, col: &str) -> Self {
330 self.push(col, Aesthetic::Linetype)
331 }
332 /// Stroke width for segment/curve/path/line (R's `linewidth`).
333 pub fn linewidth(self, col: &str) -> Self {
334 self.push(col, Aesthetic::Linewidth)
335 }
336 /// Boxplot `stat="identity"` lower hinge (B3-5).
337 pub fn lower(self, col: &str) -> Self {
338 self.push(col, Aesthetic::Lower)
339 }
340 /// Boxplot `stat="identity"` middle (median) line (B3-5).
341 pub fn middle(self, col: &str) -> Self {
342 self.push(col, Aesthetic::Middle)
343 }
344 /// Boxplot `stat="identity"` upper hinge (B3-5).
345 pub fn upper(self, col: &str) -> Self {
346 self.push(col, Aesthetic::Upper)
347 }
348 /// Boxplot `stat="identity"` outlier values, as a comma list with
349 /// optional brackets (`"7, 9"` or `"[7, 9]"`) (B3-5).
350 pub fn outliers(self, col: &str) -> Self {
351 self.push(col, Aesthetic::Outliers)
352 }
353
354 /// A custom `<title>` hover tooltip from `col`, replacing the geom's
355 /// own. `ensure_scale` skips it (no scale, no legend): computing stats
356 /// drop the column (identity-stat layers keep it), as `linetype`/`alpha`
357 /// do.
358 pub fn tooltip(self, col: &str) -> Self {
359 self.push(col, Aesthetic::Tooltip)
360 }
361
362 /// Per-row font face for `geom_text`/`geom_label` from `col` (#228, R's
363 /// `aes(fontface = ...)`): each row's value is parsed the same way
364 /// `theme()`'s `face` is (`"plain"`/`"normal"`, `"bold"`,
365 /// `"italic"`/`"oblique"`, case-insensitive); an unparseable or `NA`
366 /// value falls back to the geom's own fixed `.with_fontface(..)`. No
367 /// scale, no legend (like [`tooltip`](Self::tooltip)).
368 pub fn fontface(self, col: &str) -> Self {
369 self.push(col, Aesthetic::Fontface)
370 }
371
372 /// A custom `data-<name>` attribute on the mark, from `col`. `name` must
373 /// match `[a-z0-9][a-z0-9-]*` and not be a name ggplot-rs itself writes
374 /// via another mechanism (currently just `"part"` — see
375 /// `DrawBackend::set_mark_part`); an invalid or reserved name is skipped
376 /// with a build warning (surfaced via
377 /// [`GGPlot::aes`](crate::plot::GGPlot::aes) / `layer_aes`). A user attr
378 /// replaces a built-in of the same name (`data-x`, `data-series`,
379 /// `data-value`) — never duplicated.
380 pub fn data_attr(mut self, name: &str, col: &str) -> Self {
381 if !is_valid_data_attr_name(name) {
382 let reason = if RESERVED_DATA_ATTR_NAMES.contains(&name) {
383 "reserved (written by ggplot-rs itself)"
384 } else {
385 "invalid (must match [a-z0-9][a-z0-9-]*)"
386 };
387 self.warnings
388 .push(format!("aes: data_attr name {name:?} is {reason}; skipped"));
389 return self;
390 }
391 self.push(col, Aesthetic::DataAttr(format!("data-{name}")))
392 }
393
394 /// CSS class tokens on each mark, from `col` (#221): the row's value is
395 /// split on whitespace and every token must match
396 /// `[A-Za-z][A-Za-z0-9_-]*`; an invalid token is dropped (with one build
397 /// warning per layer), the rest are kept. The tokens are appended to the
398 /// mark's own `class="gg-mark gg-<kind>"` — there is never a second
399 /// `class` attribute, and never an inline `style` (so a strict CSP with
400 /// no `unsafe-inline` still holds: style the classes from an external
401 /// stylesheet). Read by every geom that reads [`data_attr`](Self::data_attr),
402 /// plus `geom_text`/`geom_label`. For one constant class on a whole
403 /// layer use [`GGPlot::layer_svg_attr`](crate::plot::GGPlot::layer_svg_attr)
404 /// `("class", "…")` instead.
405 pub fn class(self, col: &str) -> Self {
406 self.push(col, Aesthetic::Class)
407 }
408
409 /// Wrap marks in caller-named groups (#221): every mark whose row has
410 /// the same `col` value is drawn inside one `<g data-<name>="<value>">`
411 /// — across *all* layers that map `group_wrap` with the same `name` — so
412 /// client code can address (or tween) e.g. a diagram node's box, label
413 /// and badge as one element: `querySelector('[data-of="node-7"]')`.
414 ///
415 /// `name` follows [`data_attr`](Self::data_attr)'s rules
416 /// (`[a-z0-9][a-z0-9-]*`, not reserved); a leading `data-` is accepted
417 /// and stripped, so `group_wrap("of", …)` and `group_wrap("data-of", …)`
418 /// are the same. An invalid name is skipped with a build warning. A row
419 /// whose value is NA is drawn ungrouped.
420 ///
421 /// Paint order: a group is placed where its *first* mark would have
422 /// been drawn, and later marks of the same group are appended inside
423 /// it. So with a box layer then a label layer, both grouped by node,
424 /// the output is `<g data-of="a">box-a label-a</g><g data-of="b">box-b
425 /// label-b</g>` — node `b`'s box now paints over node `a`'s label,
426 /// which only matters if they overlap. Ungrouped marks keep their
427 /// positions. SVG backend only; raster backends ignore grouping.
428 pub fn group_wrap(mut self, name: &str, col: &str) -> Self {
429 let suffix = name.strip_prefix("data-").unwrap_or(name);
430 if !is_valid_data_attr_name(suffix) {
431 let reason = if RESERVED_DATA_ATTR_NAMES.contains(&suffix) {
432 "reserved (written by ggplot-rs itself)"
433 } else {
434 "invalid (must match [a-z0-9][a-z0-9-]*)"
435 };
436 self.warnings.push(format!(
437 "aes: group_wrap name {name:?} is {reason}; skipped"
438 ));
439 return self;
440 }
441 self.push(col, Aesthetic::GroupWrap(format!("data-{suffix}")))
442 }
443 /// Source node of a link (`geom_sankey`).
444 pub fn from(self, col: &str) -> Self {
445 self.push(col, Aesthetic::From)
446 }
447 /// Target node of a link (`geom_sankey`).
448 pub fn to(self, col: &str) -> Self {
449 self.push(col, Aesthetic::To)
450 }
451 /// Set combination of each row (`geom_venn`): `"A"`, `"A&B"`, …
452 pub fn sets(self, col: &str) -> Self {
453 self.push(col, Aesthetic::Sets)
454 }
455 /// Hierarchy parent of each row (`geom_treemap`).
456 pub fn subgroup(self, col: &str) -> Self {
457 self.push(col, Aesthetic::Subgroup)
458 }
459 /// Opening price (`geom_candlestick` / `geom_ohlc`).
460 pub fn open(self, col: &str) -> Self {
461 self.push(col, Aesthetic::Open)
462 }
463 /// Period high (`geom_candlestick` / `geom_ohlc`).
464 pub fn high(self, col: &str) -> Self {
465 self.push(col, Aesthetic::High)
466 }
467 /// Period low (`geom_candlestick` / `geom_ohlc`).
468 pub fn low(self, col: &str) -> Self {
469 self.push(col, Aesthetic::Low)
470 }
471 /// Closing price (`geom_candlestick` / `geom_ohlc`).
472 pub fn close(self, col: &str) -> Self {
473 self.push(col, Aesthetic::Close)
474 }
475 /// Data-mapped `geom_vline` position (one line per row).
476 pub fn xintercept(self, col: &str) -> Self {
477 self.push(col, Aesthetic::Xintercept)
478 }
479 /// Data-mapped `geom_hline` position (one line per row).
480 pub fn yintercept(self, col: &str) -> Self {
481 self.push(col, Aesthetic::Yintercept)
482 }
483 /// Data-mapped `geom_abline` slope (one line per row).
484 pub fn slope(self, col: &str) -> Self {
485 self.push(col, Aesthetic::Slope)
486 }
487 /// Data-mapped `geom_abline` intercept (one line per row).
488 pub fn intercept(self, col: &str) -> Self {
489 self.push(col, Aesthetic::Intercept)
490 }
491
492 // ─── after_stat() mappings ──────────────────────────────────
493 // Map stat-computed columns (e.g., `density`, `count`, `ncount`, `ndensity`)
494 // to an aesthetic. These are resolved after the stat step in the build pipeline.
495
496 /// Map a stat-computed column to the y aesthetic (e.g., `after_stat_y("density")`).
497 pub fn after_stat_y(self, col: &str) -> Self {
498 self.push_after_stat(col, Aesthetic::Y)
499 }
500
501 /// Map a stat-computed column to the x aesthetic.
502 pub fn after_stat_x(self, col: &str) -> Self {
503 self.push_after_stat(col, Aesthetic::X)
504 }
505
506 /// Map a stat-computed column to the fill aesthetic.
507 pub fn after_stat_fill(self, col: &str) -> Self {
508 self.push_after_stat(col, Aesthetic::Fill)
509 }
510
511 /// Map a stat-computed column to the color aesthetic.
512 pub fn after_stat_color(self, col: &str) -> Self {
513 self.push_after_stat(col, Aesthetic::Color)
514 }
515
516 /// Map a stat-computed column to the size aesthetic.
517 pub fn after_stat_size(self, col: &str) -> Self {
518 self.push_after_stat(col, Aesthetic::Size)
519 }
520
521 /// Map a stat-computed column to the alpha aesthetic.
522 pub fn after_stat_alpha(self, col: &str) -> Self {
523 self.push_after_stat(col, Aesthetic::Alpha)
524 }
525
526 /// Map a stat-computed column to the linewidth aesthetic.
527 pub fn after_stat_linewidth(self, col: &str) -> Self {
528 self.push_after_stat(col, Aesthetic::Linewidth)
529 }
530
531 /// Get the column mapped to a specific aesthetic.
532 pub fn get_mapping(&self, aes: &Aesthetic) -> Option<&str> {
533 self.mappings
534 .iter()
535 .find(|m| m.aesthetic == *aes)
536 .map(|m| m.column.as_str())
537 }
538
539 /// Merge another Aes into this one. The other's mappings override on conflict.
540 pub fn merge(&self, other: &Aes) -> Aes {
541 let mut result = self.clone();
542 for m in &other.mappings {
543 // Remove existing mapping for same aesthetic
544 result
545 .mappings
546 .retain(|existing| existing.aesthetic != m.aesthetic);
547 result.mappings.push(m.clone());
548 }
549 result.warnings.extend(other.warnings.iter().cloned());
550 result
551 }
552}
553
554/// Names ggplot-rs itself writes via a mechanism other than `data_attr`'s
555/// `set_mark_attrs` dedup — reserved so a user mapping can never collide and
556/// produce a duplicate attribute. `"part"` is `DrawBackend::set_mark_part`
557/// (arrowheads: `data-part="arrow"`), written directly in `push_mark`, not
558/// through the built-ins list `set_mark_attrs` dedups against. `"geom"` is
559/// `SvgBackend::mark_class`'s `data-geom="<layer geom>"`, written the same
560/// way, always last, alongside the `gg-mark` class. `"fvalue"`/`"fx"` are
561/// `DrawBackend::set_mark_fvalue`/`set_mark_fx` (B2-4), written the same way.
562const RESERVED_DATA_ATTR_NAMES: &[&str] = &["part", "geom", "fvalue", "fx"];
563
564/// `[a-z0-9][a-z0-9-]*`, excluding [`RESERVED_DATA_ATTR_NAMES`] — a valid,
565/// non-colliding HTML custom-data-attribute suffix.
566pub(crate) fn is_valid_data_attr_name(name: &str) -> bool {
567 if RESERVED_DATA_ATTR_NAMES.contains(&name) {
568 return false;
569 }
570 let mut chars = name.chars();
571 match chars.next() {
572 Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() => {}
573 _ => return false,
574 }
575 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
576}
577
578/// Internal storage namespace for validated interactive-aesthetic columns
579/// (`data_attr`, `tooltip`) in the built DataFrame. Starts with a NUL byte,
580/// which can't appear in any column name `resolve_mappings` carries over
581/// from a caller's raw source data: that loop explicitly drops a raw column
582/// whose name contains a NUL (or this prefix) rather than carrying it
583/// through unchanged — see its doc. So in practice no caller-supplied
584/// column collides with a name under this prefix, but the real guarantee is
585/// that drop, not merely that a legitimate column "wouldn't" be named this
586/// way (`GGPlot::new` accepts any `String`, including one with a NUL byte
587/// in it, deliberately crafted to collide).
588pub(crate) const INTERNAL_PREFIX: &str = "\u{0}ggplot-rs:";
589
590/// Internal storage prefix for validated `data_attr` columns — see
591/// [`INTERNAL_PREFIX`]. `geom::set_row_extras` looks for *only* this
592/// prefix instead of scanning for anything that merely looks like a
593/// `data-*` column, which would also match an unrelated raw source column
594/// a caller never mapped through `Aes::data_attr` (the injection this
595/// guards against: see `validated_data_attr_internal_name`).
596pub(crate) const DATA_ATTR_INTERNAL_PREFIX: &str = "\u{0}ggplot-rs:data-attr:";
597
598/// Internal storage name for a validated `Aes::tooltip` mapping — see
599/// [`INTERNAL_PREFIX`]. Without this, an unmapped raw column literally
600/// named `"tooltip"` would drive every mark's hover title (the same class
601/// of bug `DATA_ATTR_INTERNAL_PREFIX` fixes for `data_attr`); routing it
602/// through the same internal-prefix scheme closes that for tooltips too.
603pub(crate) const TOOLTIP_INTERNAL_NAME: &str = "\u{0}ggplot-rs:tooltip";
604
605/// Internal storage name for a validated `Aes::class` mapping (#221) — see
606/// [`INTERNAL_PREFIX`]; same reasoning as [`TOOLTIP_INTERNAL_NAME`].
607pub(crate) const CLASS_INTERNAL_NAME: &str = "\u{0}ggplot-rs:class";
608
609/// Internal storage prefix for a validated `Aes::group_wrap` mapping
610/// (#221), followed by the full `data-<name>` attribute — see
611/// [`INTERNAL_PREFIX`]; same reasoning as [`DATA_ATTR_INTERNAL_PREFIX`].
612pub(crate) const GROUP_WRAP_INTERNAL_PREFIX: &str = "\u{0}ggplot-rs:group-wrap:";
613
614/// One CSS class token (#221): `^[A-Za-z][A-Za-z0-9_-]*$`. Deliberately
615/// narrower than CSS allows (no escapes, no leading digit or `-`), so a
616/// token can never break out of the attribute or need escaping.
617pub(crate) fn is_valid_class_token(token: &str) -> bool {
618 let mut chars = token.chars();
619 match chars.next() {
620 Some(c) if c.is_ascii_alphabetic() => {}
621 _ => return false,
622 }
623 chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
624}
625
626/// Split `value` on whitespace into `(valid tokens, rejected tokens)` —
627/// see [`is_valid_class_token`]. Duplicate valid tokens are kept once.
628pub(crate) fn split_class_tokens(value: &str) -> (Vec<String>, Vec<String>) {
629 let (mut ok, mut bad) = (Vec::<String>::new(), Vec::new());
630 for t in value.split_whitespace() {
631 if !is_valid_class_token(t) {
632 bad.push(t.to_string());
633 } else if !ok.iter().any(|o| o == t) {
634 ok.push(t.to_string());
635 }
636 }
637 (ok, bad)
638}
639
640/// Validate a `group_wrap` mapping's target (`"data-<name>"`, as stored in
641/// [`Aesthetic::GroupWrap`]) and return its internal column name — the
642/// same single authoritative gate [`validated_data_attr_internal_name`] is.
643pub(crate) fn validated_group_wrap_internal_name(target: &str) -> Option<String> {
644 let suffix = target.strip_prefix("data-")?;
645 is_valid_data_attr_name(suffix).then(|| format!("{GROUP_WRAP_INTERNAL_PREFIX}{target}"))
646}
647
648/// Internal storage names for `GeomBoxplot::setup_data`'s outlier-extent
649/// columns — see [`INTERNAL_PREFIX`] (review #158 finding 5: these used to
650/// be plain `"ymin_final"`/`"ymax_final"`, trained into the y scale for
651/// *every* layer at build Step 8b regardless of which geom produced them;
652/// `resolve_mappings` carries over any raw source column with a
653/// not-otherwise-mapped canonical name, so a raw column literally named
654/// `ymin_final` — e.g. a SQL alias — on an unrelated geom like `geom_point`
655/// silently widened its y domain. Routing them through the internal-prefix
656/// scheme means only `GeomBoxplot::setup_data` itself can ever produce
657/// these columns; a raw column with this name is already stripped by
658/// `is_forged_internal_name` before it reaches the built data.
659pub(crate) const BOXPLOT_YMIN_FINAL: &str = "\u{0}ggplot-rs:ymin_final";
660pub(crate) const BOXPLOT_YMAX_FINAL: &str = "\u{0}ggplot-rs:ymax_final";
661
662/// Validate a `data_attr` mapping's target (`"data-<name>"`, as stored in
663/// [`Aesthetic::DataAttr`]) and, if valid, return the internal column name
664/// it should be written under. This is the single authoritative gate: it
665/// runs in `resolve_mappings` regardless of *how* the `DataAttr` mapping was
666/// created — the validated `Aes::data_attr` builder, or a `DataAttr`
667/// constructed directly and pushed into the public `Aes.mappings` field,
668/// bypassing the builder's own check.
669pub(crate) fn validated_data_attr_internal_name(data_attr_target: &str) -> Option<String> {
670 let suffix = data_attr_target.strip_prefix("data-")?;
671 if is_valid_data_attr_name(suffix) {
672 Some(format!("{DATA_ATTR_INTERNAL_PREFIX}{data_attr_target}"))
673 } else {
674 None
675 }
676}
677
678#[cfg(test)]
679mod data_attr_name_tests {
680 use super::{is_valid_data_attr_name, validated_data_attr_internal_name};
681
682 #[test]
683 fn accepts_lowercase_alnum_and_hyphen() {
684 for ok in ["x", "row", "data-ish", "a1", "0a", "value"] {
685 assert!(is_valid_data_attr_name(ok), "{ok} should be valid");
686 }
687 }
688
689 #[test]
690 fn rejects_empty_uppercase_and_other_punctuation() {
691 for bad in ["", "-x", "X", "a_b", "a.b", "a b", "déjà"] {
692 assert!(!is_valid_data_attr_name(bad), "{bad} should be invalid");
693 }
694 }
695
696 #[test]
697 fn rejects_the_reserved_part_name() {
698 assert!(!is_valid_data_attr_name("part"));
699 assert_eq!(validated_data_attr_internal_name("data-part"), None);
700 }
701
702 #[test]
703 fn rejects_the_reserved_geom_fvalue_and_fx_names() {
704 // B2-0's `data-geom` and B2-4's `data-fvalue`/`data-fx` are written
705 // directly in `push_mark`, the same way `data-part` is — a
706 // `data_attr` mapping under any of these names would duplicate it.
707 for name in ["geom", "fvalue", "fx"] {
708 assert!(!is_valid_data_attr_name(name), "{name} should be reserved");
709 assert_eq!(
710 validated_data_attr_internal_name(&format!("data-{name}")),
711 None
712 );
713 }
714 }
715
716 #[test]
717 fn rejects_a_malformed_injection_attempt_even_with_the_data_prefix() {
718 // Space, `=`, parens: none of these are legal in the suffix.
719 assert_eq!(
720 validated_data_attr_internal_name("data-a onmouseover=alert(1) b"),
721 None
722 );
723 }
724
725 #[test]
726 fn valid_target_maps_to_the_internal_prefix_plus_itself() {
727 let internal = validated_data_attr_internal_name("data-orig").unwrap();
728 assert!(internal.starts_with(super::DATA_ATTR_INTERNAL_PREFIX));
729 assert!(internal.ends_with("data-orig"));
730 }
731}
732
733#[cfg(test)]
734mod aesthetic_and_aes_tests {
735 use super::{Aes, Aesthetic};
736
737 /// `col_name()` for `Tooltip`/`DataAttr` is never actually used for
738 /// storage (both are special-cased in `aes::mapping`), but it should
739 /// still return a sane, documented fallback for a direct caller.
740 #[test]
741 fn col_name_for_tooltip_and_data_attr() {
742 assert_eq!(Aesthetic::Tooltip.col_name(), "tooltip");
743 assert_eq!(
744 Aesthetic::DataAttr("data-orig".to_string()).col_name(),
745 "data-orig"
746 );
747 }
748
749 #[test]
750 fn data_attr_warning_text_distinguishes_invalid_from_reserved() {
751 let invalid = Aes::new().data_attr("Not Valid!", "col");
752 assert_eq!(invalid.warnings.len(), 1);
753 assert!(invalid.warnings[0].contains("invalid"));
754 assert!(!invalid.warnings[0].contains("reserved"));
755
756 let reserved = Aes::new().data_attr("part", "col");
757 assert_eq!(reserved.warnings.len(), 1);
758 assert!(reserved.warnings[0].contains("reserved"));
759 }
760
761 #[test]
762 fn after_stat_linewidth_maps_a_stat_computed_column() {
763 let aes = Aes::new().after_stat_linewidth("weight");
764 assert_eq!(aes.get_mapping(&Aesthetic::Linewidth), Some("weight"));
765 }
766}