@@ -18,6 +18,16 @@ pub const POSITION_VALUES: &[&str] = &["identity", "stack", "dodge", "jitter"];
1818/// Closed interval side values for binned data
1919pub const CLOSED_VALUES : & [ & str ] = & [ "left" , "right" ] ;
2020
21+ /// Aesthetic aliases: user-facing names that resolve to concrete aesthetics.
22+ ///
23+ /// An alias is considered supported if any of its target aesthetics are supported
24+ /// by a geom. For example, `color` resolves to `stroke` and/or `fill` — so any geom
25+ /// that supports either `stroke` or `fill` also accepts `color`.
26+ ///
27+ /// Note: Spelling variants (`colour`/`col` → `color`) are handled separately at parse
28+ /// time by `normalise_aes_name()` in `parser/builder.rs`.
29+ pub const AESTHETIC_ALIASES : & [ ( & str , & [ & str ] ) ] = & [ ( "color" , & [ "stroke" , "fill" ] ) ] ;
30+
2131/// Default aesthetic values for a geom type
2232///
2333/// This struct describes which aesthetics a geom supports, requires, and their default values.
@@ -32,21 +42,37 @@ pub struct DefaultAesthetics {
3242}
3343
3444impl DefaultAesthetics {
35- /// Get all aesthetic names (including Delayed)
45+ /// Get all aesthetic names (including Delayed and aliases )
3646 pub fn names ( & self ) -> Vec < & ' static str > {
37- self . defaults . iter ( ) . map ( |( name, _) | * name) . collect ( )
47+ let mut result: Vec < & ' static str > = self . defaults . iter ( ) . map ( |( name, _) | * name) . collect ( ) ;
48+ // Include alias names if any of their targets are in the defaults
49+ for & ( alias, targets) in AESTHETIC_ALIASES {
50+ if targets. iter ( ) . any ( |t| result. contains ( t) ) {
51+ result. push ( alias) ;
52+ }
53+ }
54+ result
3855 }
3956
4057 /// Get supported aesthetic names (excludes Delayed, for MAPPING validation)
4158 ///
42- /// Returns the literal names from defaults. For bidirectional position checking,
43- /// use `is_supported()` which handles pos1/pos2 equivalence.
59+ /// Returns the literal names from defaults plus any aliases whose targets are
60+ /// supported. For bidirectional position checking, use `is_supported()` which
61+ /// handles pos1/pos2 equivalence.
4462 pub fn supported ( & self ) -> Vec < & ' static str > {
45- self . defaults
63+ let mut result: Vec < & ' static str > = self
64+ . defaults
4665 . iter ( )
4766 . filter ( |( _, value) | !matches ! ( value, DefaultAestheticValue :: Delayed ) )
4867 . map ( |( name, _) | * name)
49- . collect ( )
68+ . collect ( ) ;
69+ // Include alias names if any of their targets are supported
70+ for & ( alias, targets) in AESTHETIC_ALIASES {
71+ if targets. iter ( ) . any ( |t| result. contains ( t) ) {
72+ result. push ( alias) ;
73+ }
74+ }
75+ result
5076 }
5177
5278 /// Get required aesthetic names (those marked as Required)
@@ -66,7 +92,8 @@ impl DefaultAesthetics {
6692 /// Check if an aesthetic is supported (not Delayed)
6793 ///
6894 /// Position aesthetics are bidirectional: if pos1* is supported, pos2* is also
69- /// considered supported (and vice versa).
95+ /// considered supported (and vice versa). Aliases (e.g., `color`) are supported
96+ /// if any of their target aesthetics are supported.
7097 pub fn is_supported ( & self , name : & str ) -> bool {
7198 // Check for direct match first
7299 let direct_match = self
@@ -86,6 +113,13 @@ impl DefaultAesthetics {
86113 } ) ;
87114 }
88115
116+ // Check if name is an alias that resolves to a supported aesthetic
117+ for & ( alias, targets) in AESTHETIC_ALIASES {
118+ if alias == name {
119+ return targets. iter ( ) . any ( |t| self . is_supported ( t) ) ;
120+ }
121+ }
122+
89123 false
90124 }
91125
@@ -184,18 +218,20 @@ mod tests {
184218 assert_eq ! ( aes. get( "yend" ) , Some ( & DefaultAestheticValue :: Delayed ) ) ;
185219 assert_eq ! ( aes. get( "nonexistent" ) , None ) ;
186220
187- // Test names() - includes all aesthetics
221+ // Test names() - includes all aesthetics + aliases
188222 let names = aes. names ( ) ;
189- assert_eq ! ( names. len( ) , 6 ) ;
223+ assert_eq ! ( names. len( ) , 7 ) ; // 6 defaults + "color" alias (has stroke+fill)
190224 assert ! ( names. contains( & "x" ) ) ;
191225 assert ! ( names. contains( & "yend" ) ) ;
226+ assert ! ( names. contains( & "color" ) ) ; // alias resolved from stroke+fill
192227
193- // Test supported() - excludes Delayed
228+ // Test supported() - excludes Delayed, includes aliases
194229 let supported = aes. supported ( ) ;
195- assert_eq ! ( supported. len( ) , 5 ) ;
230+ assert_eq ! ( supported. len( ) , 6 ) ; // 5 non-delayed + "color" alias
196231 assert ! ( supported. contains( & "x" ) ) ;
197232 assert ! ( supported. contains( & "size" ) ) ;
198233 assert ! ( supported. contains( & "fill" ) ) ;
234+ assert ! ( supported. contains( & "color" ) ) ; // alias
199235 assert ! ( !supported. contains( & "yend" ) ) ; // Delayed excluded
200236
201237 // Test required() - only Required variants
@@ -208,6 +244,7 @@ mod tests {
208244 // Test is_supported() - efficient membership check
209245 assert ! ( aes. is_supported( "x" ) ) ;
210246 assert ! ( aes. is_supported( "size" ) ) ;
247+ assert ! ( aes. is_supported( "color" ) ) ; // alias: has stroke+fill
211248 assert ! ( !aes. is_supported( "yend" ) ) ; // Delayed not supported
212249 assert ! ( !aes. is_supported( "nonexistent" ) ) ;
213250
@@ -222,4 +259,42 @@ mod tests {
222259 assert ! ( !aes. is_required( "size" ) ) ;
223260 assert ! ( !aes. is_required( "yend" ) ) ;
224261 }
262+
263+ #[ test]
264+ fn test_color_alias_requires_stroke_or_fill ( ) {
265+ // Geom with neither stroke nor fill: color alias should NOT be supported
266+ let aes = DefaultAesthetics {
267+ defaults : & [
268+ ( "pos1" , DefaultAestheticValue :: Required ) ,
269+ ( "pos2" , DefaultAestheticValue :: Required ) ,
270+ ( "size" , DefaultAestheticValue :: Number ( 3.0 ) ) ,
271+ ] ,
272+ } ;
273+
274+ assert ! ( !aes. is_supported( "color" ) ) ;
275+ assert ! ( !aes. supported( ) . contains( & "color" ) ) ;
276+ assert ! ( !aes. names( ) . contains( & "color" ) ) ;
277+
278+ // Geom with only stroke: color alias should be supported
279+ let aes_stroke = DefaultAesthetics {
280+ defaults : & [
281+ ( "pos1" , DefaultAestheticValue :: Required ) ,
282+ ( "stroke" , DefaultAestheticValue :: String ( "black" ) ) ,
283+ ] ,
284+ } ;
285+
286+ assert ! ( aes_stroke. is_supported( "color" ) ) ;
287+ assert ! ( aes_stroke. supported( ) . contains( & "color" ) ) ;
288+
289+ // Geom with only fill: color alias should be supported
290+ let aes_fill = DefaultAesthetics {
291+ defaults : & [
292+ ( "pos1" , DefaultAestheticValue :: Required ) ,
293+ ( "fill" , DefaultAestheticValue :: String ( "black" ) ) ,
294+ ] ,
295+ } ;
296+
297+ assert ! ( aes_fill. is_supported( "color" ) ) ;
298+ assert ! ( aes_fill. supported( ) . contains( & "color" ) ) ;
299+ }
225300}
0 commit comments