Skip to content

Commit ad99073

Browse files
authored
Allow setting titles to null in LABEL (#302)
1 parent 37633f0 commit ad99073

5 files changed

Lines changed: 64 additions & 37 deletions

File tree

src/parser/builder.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1124,12 +1124,13 @@ fn build_labels(node: &Node, source: &SourceTree) -> Result<Labels> {
11241124
// Parse label type (name)
11251125
let label_type = source.get_text(&name_node);
11261126

1127-
// Parse label value (must be a string)
1127+
// Parse label value (string or null)
11281128
let label_value = match value_node.kind() {
1129-
"string" => parse_string_node(&value_node, source),
1129+
"string" => Some(parse_string_node(&value_node, source)),
1130+
"null_literal" => None,
11301131
_ => {
11311132
return Err(GgsqlError::ParseError(format!(
1132-
"Label '{}' must have a string value, got: {}",
1133+
"Label '{}' must have a string or null value, got: {}",
11331134
label_type,
11341135
value_node.kind()
11351136
)));

src/plot/main.rs

Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,8 @@ pub struct Plot {
7979
/// Text labels (from LABELS clause)
8080
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
8181
pub struct Labels {
82-
/// Label assignments (label type → text)
83-
pub labels: HashMap<String, String>,
82+
/// Label assignments (label type → text, None = suppress)
83+
pub labels: HashMap<String, Option<String>>,
8484
}
8585

8686
/// Theme styling (from THEME clause)
@@ -301,7 +301,7 @@ impl Plot {
301301
label_source.to_string()
302302
};
303303

304-
labels.labels.insert(primary.to_string(), column_name);
304+
labels.labels.insert(primary.to_string(), Some(column_name));
305305
}
306306
}
307307
}
@@ -635,7 +635,7 @@ mod tests {
635635
};
636636
labels
637637
.labels
638-
.insert("pos1".to_string(), "Custom X Label".to_string());
638+
.insert("pos1".to_string(), Some("Custom X Label".to_string()));
639639
spec.labels = Some(labels);
640640

641641
spec.compute_aesthetic_labels();
@@ -644,7 +644,7 @@ mod tests {
644644
// User-specified label should be preserved
645645
assert_eq!(
646646
labels.labels.get("pos1"),
647-
Some(&"Custom X Label".to_string())
647+
Some(&Some("Custom X Label".to_string()))
648648
);
649649
// pos2 should still be computed from variants
650650
assert!(labels.labels.contains_key("pos2"));
@@ -684,7 +684,7 @@ mod tests {
684684

685685
let labels = spec.labels.as_ref().unwrap();
686686
// First layer's pos1 mapping should win
687-
assert_eq!(labels.labels.get("pos1"), Some(&"date".to_string()));
687+
assert_eq!(labels.labels.get("pos1"), Some(&Some("date".to_string())));
688688
}
689689

690690
#[test]
@@ -711,7 +711,7 @@ mod tests {
711711
// The stroke label should be "stroke" (extracted from __ggsql_aes_stroke__)
712712
assert_eq!(
713713
labels.labels.get("stroke"),
714-
Some(&"stroke".to_string()),
714+
Some(&Some("stroke".to_string())),
715715
"Stroke aesthetic should use 'stroke' as label"
716716
);
717717
}
@@ -736,7 +736,7 @@ mod tests {
736736
// The size label should be "size", not "color"
737737
assert_eq!(
738738
labels.labels.get("size"),
739-
Some(&"size".to_string()),
739+
Some(&Some("size".to_string())),
740740
"Non-color aesthetic should keep its name"
741741
);
742742
}
@@ -758,17 +758,17 @@ mod tests {
758758
});
759759
spec.labels = Some(Labels {
760760
labels: HashMap::from([
761-
("x".to_string(), "X Axis".to_string()),
762-
("y".to_string(), "Y Axis".to_string()),
761+
("x".to_string(), Some("X Axis".to_string())),
762+
("y".to_string(), Some("Y Axis".to_string())),
763763
]),
764764
});
765765

766766
spec.initialize_aesthetic_context();
767767
spec.transform_aesthetics_to_internal();
768768

769769
let labels = spec.labels.as_ref().unwrap();
770-
assert_eq!(labels.labels.get("pos1"), Some(&"X Axis".to_string()));
771-
assert_eq!(labels.labels.get("pos2"), Some(&"Y Axis".to_string()));
770+
assert_eq!(labels.labels.get("pos1"), Some(&Some("X Axis".to_string())));
771+
assert_eq!(labels.labels.get("pos2"), Some(&Some("Y Axis".to_string())));
772772
assert!(!labels.labels.contains_key("x"));
773773
assert!(!labels.labels.contains_key("y"));
774774
}
@@ -787,8 +787,8 @@ mod tests {
787787
});
788788
spec.labels = Some(Labels {
789789
labels: HashMap::from([
790-
("x".to_string(), "Category".to_string()),
791-
("y".to_string(), "Value".to_string()),
790+
("x".to_string(), Some("Category".to_string())),
791+
("y".to_string(), Some("Value".to_string())),
792792
]),
793793
});
794794

@@ -797,8 +797,11 @@ mod tests {
797797

798798
let labels = spec.labels.as_ref().unwrap();
799799
// x maps to pos2 (second position), y maps to pos1 (first position)
800-
assert_eq!(labels.labels.get("pos1"), Some(&"Value".to_string()));
801-
assert_eq!(labels.labels.get("pos2"), Some(&"Category".to_string()));
800+
assert_eq!(labels.labels.get("pos1"), Some(&Some("Value".to_string())));
801+
assert_eq!(
802+
labels.labels.get("pos2"),
803+
Some(&Some("Category".to_string()))
804+
);
802805
}
803806

804807
#[test]
@@ -814,17 +817,20 @@ mod tests {
814817
});
815818
spec.labels = Some(Labels {
816819
labels: HashMap::from([
817-
("angle".to_string(), "Angle".to_string()),
818-
("radius".to_string(), "Distance".to_string()),
820+
("angle".to_string(), Some("Angle".to_string())),
821+
("radius".to_string(), Some("Distance".to_string())),
819822
]),
820823
});
821824

822825
spec.initialize_aesthetic_context();
823826
spec.transform_aesthetics_to_internal();
824827

825828
let labels = spec.labels.as_ref().unwrap();
826-
assert_eq!(labels.labels.get("pos1"), Some(&"Angle".to_string()));
827-
assert_eq!(labels.labels.get("pos2"), Some(&"Distance".to_string()));
829+
assert_eq!(labels.labels.get("pos1"), Some(&Some("Angle".to_string())));
830+
assert_eq!(
831+
labels.labels.get("pos2"),
832+
Some(&Some("Distance".to_string()))
833+
);
828834
}
829835

830836
#[test]
@@ -840,9 +846,9 @@ mod tests {
840846
});
841847
spec.labels = Some(Labels {
842848
labels: HashMap::from([
843-
("title".to_string(), "My Chart".to_string()),
844-
("color".to_string(), "Category".to_string()),
845-
("x".to_string(), "X Axis".to_string()),
849+
("title".to_string(), Some("My Chart".to_string())),
850+
("color".to_string(), Some("Category".to_string())),
851+
("x".to_string(), Some("X Axis".to_string())),
846852
]),
847853
});
848854

@@ -851,9 +857,15 @@ mod tests {
851857

852858
let labels = spec.labels.as_ref().unwrap();
853859
// Material labels should remain unchanged
854-
assert_eq!(labels.labels.get("title"), Some(&"My Chart".to_string()));
855-
assert_eq!(labels.labels.get("color"), Some(&"Category".to_string()));
860+
assert_eq!(
861+
labels.labels.get("title"),
862+
Some(&Some("My Chart".to_string()))
863+
);
864+
assert_eq!(
865+
labels.labels.get("color"),
866+
Some(&Some("Category".to_string()))
867+
);
856868
// Position label should be transformed
857-
assert_eq!(labels.labels.get("pos1"), Some(&"X Axis".to_string()));
869+
assert_eq!(labels.labels.get("pos1"), Some(&Some("X Axis".to_string())));
858870
}
859871
}

src/writer/vegalite/encoding.rs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -414,8 +414,15 @@ fn apply_title_to_encoding(
414414
.as_ref()
415415
.and_then(|labels| labels.labels.get(primary));
416416

417-
if let Some(label) = explicit_label {
418-
encoding["title"] = super::split_label_on_newlines(label);
417+
if let Some(label_opt) = explicit_label {
418+
match label_opt {
419+
Some(label) => {
420+
encoding["title"] = super::split_label_on_newlines(label);
421+
}
422+
None => {
423+
encoding["title"] = Value::Null;
424+
}
425+
}
419426
titled_families.insert(primary.to_string());
420427
} else if let Some(orig) = original_name {
421428
// Use original column name as default title when available
@@ -428,8 +435,15 @@ fn apply_title_to_encoding(
428435
} else if !is_primary && !primary_exists && !titled_families.contains(primary) {
429436
// Variant without primary: allow first variant to claim title (for explicit labels)
430437
if let Some(ref labels) = spec.labels {
431-
if let Some(label) = labels.labels.get(primary) {
432-
encoding["title"] = super::split_label_on_newlines(label);
438+
if let Some(label_opt) = labels.labels.get(primary) {
439+
match label_opt {
440+
Some(label) => {
441+
encoding["title"] = super::split_label_on_newlines(label);
442+
}
443+
None => {
444+
encoding["title"] = Value::Null;
445+
}
446+
}
433447
titled_families.insert(primary.to_string());
434448
}
435449
}

src/writer/vegalite/mod.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1110,8 +1110,8 @@ impl Writer for VegaLiteWriter {
11101110
}
11111111

11121112
if let Some(labels) = &spec.labels {
1113-
let title = labels.labels.get("title");
1114-
let subtitle = labels.labels.get("subtitle");
1113+
let title = labels.labels.get("title").and_then(|v| v.as_ref());
1114+
let subtitle = labels.labels.get("subtitle").and_then(|v| v.as_ref());
11151115
match (title, subtitle) {
11161116
(Some(t), Some(st)) => {
11171117
// Vega-Lite uses an object for title + subtitle
@@ -1564,7 +1564,7 @@ mod tests {
15641564
};
15651565
labels
15661566
.labels
1567-
.insert("title".to_string(), "My Chart".to_string());
1567+
.insert("title".to_string(), Some("My Chart".to_string()));
15681568
spec.labels = Some(labels);
15691569

15701570
let df = df! {

tree-sitter-ggsql/grammar.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -837,7 +837,7 @@ module.exports = grammar({
837837
label_assignment: $ => seq(
838838
field('name', $.label_type),
839839
'=>',
840-
field('value', $.string)
840+
field('value', choice($.string, $.null_literal))
841841
),
842842

843843
label_type: $ => $.identifier,

0 commit comments

Comments
 (0)