From caad624a3719e1305f42f81178849bbac1629e02 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Tue, 14 Apr 2026 09:08:00 +0200 Subject: [PATCH 1/2] Allow setting titles to null in LABEL --- src/parser/builder.rs | 7 +++-- src/plot/main.rs | 52 ++++++++++++++++----------------- src/writer/vegalite/encoding.rs | 22 +++++++++++--- src/writer/vegalite/mod.rs | 6 ++-- tree-sitter-ggsql/grammar.js | 2 +- 5 files changed, 52 insertions(+), 37 deletions(-) diff --git a/src/parser/builder.rs b/src/parser/builder.rs index b09a8b2c..4d575fcb 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -1124,12 +1124,13 @@ fn build_labels(node: &Node, source: &SourceTree) -> Result { // Parse label type (name) let label_type = source.get_text(&name_node); - // Parse label value (must be a string) + // Parse label value (string or null) let label_value = match value_node.kind() { - "string" => parse_string_node(&value_node, source), + "string" => Some(parse_string_node(&value_node, source)), + "null_literal" => None, _ => { return Err(GgsqlError::ParseError(format!( - "Label '{}' must have a string value, got: {}", + "Label '{}' must have a string or null value, got: {}", label_type, value_node.kind() ))); diff --git a/src/plot/main.rs b/src/plot/main.rs index 955d49f5..cc48e68f 100644 --- a/src/plot/main.rs +++ b/src/plot/main.rs @@ -79,8 +79,8 @@ pub struct Plot { /// Text labels (from LABELS clause) #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Labels { - /// Label assignments (label type → text) - pub labels: HashMap, + /// Label assignments (label type → text, None = suppress) + pub labels: HashMap>, } /// Theme styling (from THEME clause) @@ -301,7 +301,7 @@ impl Plot { label_source.to_string() }; - labels.labels.insert(primary.to_string(), column_name); + labels.labels.insert(primary.to_string(), Some(column_name)); } } } @@ -635,7 +635,7 @@ mod tests { }; labels .labels - .insert("pos1".to_string(), "Custom X Label".to_string()); + .insert("pos1".to_string(), Some("Custom X Label".to_string())); spec.labels = Some(labels); spec.compute_aesthetic_labels(); @@ -644,7 +644,7 @@ mod tests { // User-specified label should be preserved assert_eq!( labels.labels.get("pos1"), - Some(&"Custom X Label".to_string()) + Some(&Some("Custom X Label".to_string())) ); // pos2 should still be computed from variants assert!(labels.labels.contains_key("pos2")); @@ -684,7 +684,7 @@ mod tests { let labels = spec.labels.as_ref().unwrap(); // First layer's pos1 mapping should win - assert_eq!(labels.labels.get("pos1"), Some(&"date".to_string())); + assert_eq!(labels.labels.get("pos1"), Some(&Some("date".to_string()))); } #[test] @@ -711,7 +711,7 @@ mod tests { // The stroke label should be "stroke" (extracted from __ggsql_aes_stroke__) assert_eq!( labels.labels.get("stroke"), - Some(&"stroke".to_string()), + Some(&Some("stroke".to_string())), "Stroke aesthetic should use 'stroke' as label" ); } @@ -736,7 +736,7 @@ mod tests { // The size label should be "size", not "color" assert_eq!( labels.labels.get("size"), - Some(&"size".to_string()), + Some(&Some("size".to_string())), "Non-color aesthetic should keep its name" ); } @@ -758,8 +758,8 @@ mod tests { }); spec.labels = Some(Labels { labels: HashMap::from([ - ("x".to_string(), "X Axis".to_string()), - ("y".to_string(), "Y Axis".to_string()), + ("x".to_string(), Some("X Axis".to_string())), + ("y".to_string(), Some("Y Axis".to_string())), ]), }); @@ -767,8 +767,8 @@ mod tests { spec.transform_aesthetics_to_internal(); let labels = spec.labels.as_ref().unwrap(); - assert_eq!(labels.labels.get("pos1"), Some(&"X Axis".to_string())); - assert_eq!(labels.labels.get("pos2"), Some(&"Y Axis".to_string())); + assert_eq!(labels.labels.get("pos1"), Some(&Some("X Axis".to_string()))); + assert_eq!(labels.labels.get("pos2"), Some(&Some("Y Axis".to_string()))); assert!(!labels.labels.contains_key("x")); assert!(!labels.labels.contains_key("y")); } @@ -787,8 +787,8 @@ mod tests { }); spec.labels = Some(Labels { labels: HashMap::from([ - ("x".to_string(), "Category".to_string()), - ("y".to_string(), "Value".to_string()), + ("x".to_string(), Some("Category".to_string())), + ("y".to_string(), Some("Value".to_string())), ]), }); @@ -797,8 +797,8 @@ mod tests { let labels = spec.labels.as_ref().unwrap(); // x maps to pos2 (second position), y maps to pos1 (first position) - assert_eq!(labels.labels.get("pos1"), Some(&"Value".to_string())); - assert_eq!(labels.labels.get("pos2"), Some(&"Category".to_string())); + assert_eq!(labels.labels.get("pos1"), Some(&Some("Value".to_string()))); + assert_eq!(labels.labels.get("pos2"), Some(&Some("Category".to_string()))); } #[test] @@ -814,8 +814,8 @@ mod tests { }); spec.labels = Some(Labels { labels: HashMap::from([ - ("angle".to_string(), "Angle".to_string()), - ("radius".to_string(), "Distance".to_string()), + ("angle".to_string(), Some("Angle".to_string())), + ("radius".to_string(), Some("Distance".to_string())), ]), }); @@ -823,8 +823,8 @@ mod tests { spec.transform_aesthetics_to_internal(); let labels = spec.labels.as_ref().unwrap(); - assert_eq!(labels.labels.get("pos1"), Some(&"Angle".to_string())); - assert_eq!(labels.labels.get("pos2"), Some(&"Distance".to_string())); + assert_eq!(labels.labels.get("pos1"), Some(&Some("Angle".to_string()))); + assert_eq!(labels.labels.get("pos2"), Some(&Some("Distance".to_string()))); } #[test] @@ -840,9 +840,9 @@ mod tests { }); spec.labels = Some(Labels { labels: HashMap::from([ - ("title".to_string(), "My Chart".to_string()), - ("color".to_string(), "Category".to_string()), - ("x".to_string(), "X Axis".to_string()), + ("title".to_string(), Some("My Chart".to_string())), + ("color".to_string(), Some("Category".to_string())), + ("x".to_string(), Some("X Axis".to_string())), ]), }); @@ -851,9 +851,9 @@ mod tests { let labels = spec.labels.as_ref().unwrap(); // Material labels should remain unchanged - assert_eq!(labels.labels.get("title"), Some(&"My Chart".to_string())); - assert_eq!(labels.labels.get("color"), Some(&"Category".to_string())); + assert_eq!(labels.labels.get("title"), Some(&Some("My Chart".to_string()))); + assert_eq!(labels.labels.get("color"), Some(&Some("Category".to_string()))); // Position label should be transformed - assert_eq!(labels.labels.get("pos1"), Some(&"X Axis".to_string())); + assert_eq!(labels.labels.get("pos1"), Some(&Some("X Axis".to_string()))); } } diff --git a/src/writer/vegalite/encoding.rs b/src/writer/vegalite/encoding.rs index 07c9c18c..3f690233 100644 --- a/src/writer/vegalite/encoding.rs +++ b/src/writer/vegalite/encoding.rs @@ -414,8 +414,15 @@ fn apply_title_to_encoding( .as_ref() .and_then(|labels| labels.labels.get(primary)); - if let Some(label) = explicit_label { - encoding["title"] = super::split_label_on_newlines(label); + if let Some(label_opt) = explicit_label { + match label_opt { + Some(label) => { + encoding["title"] = super::split_label_on_newlines(label); + } + None => { + encoding["title"] = Value::Null; + } + } titled_families.insert(primary.to_string()); } else if let Some(orig) = original_name { // Use original column name as default title when available @@ -428,8 +435,15 @@ fn apply_title_to_encoding( } else if !is_primary && !primary_exists && !titled_families.contains(primary) { // Variant without primary: allow first variant to claim title (for explicit labels) if let Some(ref labels) = spec.labels { - if let Some(label) = labels.labels.get(primary) { - encoding["title"] = super::split_label_on_newlines(label); + if let Some(label_opt) = labels.labels.get(primary) { + match label_opt { + Some(label) => { + encoding["title"] = super::split_label_on_newlines(label); + } + None => { + encoding["title"] = Value::Null; + } + } titled_families.insert(primary.to_string()); } } diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index 405da449..8635a8f8 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -1110,8 +1110,8 @@ impl Writer for VegaLiteWriter { } if let Some(labels) = &spec.labels { - let title = labels.labels.get("title"); - let subtitle = labels.labels.get("subtitle"); + let title = labels.labels.get("title").and_then(|v| v.as_ref()); + let subtitle = labels.labels.get("subtitle").and_then(|v| v.as_ref()); match (title, subtitle) { (Some(t), Some(st)) => { // Vega-Lite uses an object for title + subtitle @@ -1564,7 +1564,7 @@ mod tests { }; labels .labels - .insert("title".to_string(), "My Chart".to_string()); + .insert("title".to_string(), Some("My Chart".to_string())); spec.labels = Some(labels); let df = df! { diff --git a/tree-sitter-ggsql/grammar.js b/tree-sitter-ggsql/grammar.js index 089b32fe..46023d7b 100644 --- a/tree-sitter-ggsql/grammar.js +++ b/tree-sitter-ggsql/grammar.js @@ -837,7 +837,7 @@ module.exports = grammar({ label_assignment: $ => seq( field('name', $.label_type), '=>', - field('value', $.string) + field('value', choice($.string, $.null_literal)) ), label_type: $ => $.identifier, From 4803d878e790b2903066ff1360252c3b5d0021fe Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Tue, 14 Apr 2026 09:09:46 +0200 Subject: [PATCH 2/2] reformat --- src/plot/main.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/plot/main.rs b/src/plot/main.rs index cc48e68f..f9a25927 100644 --- a/src/plot/main.rs +++ b/src/plot/main.rs @@ -798,7 +798,10 @@ mod tests { let labels = spec.labels.as_ref().unwrap(); // x maps to pos2 (second position), y maps to pos1 (first position) assert_eq!(labels.labels.get("pos1"), Some(&Some("Value".to_string()))); - assert_eq!(labels.labels.get("pos2"), Some(&Some("Category".to_string()))); + assert_eq!( + labels.labels.get("pos2"), + Some(&Some("Category".to_string())) + ); } #[test] @@ -824,7 +827,10 @@ mod tests { let labels = spec.labels.as_ref().unwrap(); assert_eq!(labels.labels.get("pos1"), Some(&Some("Angle".to_string()))); - assert_eq!(labels.labels.get("pos2"), Some(&Some("Distance".to_string()))); + assert_eq!( + labels.labels.get("pos2"), + Some(&Some("Distance".to_string())) + ); } #[test] @@ -851,8 +857,14 @@ mod tests { let labels = spec.labels.as_ref().unwrap(); // Material labels should remain unchanged - assert_eq!(labels.labels.get("title"), Some(&Some("My Chart".to_string()))); - assert_eq!(labels.labels.get("color"), Some(&Some("Category".to_string()))); + assert_eq!( + labels.labels.get("title"), + Some(&Some("My Chart".to_string())) + ); + assert_eq!( + labels.labels.get("color"), + Some(&Some("Category".to_string())) + ); // Position label should be transformed assert_eq!(labels.labels.get("pos1"), Some(&Some("X Axis".to_string()))); }