1use rust_i18n::t;
56use serde::Deserialize;
57use std::collections::HashMap;
58
59#[derive(Debug, Clone)]
61pub struct SettingSchema {
62 pub path: String,
64 pub name: String,
66 pub description: Option<String>,
68 pub setting_type: SettingType,
70 pub default: Option<serde_json::Value>,
72 pub read_only: bool,
74 pub section: Option<String>,
76 pub order: Option<i32>,
79 pub nullable: bool,
82 pub enum_from: Option<String>,
85 pub dual_list_sibling: Option<String>,
87}
88
89#[derive(Debug, Clone)]
91pub enum SettingType {
92 Boolean,
94 Integer {
96 minimum: Option<i64>,
97 maximum: Option<i64>,
98 },
99 Number {
101 minimum: Option<f64>,
102 maximum: Option<f64>,
103 },
104 String,
106 Enum { options: Vec<EnumOption> },
108 StringArray,
110 IntegerArray,
112 ObjectArray {
114 item_schema: Box<SettingSchema>,
115 display_field: Option<String>,
117 },
118 Object { properties: Vec<SettingSchema> },
120 Map {
122 value_schema: Box<SettingSchema>,
123 display_field: Option<String>,
125 no_add: bool,
127 },
128 DualList {
130 options: Vec<EnumOption>,
131 sibling_path: Option<String>,
132 },
133 Complex,
135}
136
137#[derive(Debug, Clone)]
139pub struct EnumOption {
140 pub name: String,
142 pub value: String,
144}
145
146#[derive(Debug, Clone)]
148pub struct SettingCategory {
149 pub name: String,
151 pub path: String,
153 pub description: Option<String>,
155 pub nullable: bool,
158 pub settings: Vec<SettingSchema>,
160 pub subcategories: Vec<SettingCategory>,
162}
163
164#[derive(Debug, Deserialize)]
166struct RawSchema {
167 #[serde(rename = "type")]
168 schema_type: Option<SchemaType>,
169 description: Option<String>,
170 default: Option<serde_json::Value>,
171 properties: Option<HashMap<String, RawSchema>>,
172 items: Option<Box<RawSchema>>,
173 #[serde(rename = "enum")]
174 enum_values: Option<Vec<serde_json::Value>>,
175 minimum: Option<serde_json::Number>,
176 maximum: Option<serde_json::Number>,
177 #[serde(rename = "$ref")]
178 ref_path: Option<String>,
179 #[serde(rename = "$defs")]
180 defs: Option<HashMap<String, RawSchema>>,
181 #[serde(rename = "additionalProperties")]
182 additional_properties: Option<AdditionalProperties>,
183 #[serde(rename = "x-enum-values", default)]
185 extensible_enum_values: Vec<EnumValueEntry>,
186 #[serde(rename = "x-display-field")]
189 display_field: Option<String>,
190 #[serde(rename = "readOnly", default)]
192 read_only: bool,
193 #[serde(rename = "x-standalone-category", default)]
195 standalone_category: bool,
196 #[serde(rename = "x-no-add", default)]
198 no_add: bool,
199 #[serde(rename = "x-section")]
201 section: Option<String>,
202 #[serde(rename = "x-order")]
204 order: Option<i32>,
205 #[serde(rename = "anyOf")]
207 any_of: Option<Vec<RawSchema>>,
208 #[serde(rename = "x-enum-from")]
212 enum_from: Option<String>,
213 #[serde(rename = "x-dual-list-options", default)]
215 dual_list_options: Vec<DualListOptionEntry>,
216 #[serde(rename = "x-dual-list-sibling")]
218 dual_list_sibling: Option<String>,
219}
220
221#[derive(Debug, Deserialize)]
223struct EnumValueEntry {
224 #[serde(rename = "ref")]
226 ref_path: String,
227 name: Option<String>,
229 value: serde_json::Value,
231}
232
233#[derive(Debug, Deserialize)]
235struct DualListOptionEntry {
236 value: String,
238 name: Option<String>,
240}
241
242#[derive(Debug, Deserialize)]
244#[serde(untagged)]
245enum AdditionalProperties {
246 Bool(bool),
247 Schema(Box<RawSchema>),
248}
249
250#[derive(Debug, Deserialize)]
252#[serde(untagged)]
253enum SchemaType {
254 Single(String),
255 Multiple(Vec<String>),
256}
257
258impl SchemaType {
259 fn primary(&self) -> Option<&str> {
261 match self {
262 Self::Single(s) => Some(s.as_str()),
263 Self::Multiple(v) => v.first().map(|s| s.as_str()),
264 }
265 }
266
267 fn contains_null(&self) -> bool {
269 match self {
270 Self::Single(s) => s == "null",
271 Self::Multiple(v) => v.iter().any(|s| s == "null"),
272 }
273 }
274}
275
276type EnumValuesMap = HashMap<String, Vec<EnumOption>>;
278
279pub fn parse_schema(schema_json: &str) -> Result<Vec<SettingCategory>, serde_json::Error> {
281 let raw: RawSchema = serde_json::from_str(schema_json)?;
282
283 let defs = raw.defs.unwrap_or_default();
284 let properties = raw.properties.unwrap_or_default();
285
286 let enum_values_map = build_enum_values_map(&raw.extensible_enum_values);
288
289 let mut categories = Vec::new();
290 let mut top_level_settings = Vec::new();
291
292 let mut sorted_props: Vec<_> = properties.into_iter().collect();
294 sorted_props.sort_by(|a, b| a.0.cmp(&b.0));
295 for (name, prop) in sorted_props {
296 let path = format!("/{}", name);
297 let display_name = humanize_name(&name);
298
299 let resolved = resolve_ref(&prop, &defs);
301
302 let is_nullable = prop.any_of.as_ref().is_some_and(|variants| {
304 variants.iter().any(|v| {
305 v.schema_type
306 .as_ref()
307 .map(|t| t.primary() == Some("null"))
308 .unwrap_or(false)
309 })
310 });
311
312 if prop.standalone_category {
314 let setting = parse_setting(&name, &path, &prop, &defs, &enum_values_map);
316 categories.push(SettingCategory {
317 name: display_name,
318 path: path.clone(),
319 description: prop.description.clone().or(resolved.description.clone()),
320 nullable: is_nullable,
321 settings: vec![setting],
322 subcategories: Vec::new(),
323 });
324 } else if let Some(ref inner_props) = resolved.properties {
325 let settings = parse_properties(inner_props, &path, &defs, &enum_values_map);
327 let description = match (&prop.description, &resolved.description) {
330 (Some(field_desc), Some(struct_desc)) if field_desc != struct_desc => {
331 Some(format!("{}\n{}", field_desc, struct_desc))
332 }
333 (Some(d), _) | (_, Some(d)) => Some(d.clone()),
334 _ => None,
335 };
336 categories.push(SettingCategory {
337 name: display_name,
338 path: path.clone(),
339 description,
340 nullable: is_nullable,
341 settings,
342 subcategories: Vec::new(),
343 });
344 } else {
345 let setting = parse_setting(&name, &path, &prop, &defs, &enum_values_map);
347 top_level_settings.push(setting);
348 }
349 }
350
351 if !top_level_settings.is_empty() {
353 top_level_settings.sort_by(|a, b| a.name.cmp(&b.name));
355 categories.insert(
356 0,
357 SettingCategory {
358 name: "General".to_string(),
359 path: String::new(),
360 description: Some("General settings".to_string()),
361 nullable: false,
362 settings: top_level_settings,
363 subcategories: Vec::new(),
364 },
365 );
366 }
367
368 categories.sort_by(|a, b| match (a.name.as_str(), b.name.as_str()) {
370 ("General", _) => std::cmp::Ordering::Less,
371 (_, "General") => std::cmp::Ordering::Greater,
372 (a, b) => a.cmp(b),
373 });
374
375 Ok(categories)
376}
377
378fn build_enum_values_map(entries: &[EnumValueEntry]) -> EnumValuesMap {
380 let mut map: EnumValuesMap = HashMap::new();
381
382 for entry in entries {
383 let value_str = match &entry.value {
384 serde_json::Value::String(s) => s.clone(),
385 other => other.to_string(),
386 };
387
388 let option = EnumOption {
389 name: entry.name.clone().unwrap_or_else(|| value_str.clone()),
390 value: value_str,
391 };
392
393 map.entry(entry.ref_path.clone()).or_default().push(option);
394 }
395
396 map
397}
398
399fn parse_properties(
401 properties: &HashMap<String, RawSchema>,
402 parent_path: &str,
403 defs: &HashMap<String, RawSchema>,
404 enum_values_map: &EnumValuesMap,
405) -> Vec<SettingSchema> {
406 let mut settings = Vec::new();
407
408 for (name, prop) in properties {
409 let path = format!("{}/{}", parent_path, name);
410 let setting = parse_setting(name, &path, prop, defs, enum_values_map);
411
412 settings.push(setting);
413 }
414
415 settings.sort_by(|a, b| match (a.order, b.order) {
418 (Some(a_ord), Some(b_ord)) => a_ord.cmp(&b_ord).then_with(|| a.name.cmp(&b.name)),
419 (Some(_), None) => std::cmp::Ordering::Less,
420 (None, Some(_)) => std::cmp::Ordering::Greater,
421 (None, None) => a.name.cmp(&b.name),
422 });
423
424 settings
425}
426
427fn parse_setting(
429 name: &str,
430 path: &str,
431 schema: &RawSchema,
432 defs: &HashMap<String, RawSchema>,
433 enum_values_map: &EnumValuesMap,
434) -> SettingSchema {
435 let setting_type = determine_type(schema, defs, enum_values_map);
436
437 let resolved = resolve_ref(schema, defs);
439 let description = schema
440 .description
441 .clone()
442 .or_else(|| resolved.description.clone());
443
444 let read_only = schema.read_only || resolved.read_only;
446
447 let section = schema.section.clone().or_else(|| resolved.section.clone());
449
450 let order = schema.order.or(resolved.order);
452
453 let nullable = resolved
455 .schema_type
456 .as_ref()
457 .map(|t| t.contains_null())
458 .unwrap_or(false)
459 || schema.any_of.as_ref().is_some_and(|variants| {
460 variants.iter().any(|v| {
461 v.schema_type
462 .as_ref()
463 .map(|t| t.primary() == Some("null"))
464 .unwrap_or(false)
465 })
466 });
467
468 SettingSchema {
469 path: path.to_string(),
470 name: i18n_name(path, name),
471 description,
472 setting_type,
473 default: schema.default.clone(),
474 read_only,
475 section,
476 order,
477 nullable,
478 enum_from: schema
479 .enum_from
480 .clone()
481 .or_else(|| resolved.enum_from.clone()),
482 dual_list_sibling: schema
483 .dual_list_sibling
484 .clone()
485 .or_else(|| resolved.dual_list_sibling.clone()),
486 }
487}
488
489fn determine_type(
491 schema: &RawSchema,
492 defs: &HashMap<String, RawSchema>,
493 enum_values_map: &EnumValuesMap,
494) -> SettingType {
495 if let Some(ref ref_path) = schema.ref_path {
497 if let Some(options) = enum_values_map.get(ref_path) {
498 if !options.is_empty() {
499 return SettingType::Enum {
500 options: options.clone(),
501 };
502 }
503 }
504 }
505
506 let resolved = resolve_ref(schema, defs);
508
509 let enum_values = schema
511 .enum_values
512 .as_ref()
513 .or(resolved.enum_values.as_ref());
514 if let Some(values) = enum_values {
515 let options: Vec<EnumOption> = values
516 .iter()
517 .filter_map(|v| {
518 if v.is_null() {
519 Some(EnumOption {
521 name: "Auto-detect".to_string(),
522 value: String::new(), })
524 } else {
525 v.as_str().map(|s| EnumOption {
526 name: s.to_string(),
527 value: s.to_string(),
528 })
529 }
530 })
531 .collect();
532 if !options.is_empty() {
533 return SettingType::Enum { options };
534 }
535 }
536
537 match resolved.schema_type.as_ref().and_then(|t| t.primary()) {
539 Some("boolean") => SettingType::Boolean,
540 Some("integer") => {
541 let minimum = resolved.minimum.as_ref().and_then(|n| n.as_i64());
542 let maximum = resolved.maximum.as_ref().and_then(|n| n.as_i64());
543 SettingType::Integer { minimum, maximum }
544 }
545 Some("number") => {
546 let minimum = resolved.minimum.as_ref().and_then(|n| n.as_f64());
547 let maximum = resolved.maximum.as_ref().and_then(|n| n.as_f64());
548 SettingType::Number { minimum, maximum }
549 }
550 Some("string") => SettingType::String,
551 Some("array") => {
552 if let Some(ref items) = resolved.items {
554 let item_resolved = resolve_ref(items, defs);
555 if !item_resolved.dual_list_options.is_empty() {
557 let options = item_resolved
558 .dual_list_options
559 .iter()
560 .map(|entry| EnumOption {
561 name: entry.name.clone().unwrap_or_else(|| entry.value.clone()),
562 value: entry.value.clone(),
563 })
564 .collect();
565 return SettingType::DualList {
566 options,
567 sibling_path: schema
568 .dual_list_sibling
569 .clone()
570 .or_else(|| resolved.dual_list_sibling.clone()),
571 };
572 }
573 let item_type = item_resolved.schema_type.as_ref().and_then(|t| t.primary());
574 if item_type == Some("string") {
575 return SettingType::StringArray;
576 }
577 if item_type == Some("integer") || item_type == Some("number") {
578 return SettingType::IntegerArray;
579 }
580 if items.ref_path.is_some() {
582 let item_schema =
584 parse_setting("item", "", item_resolved, defs, enum_values_map);
585
586 if matches!(item_schema.setting_type, SettingType::Object { .. }) {
588 let display_field = item_resolved.display_field.clone();
590 return SettingType::ObjectArray {
591 item_schema: Box::new(item_schema),
592 display_field,
593 };
594 }
595 }
596 }
597 SettingType::Complex
598 }
599 Some("object") => {
600 if let Some(ref add_props) = resolved.additional_properties {
602 match add_props {
603 AdditionalProperties::Schema(schema_box) => {
604 let inner_resolved = resolve_ref(schema_box, defs);
605 let value_schema =
606 parse_setting("value", "", inner_resolved, defs, enum_values_map);
607
608 let display_field = inner_resolved.display_field.clone().or_else(|| {
611 inner_resolved.items.as_ref().and_then(|items| {
612 let items_resolved = resolve_ref(items, defs);
613 items_resolved.display_field.clone()
614 })
615 });
616
617 let no_add = resolved.no_add;
619
620 return SettingType::Map {
621 value_schema: Box::new(value_schema),
622 display_field,
623 no_add,
624 };
625 }
626 AdditionalProperties::Bool(true) => {
627 return SettingType::Complex;
629 }
630 AdditionalProperties::Bool(false) => {
631 }
634 }
635 }
636 if let Some(ref props) = resolved.properties {
638 let properties = parse_properties(props, "", defs, enum_values_map);
639 return SettingType::Object { properties };
640 }
641 SettingType::Complex
642 }
643 _ => SettingType::Complex,
644 }
645}
646
647fn resolve_ref<'a>(schema: &'a RawSchema, defs: &'a HashMap<String, RawSchema>) -> &'a RawSchema {
653 if let Some(ref ref_path) = schema.ref_path {
655 if let Some(def_name) = ref_path.strip_prefix("#/$defs/") {
656 if let Some(def) = defs.get(def_name) {
657 return def;
658 }
659 }
660 }
661 if let Some(ref variants) = schema.any_of {
663 for variant in variants {
664 let is_null = variant
665 .schema_type
666 .as_ref()
667 .map(|t| t.primary() == Some("null"))
668 .unwrap_or(false);
669 if !is_null {
670 return resolve_ref(variant, defs);
671 }
672 }
673 }
674 schema
675}
676
677fn i18n_name(path: &str, fallback_name: &str) -> String {
683 let key = format!("settings.field{}", path.replace('/', "."));
684 let translated = t!(&key);
685 if *translated == key {
686 humanize_name(fallback_name)
687 } else {
688 translated.to_string()
689 }
690}
691
692fn humanize_name(name: &str) -> String {
694 name.split('_')
695 .map(|word| {
696 let mut chars = word.chars();
697 match chars.next() {
698 None => String::new(),
699 Some(first) => first.to_uppercase().chain(chars).collect(),
700 }
701 })
702 .collect::<Vec<_>>()
703 .join(" ")
704}
705
706#[cfg(test)]
707mod tests {
708 use super::*;
709
710 const SAMPLE_SCHEMA: &str = r##"
711{
712 "$schema": "https://json-schema.org/draft/2020-12/schema",
713 "title": "Config",
714 "type": "object",
715 "properties": {
716 "theme": {
717 "description": "Color theme name",
718 "type": "string",
719 "default": "high-contrast"
720 },
721 "check_for_updates": {
722 "description": "Check for new versions on quit",
723 "type": "boolean",
724 "default": true
725 },
726 "editor": {
727 "description": "Editor settings",
728 "$ref": "#/$defs/EditorConfig"
729 }
730 },
731 "$defs": {
732 "EditorConfig": {
733 "description": "Editor behavior configuration",
734 "type": "object",
735 "properties": {
736 "tab_size": {
737 "description": "Number of spaces per tab",
738 "type": "integer",
739 "minimum": 1,
740 "maximum": 16,
741 "default": 4
742 },
743 "line_numbers": {
744 "description": "Show line numbers",
745 "type": "boolean",
746 "default": true
747 }
748 }
749 }
750 }
751}
752"##;
753
754 #[test]
755 fn test_parse_schema() {
756 let categories = parse_schema(SAMPLE_SCHEMA).unwrap();
757
758 assert_eq!(categories.len(), 2);
760 assert_eq!(categories[0].name, "General");
761 assert_eq!(categories[1].name, "Editor");
762 }
763
764 #[test]
765 fn test_general_category() {
766 let categories = parse_schema(SAMPLE_SCHEMA).unwrap();
767 let general = &categories[0];
768
769 assert_eq!(general.settings.len(), 2);
771
772 let theme = general
773 .settings
774 .iter()
775 .find(|s| s.path == "/theme")
776 .unwrap();
777 assert!(matches!(theme.setting_type, SettingType::String));
778
779 let updates = general
780 .settings
781 .iter()
782 .find(|s| s.path == "/check_for_updates")
783 .unwrap();
784 assert!(matches!(updates.setting_type, SettingType::Boolean));
785 }
786
787 #[test]
788 fn test_editor_category() {
789 let categories = parse_schema(SAMPLE_SCHEMA).unwrap();
790 let editor = &categories[1];
791
792 assert_eq!(editor.path, "/editor");
793 assert_eq!(editor.settings.len(), 2);
794
795 let tab_size = editor
796 .settings
797 .iter()
798 .find(|s| s.name == "Tab Size")
799 .unwrap();
800 if let SettingType::Integer { minimum, maximum } = &tab_size.setting_type {
801 assert_eq!(*minimum, Some(1));
802 assert_eq!(*maximum, Some(16));
803 } else {
804 panic!("Expected integer type");
805 }
806 }
807
808 #[test]
809 fn test_any_of_nullable_object() {
810 let schema_json = r##"
813{
814 "$schema": "https://json-schema.org/draft/2020-12/schema",
815 "title": "Config",
816 "type": "object",
817 "properties": {
818 "fallback": {
819 "description": "Fallback language config",
820 "anyOf": [
821 { "$ref": "#/$defs/LanguageConfig" },
822 { "type": "null" }
823 ],
824 "default": null
825 }
826 },
827 "$defs": {
828 "LanguageConfig": {
829 "description": "Language-specific configuration",
830 "type": "object",
831 "properties": {
832 "grammar": {
833 "description": "Grammar name",
834 "type": "string",
835 "default": ""
836 },
837 "comment_prefix": {
838 "description": "Comment prefix",
839 "type": ["string", "null"],
840 "default": null
841 },
842 "auto_indent": {
843 "description": "Enable auto-indent",
844 "type": "boolean",
845 "default": false
846 }
847 }
848 }
849 }
850}
851"##;
852 let categories = parse_schema(schema_json).unwrap();
853
854 let fallback_cat = categories
857 .iter()
858 .find(|c| c.path == "/fallback")
859 .expect("fallback should be a category");
860 assert_eq!(fallback_cat.settings.len(), 3);
861
862 let grammar = fallback_cat
864 .settings
865 .iter()
866 .find(|s| s.name == "Grammar")
867 .unwrap();
868 assert!(matches!(grammar.setting_type, SettingType::String));
869
870 let auto_indent = fallback_cat
871 .settings
872 .iter()
873 .find(|s| s.name == "Auto Indent")
874 .unwrap();
875 assert!(matches!(auto_indent.setting_type, SettingType::Boolean));
876 }
877
878 #[test]
879 fn test_humanize_name() {
880 assert_eq!(humanize_name("tab_size"), "Tab Size");
881 assert_eq!(humanize_name("line_numbers"), "Line Numbers");
882 assert_eq!(humanize_name("check_for_updates"), "Check For Updates");
883 assert_eq!(humanize_name("lsp"), "Lsp");
884 }
885
886 #[test]
887 fn test_enum_from_parsed_from_schema() {
888 let schema_json = r##"{
889 "type": "object",
890 "properties": {
891 "default_language": {
892 "type": ["string", "null"],
893 "x-enum-from": "/languages"
894 },
895 "theme": {
896 "type": "string"
897 }
898 }
899 }"##;
900
901 let categories = parse_schema(schema_json).unwrap();
902 let general = &categories[0];
903 let default_lang = general
904 .settings
905 .iter()
906 .find(|s| s.name == "Default Language")
907 .expect("should have Default Language setting");
908
909 assert_eq!(
910 default_lang.enum_from.as_deref(),
911 Some("/languages"),
912 "enum_from should be parsed from x-enum-from"
913 );
914 assert!(default_lang.nullable, "should be nullable");
915
916 let theme = general
918 .settings
919 .iter()
920 .find(|s| s.name == "Theme")
921 .expect("should have Theme setting");
922 assert!(theme.enum_from.is_none());
923 }
924
925 #[test]
926 fn test_dual_list_parsed_from_schema() {
927 let schema_json = r##"{
928 "type": "object",
929 "properties": {
930 "tags": {
931 "type": "array",
932 "items": {
933 "type": "string",
934 "x-dual-list-options": [
935 {"value": "red", "name": "Red"},
936 {"value": "green", "name": "Green"},
937 {"value": "blue", "name": "Blue"}
938 ]
939 },
940 "x-dual-list-sibling": "/other_tags"
941 }
942 }
943 }"##;
944 let categories = parse_schema(schema_json).unwrap();
945 let general = &categories[0];
946 let tags = general
947 .settings
948 .iter()
949 .find(|s| s.path == "/tags")
950 .expect("tags setting");
951
952 match &tags.setting_type {
953 SettingType::DualList {
954 options,
955 sibling_path,
956 } => {
957 assert_eq!(options.len(), 3);
958 assert_eq!(options[0].value, "red");
959 assert_eq!(options[0].name, "Red");
960 assert_eq!(sibling_path.as_deref(), Some("/other_tags"));
961 }
962 other => panic!("expected DualList, got {:?}", other),
963 }
964 assert_eq!(tags.dual_list_sibling.as_deref(), Some("/other_tags"));
965 }
966}