Skip to main content

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