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