use rust_i18n::t;
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct SettingSchema {
pub path: String,
pub name: String,
pub description: Option<String>,
pub setting_type: SettingType,
pub default: Option<serde_json::Value>,
pub read_only: bool,
pub section: Option<String>,
pub order: Option<i32>,
pub nullable: bool,
pub enum_from: Option<String>,
pub dual_list_sibling: Option<String>,
}
#[derive(Debug, Clone)]
pub enum SettingType {
Boolean,
Integer {
minimum: Option<i64>,
maximum: Option<i64>,
},
Number {
minimum: Option<f64>,
maximum: Option<f64>,
},
String,
Enum { options: Vec<EnumOption> },
StringArray,
IntegerArray,
ObjectArray {
item_schema: Box<SettingSchema>,
display_field: Option<String>,
},
Object { properties: Vec<SettingSchema> },
Map {
value_schema: Box<SettingSchema>,
display_field: Option<String>,
no_add: bool,
},
DualList {
options: Vec<EnumOption>,
sibling_path: Option<String>,
},
Complex,
}
#[derive(Debug, Clone)]
pub struct EnumOption {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone)]
pub struct SettingCategory {
pub name: String,
pub path: String,
pub description: Option<String>,
pub nullable: bool,
pub settings: Vec<SettingSchema>,
pub subcategories: Vec<SettingCategory>,
}
#[derive(Debug, Deserialize)]
struct RawSchema {
#[serde(rename = "type")]
schema_type: Option<SchemaType>,
description: Option<String>,
default: Option<serde_json::Value>,
properties: Option<HashMap<String, RawSchema>>,
items: Option<Box<RawSchema>>,
#[serde(rename = "enum")]
enum_values: Option<Vec<serde_json::Value>>,
minimum: Option<serde_json::Number>,
maximum: Option<serde_json::Number>,
#[serde(rename = "$ref")]
ref_path: Option<String>,
#[serde(rename = "$defs")]
defs: Option<HashMap<String, RawSchema>>,
#[serde(rename = "additionalProperties")]
additional_properties: Option<AdditionalProperties>,
#[serde(rename = "x-enum-values", default)]
extensible_enum_values: Vec<EnumValueEntry>,
#[serde(rename = "x-display-field")]
display_field: Option<String>,
#[serde(rename = "readOnly", default)]
read_only: bool,
#[serde(rename = "x-standalone-category", default)]
standalone_category: bool,
#[serde(rename = "x-no-add", default)]
no_add: bool,
#[serde(rename = "x-section")]
section: Option<String>,
#[serde(rename = "x-order")]
order: Option<i32>,
#[serde(rename = "anyOf")]
any_of: Option<Vec<RawSchema>>,
#[serde(rename = "x-enum-from")]
enum_from: Option<String>,
#[serde(rename = "x-dual-list-options", default)]
dual_list_options: Vec<DualListOptionEntry>,
#[serde(rename = "x-dual-list-sibling")]
dual_list_sibling: Option<String>,
}
#[derive(Debug, Deserialize)]
struct EnumValueEntry {
#[serde(rename = "ref")]
ref_path: String,
name: Option<String>,
value: serde_json::Value,
}
#[derive(Debug, Deserialize)]
struct DualListOptionEntry {
value: String,
name: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum AdditionalProperties {
Bool(bool),
Schema(Box<RawSchema>),
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum SchemaType {
Single(String),
Multiple(Vec<String>),
}
impl SchemaType {
fn primary(&self) -> Option<&str> {
match self {
Self::Single(s) => Some(s.as_str()),
Self::Multiple(v) => v.first().map(|s| s.as_str()),
}
}
fn contains_null(&self) -> bool {
match self {
Self::Single(s) => s == "null",
Self::Multiple(v) => v.iter().any(|s| s == "null"),
}
}
}
type EnumValuesMap = HashMap<String, Vec<EnumOption>>;
pub fn parse_schema(schema_json: &str) -> Result<Vec<SettingCategory>, serde_json::Error> {
let raw: RawSchema = serde_json::from_str(schema_json)?;
let defs = raw.defs.unwrap_or_default();
let properties = raw.properties.unwrap_or_default();
let enum_values_map = build_enum_values_map(&raw.extensible_enum_values);
let mut categories = Vec::new();
let mut top_level_settings = Vec::new();
let mut sorted_props: Vec<_> = properties.into_iter().collect();
sorted_props.sort_by(|a, b| a.0.cmp(&b.0));
for (name, prop) in sorted_props {
let path = format!("/{}", name);
let display_name = humanize_name(&name);
let resolved = resolve_ref(&prop, &defs);
let is_nullable = prop.any_of.as_ref().is_some_and(|variants| {
variants.iter().any(|v| {
v.schema_type
.as_ref()
.map(|t| t.primary() == Some("null"))
.unwrap_or(false)
})
});
if prop.standalone_category {
let setting = parse_setting(&name, &path, &prop, &defs, &enum_values_map);
categories.push(SettingCategory {
name: display_name,
path: path.clone(),
description: prop.description.clone().or(resolved.description.clone()),
nullable: is_nullable,
settings: vec![setting],
subcategories: Vec::new(),
});
} else if let Some(ref inner_props) = resolved.properties {
let settings = parse_properties(inner_props, &path, &defs, &enum_values_map);
let description = match (&prop.description, &resolved.description) {
(Some(field_desc), Some(struct_desc)) if field_desc != struct_desc => {
Some(format!("{}\n{}", field_desc, struct_desc))
}
(Some(d), _) | (_, Some(d)) => Some(d.clone()),
_ => None,
};
categories.push(SettingCategory {
name: display_name,
path: path.clone(),
description,
nullable: is_nullable,
settings,
subcategories: Vec::new(),
});
} else {
let setting = parse_setting(&name, &path, &prop, &defs, &enum_values_map);
top_level_settings.push(setting);
}
}
if !top_level_settings.is_empty() {
top_level_settings.sort_by(|a, b| a.name.cmp(&b.name));
categories.insert(
0,
SettingCategory {
name: "General".to_string(),
path: String::new(),
description: Some("General settings".to_string()),
nullable: false,
settings: top_level_settings,
subcategories: Vec::new(),
},
);
}
categories.sort_by(|a, b| match (a.name.as_str(), b.name.as_str()) {
("General", _) => std::cmp::Ordering::Less,
(_, "General") => std::cmp::Ordering::Greater,
(a, b) => a.cmp(b),
});
Ok(categories)
}
fn build_enum_values_map(entries: &[EnumValueEntry]) -> EnumValuesMap {
let mut map: EnumValuesMap = HashMap::new();
for entry in entries {
let value_str = match &entry.value {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
let option = EnumOption {
name: entry.name.clone().unwrap_or_else(|| value_str.clone()),
value: value_str,
};
map.entry(entry.ref_path.clone()).or_default().push(option);
}
map
}
fn parse_properties(
properties: &HashMap<String, RawSchema>,
parent_path: &str,
defs: &HashMap<String, RawSchema>,
enum_values_map: &EnumValuesMap,
) -> Vec<SettingSchema> {
let mut settings = Vec::new();
for (name, prop) in properties {
let path = format!("{}/{}", parent_path, name);
let setting = parse_setting(name, &path, prop, defs, enum_values_map);
settings.push(setting);
}
settings.sort_by(|a, b| match (a.order, b.order) {
(Some(a_ord), Some(b_ord)) => a_ord.cmp(&b_ord).then_with(|| a.name.cmp(&b.name)),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => a.name.cmp(&b.name),
});
settings
}
fn parse_setting(
name: &str,
path: &str,
schema: &RawSchema,
defs: &HashMap<String, RawSchema>,
enum_values_map: &EnumValuesMap,
) -> SettingSchema {
let setting_type = determine_type(schema, defs, enum_values_map);
let resolved = resolve_ref(schema, defs);
let description = schema
.description
.clone()
.or_else(|| resolved.description.clone());
let read_only = schema.read_only || resolved.read_only;
let section = schema.section.clone().or_else(|| resolved.section.clone());
let order = schema.order.or(resolved.order);
let nullable = resolved
.schema_type
.as_ref()
.map(|t| t.contains_null())
.unwrap_or(false)
|| schema.any_of.as_ref().is_some_and(|variants| {
variants.iter().any(|v| {
v.schema_type
.as_ref()
.map(|t| t.primary() == Some("null"))
.unwrap_or(false)
})
});
SettingSchema {
path: path.to_string(),
name: i18n_name(path, name),
description,
setting_type,
default: schema.default.clone(),
read_only,
section,
order,
nullable,
enum_from: schema
.enum_from
.clone()
.or_else(|| resolved.enum_from.clone()),
dual_list_sibling: schema
.dual_list_sibling
.clone()
.or_else(|| resolved.dual_list_sibling.clone()),
}
}
fn determine_type(
schema: &RawSchema,
defs: &HashMap<String, RawSchema>,
enum_values_map: &EnumValuesMap,
) -> SettingType {
if let Some(ref ref_path) = schema.ref_path {
if let Some(options) = enum_values_map.get(ref_path) {
if !options.is_empty() {
return SettingType::Enum {
options: options.clone(),
};
}
}
}
let resolved = resolve_ref(schema, defs);
let enum_values = schema
.enum_values
.as_ref()
.or(resolved.enum_values.as_ref());
if let Some(values) = enum_values {
let options: Vec<EnumOption> = values
.iter()
.filter_map(|v| {
if v.is_null() {
Some(EnumOption {
name: "Auto-detect".to_string(),
value: String::new(), })
} else {
v.as_str().map(|s| EnumOption {
name: s.to_string(),
value: s.to_string(),
})
}
})
.collect();
if !options.is_empty() {
return SettingType::Enum { options };
}
}
match resolved.schema_type.as_ref().and_then(|t| t.primary()) {
Some("boolean") => SettingType::Boolean,
Some("integer") => {
let minimum = resolved.minimum.as_ref().and_then(|n| n.as_i64());
let maximum = resolved.maximum.as_ref().and_then(|n| n.as_i64());
SettingType::Integer { minimum, maximum }
}
Some("number") => {
let minimum = resolved.minimum.as_ref().and_then(|n| n.as_f64());
let maximum = resolved.maximum.as_ref().and_then(|n| n.as_f64());
SettingType::Number { minimum, maximum }
}
Some("string") => SettingType::String,
Some("array") => {
if let Some(ref items) = resolved.items {
let item_resolved = resolve_ref(items, defs);
if !item_resolved.dual_list_options.is_empty() {
let options = item_resolved
.dual_list_options
.iter()
.map(|entry| EnumOption {
name: entry.name.clone().unwrap_or_else(|| entry.value.clone()),
value: entry.value.clone(),
})
.collect();
return SettingType::DualList {
options,
sibling_path: schema
.dual_list_sibling
.clone()
.or_else(|| resolved.dual_list_sibling.clone()),
};
}
let item_type = item_resolved.schema_type.as_ref().and_then(|t| t.primary());
if item_type == Some("string") {
return SettingType::StringArray;
}
if item_type == Some("integer") || item_type == Some("number") {
return SettingType::IntegerArray;
}
if items.ref_path.is_some() {
let item_schema =
parse_setting("item", "", item_resolved, defs, enum_values_map);
if matches!(item_schema.setting_type, SettingType::Object { .. }) {
let display_field = item_resolved.display_field.clone();
return SettingType::ObjectArray {
item_schema: Box::new(item_schema),
display_field,
};
}
}
}
SettingType::Complex
}
Some("object") => {
if let Some(ref add_props) = resolved.additional_properties {
match add_props {
AdditionalProperties::Schema(schema_box) => {
let inner_resolved = resolve_ref(schema_box, defs);
let value_schema =
parse_setting("value", "", inner_resolved, defs, enum_values_map);
let display_field = inner_resolved.display_field.clone().or_else(|| {
inner_resolved.items.as_ref().and_then(|items| {
let items_resolved = resolve_ref(items, defs);
items_resolved.display_field.clone()
})
});
let no_add = resolved.no_add;
return SettingType::Map {
value_schema: Box::new(value_schema),
display_field,
no_add,
};
}
AdditionalProperties::Bool(true) => {
return SettingType::Complex;
}
AdditionalProperties::Bool(false) => {
}
}
}
if let Some(ref props) = resolved.properties {
let properties = parse_properties(props, "", defs, enum_values_map);
return SettingType::Object { properties };
}
SettingType::Complex
}
_ => SettingType::Complex,
}
}
fn resolve_ref<'a>(schema: &'a RawSchema, defs: &'a HashMap<String, RawSchema>) -> &'a RawSchema {
if let Some(ref ref_path) = schema.ref_path {
if let Some(def_name) = ref_path.strip_prefix("#/$defs/") {
if let Some(def) = defs.get(def_name) {
return def;
}
}
}
if let Some(ref variants) = schema.any_of {
for variant in variants {
let is_null = variant
.schema_type
.as_ref()
.map(|t| t.primary() == Some("null"))
.unwrap_or(false);
if !is_null {
return resolve_ref(variant, defs);
}
}
}
schema
}
fn i18n_name(path: &str, fallback_name: &str) -> String {
let key = format!("settings.field{}", path.replace('/', "."));
let translated = t!(&key);
if *translated == key {
humanize_name(fallback_name)
} else {
translated.to_string()
}
}
fn humanize_name(name: &str) -> String {
name.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(chars).collect(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_SCHEMA: &str = r##"
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Config",
"type": "object",
"properties": {
"theme": {
"description": "Color theme name",
"type": "string",
"default": "high-contrast"
},
"check_for_updates": {
"description": "Check for new versions on quit",
"type": "boolean",
"default": true
},
"editor": {
"description": "Editor settings",
"$ref": "#/$defs/EditorConfig"
}
},
"$defs": {
"EditorConfig": {
"description": "Editor behavior configuration",
"type": "object",
"properties": {
"tab_size": {
"description": "Number of spaces per tab",
"type": "integer",
"minimum": 1,
"maximum": 16,
"default": 4
},
"line_numbers": {
"description": "Show line numbers",
"type": "boolean",
"default": true
}
}
}
}
}
"##;
#[test]
fn test_parse_schema() {
let categories = parse_schema(SAMPLE_SCHEMA).unwrap();
assert_eq!(categories.len(), 2);
assert_eq!(categories[0].name, "General");
assert_eq!(categories[1].name, "Editor");
}
#[test]
fn test_general_category() {
let categories = parse_schema(SAMPLE_SCHEMA).unwrap();
let general = &categories[0];
assert_eq!(general.settings.len(), 2);
let theme = general
.settings
.iter()
.find(|s| s.path == "/theme")
.unwrap();
assert!(matches!(theme.setting_type, SettingType::String));
let updates = general
.settings
.iter()
.find(|s| s.path == "/check_for_updates")
.unwrap();
assert!(matches!(updates.setting_type, SettingType::Boolean));
}
#[test]
fn test_editor_category() {
let categories = parse_schema(SAMPLE_SCHEMA).unwrap();
let editor = &categories[1];
assert_eq!(editor.path, "/editor");
assert_eq!(editor.settings.len(), 2);
let tab_size = editor
.settings
.iter()
.find(|s| s.name == "Tab Size")
.unwrap();
if let SettingType::Integer { minimum, maximum } = &tab_size.setting_type {
assert_eq!(*minimum, Some(1));
assert_eq!(*maximum, Some(16));
} else {
panic!("Expected integer type");
}
}
#[test]
fn test_any_of_nullable_object() {
let schema_json = r##"
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Config",
"type": "object",
"properties": {
"fallback": {
"description": "Fallback language config",
"anyOf": [
{ "$ref": "#/$defs/LanguageConfig" },
{ "type": "null" }
],
"default": null
}
},
"$defs": {
"LanguageConfig": {
"description": "Language-specific configuration",
"type": "object",
"properties": {
"grammar": {
"description": "Grammar name",
"type": "string",
"default": ""
},
"comment_prefix": {
"description": "Comment prefix",
"type": ["string", "null"],
"default": null
},
"auto_indent": {
"description": "Enable auto-indent",
"type": "boolean",
"default": false
}
}
}
}
}
"##;
let categories = parse_schema(schema_json).unwrap();
let fallback_cat = categories
.iter()
.find(|c| c.path == "/fallback")
.expect("fallback should be a category");
assert_eq!(fallback_cat.settings.len(), 3);
let grammar = fallback_cat
.settings
.iter()
.find(|s| s.name == "Grammar")
.unwrap();
assert!(matches!(grammar.setting_type, SettingType::String));
let auto_indent = fallback_cat
.settings
.iter()
.find(|s| s.name == "Auto Indent")
.unwrap();
assert!(matches!(auto_indent.setting_type, SettingType::Boolean));
}
#[test]
fn test_humanize_name() {
assert_eq!(humanize_name("tab_size"), "Tab Size");
assert_eq!(humanize_name("line_numbers"), "Line Numbers");
assert_eq!(humanize_name("check_for_updates"), "Check For Updates");
assert_eq!(humanize_name("lsp"), "Lsp");
}
#[test]
fn test_enum_from_parsed_from_schema() {
let schema_json = r##"{
"type": "object",
"properties": {
"default_language": {
"type": ["string", "null"],
"x-enum-from": "/languages"
},
"theme": {
"type": "string"
}
}
}"##;
let categories = parse_schema(schema_json).unwrap();
let general = &categories[0];
let default_lang = general
.settings
.iter()
.find(|s| s.name == "Default Language")
.expect("should have Default Language setting");
assert_eq!(
default_lang.enum_from.as_deref(),
Some("/languages"),
"enum_from should be parsed from x-enum-from"
);
assert!(default_lang.nullable, "should be nullable");
let theme = general
.settings
.iter()
.find(|s| s.name == "Theme")
.expect("should have Theme setting");
assert!(theme.enum_from.is_none());
}
#[test]
fn test_dual_list_parsed_from_schema() {
let schema_json = r##"{
"type": "object",
"properties": {
"tags": {
"type": "array",
"items": {
"type": "string",
"x-dual-list-options": [
{"value": "red", "name": "Red"},
{"value": "green", "name": "Green"},
{"value": "blue", "name": "Blue"}
]
},
"x-dual-list-sibling": "/other_tags"
}
}
}"##;
let categories = parse_schema(schema_json).unwrap();
let general = &categories[0];
let tags = general
.settings
.iter()
.find(|s| s.path == "/tags")
.expect("tags setting");
match &tags.setting_type {
SettingType::DualList {
options,
sibling_path,
} => {
assert_eq!(options.len(), 3);
assert_eq!(options[0].value, "red");
assert_eq!(options[0].name, "Red");
assert_eq!(sibling_path.as_deref(), Some("/other_tags"));
}
other => panic!("expected DualList, got {:?}", other),
}
assert_eq!(tags.dual_list_sibling.as_deref(), Some("/other_tags"));
}
}