Skip to main content

fresh/view/settings/
schema.rs

1//! JSON Schema parsing for settings UI
2//!
3//! Parses the config JSON Schema to build the settings UI structure.
4//!
5//! # Extensible Enums with `x-enum-values`
6//!
7//! This module supports a custom JSON Schema extension called `x-enum-values` that allows
8//! enum values to be defined separately from type definitions. This enables extensibility -
9//! new enum values can be added without modifying the type schema.
10//!
11//! ## How it works
12//!
13//! 1. Define a type in `$defs` (without hardcoded enum values):
14//!    ```json
15//!    "$defs": {
16//!      "ThemeOptions": {
17//!        "type": "string"
18//!      }
19//!    }
20//!    ```
21//!
22//! 2. Reference the type in properties:
23//!    ```json
24//!    "properties": {
25//!      "theme": {
26//!        "$ref": "#/$defs/ThemeOptions",
27//!        "default": "dark"
28//!      }
29//!    }
30//!    ```
31//!
32//! 3. Define enum values separately - each value declares which type it extends:
33//!    ```json
34//!    "x-enum-values": [
35//!      { "ref": "#/$defs/ThemeOptions", "name": "Dark", "value": "dark" },
36//!      { "ref": "#/$defs/ThemeOptions", "name": "Light", "value": "light" },
37//!      { "ref": "#/$defs/ThemeOptions", "name": "High Contrast", "value": "high-contrast" }
38//!    ]
39//!    ```
40//!
41//! ## Entry structure
42//!
43//! Each entry in `x-enum-values` has:
44//! - `ref` (required): JSON pointer to the type being extended (e.g., `#/$defs/ThemeOptions`)
45//! - `value` (required): The actual value, must match the referenced type
46//! - `name` (optional): Human-friendly display name, defaults to `value` if not provided
47//!
48//! ## Benefits
49//!
50//! - **Extensibility**: Add new values without changing the schema structure
51//! - **Self-describing**: Values declare which type they belong to
52//! - **Plugin-friendly**: External sources can contribute enum values
53//! - **Type-safe**: Values are validated against their referenced type
54
55use rust_i18n::t;
56use serde::Deserialize;
57use std::collections::HashMap;
58
59/// A property/setting from the schema
60#[derive(Debug, Clone)]
61pub struct SettingSchema {
62    /// JSON pointer path (e.g., "/editor/tab_size")
63    pub path: String,
64    /// Human-readable name derived from property name
65    pub name: String,
66    /// Description from schema
67    pub description: Option<String>,
68    /// The type of this setting
69    pub setting_type: SettingType,
70    /// Default value (as JSON)
71    pub default: Option<serde_json::Value>,
72    /// Whether this field is read-only (cannot be edited by user)
73    pub read_only: bool,
74    /// Section/group within the category (from x-section)
75    pub section: Option<String>,
76    /// Sort order override (from x-order). Lower values sort first.
77    /// When set, overrides alphabetical sorting.
78    pub order: Option<i32>,
79}
80
81/// Type of a setting, determines which control to render
82#[derive(Debug, Clone)]
83pub enum SettingType {
84    /// Boolean toggle
85    Boolean,
86    /// Integer number with optional min/max
87    Integer {
88        minimum: Option<i64>,
89        maximum: Option<i64>,
90    },
91    /// Floating point number
92    Number {
93        minimum: Option<f64>,
94        maximum: Option<f64>,
95    },
96    /// Free-form string
97    String,
98    /// String with enumerated options (display name, value)
99    Enum { options: Vec<EnumOption> },
100    /// Array of strings
101    StringArray,
102    /// Array of integers (rendered as TextList, values parsed as numbers)
103    IntegerArray,
104    /// Array of objects with a schema (for keybindings, etc.)
105    ObjectArray {
106        item_schema: Box<SettingSchema>,
107        /// JSON pointer to field within item to display as preview (e.g., "/action")
108        display_field: Option<String>,
109    },
110    /// Nested object (category)
111    Object { properties: Vec<SettingSchema> },
112    /// Map with string keys (for languages, lsp configs)
113    Map {
114        value_schema: Box<SettingSchema>,
115        /// JSON pointer to field within value to display as preview (e.g., "/command")
116        display_field: Option<String>,
117        /// Whether to disallow adding new entries (entries are auto-managed)
118        no_add: bool,
119    },
120    /// Complex type we can't edit directly
121    Complex,
122}
123
124/// An option in an enum type
125#[derive(Debug, Clone)]
126pub struct EnumOption {
127    /// Display name shown in UI
128    pub name: String,
129    /// Actual value stored in config
130    pub value: String,
131}
132
133/// A category in the settings tree
134#[derive(Debug, Clone)]
135pub struct SettingCategory {
136    /// Category name (e.g., "Editor", "File Explorer")
137    pub name: String,
138    /// JSON path prefix for this category
139    pub path: String,
140    /// Description of this category
141    pub description: Option<String>,
142    /// Settings in this category
143    pub settings: Vec<SettingSchema>,
144    /// Subcategories
145    pub subcategories: Vec<SettingCategory>,
146}
147
148/// Raw JSON Schema structure for deserialization
149#[derive(Debug, Deserialize)]
150struct RawSchema {
151    #[serde(rename = "type")]
152    schema_type: Option<SchemaType>,
153    description: Option<String>,
154    default: Option<serde_json::Value>,
155    properties: Option<HashMap<String, RawSchema>>,
156    items: Option<Box<RawSchema>>,
157    #[serde(rename = "enum")]
158    enum_values: Option<Vec<serde_json::Value>>,
159    minimum: Option<serde_json::Number>,
160    maximum: Option<serde_json::Number>,
161    #[serde(rename = "$ref")]
162    ref_path: Option<String>,
163    #[serde(rename = "$defs")]
164    defs: Option<HashMap<String, RawSchema>>,
165    #[serde(rename = "additionalProperties")]
166    additional_properties: Option<AdditionalProperties>,
167    /// Extensible enum values - see module docs for details
168    #[serde(rename = "x-enum-values", default)]
169    extensible_enum_values: Vec<EnumValueEntry>,
170    /// Custom extension: field to display as preview in Map/ObjectArray entries
171    /// e.g., "/command" for OnSaveAction, "/action" for Keybinding
172    #[serde(rename = "x-display-field")]
173    display_field: Option<String>,
174    /// Whether this field is read-only
175    #[serde(rename = "readOnly", default)]
176    read_only: bool,
177    /// Whether this Map-type property should be rendered as its own category
178    #[serde(rename = "x-standalone-category", default)]
179    standalone_category: bool,
180    /// Whether this Map should disallow adding new entries (entries are auto-managed)
181    #[serde(rename = "x-no-add", default)]
182    no_add: bool,
183    /// Section/group within the category for organizing related settings
184    #[serde(rename = "x-section")]
185    section: Option<String>,
186    /// Sort order override for field ordering in entry dialogs
187    #[serde(rename = "x-order")]
188    order: Option<i32>,
189}
190
191/// An entry in the x-enum-values array
192#[derive(Debug, Deserialize)]
193struct EnumValueEntry {
194    /// JSON pointer to the type being extended (e.g., "#/$defs/ThemeOptions")
195    #[serde(rename = "ref")]
196    ref_path: String,
197    /// Human-friendly display name (optional, defaults to value)
198    name: Option<String>,
199    /// The actual value (must match the referenced type)
200    value: serde_json::Value,
201}
202
203/// additionalProperties can be a boolean or a schema object
204#[derive(Debug, Deserialize)]
205#[serde(untagged)]
206enum AdditionalProperties {
207    Bool(bool),
208    Schema(Box<RawSchema>),
209}
210
211/// JSON Schema type can be a single string or an array of strings
212#[derive(Debug, Deserialize)]
213#[serde(untagged)]
214enum SchemaType {
215    Single(String),
216    Multiple(Vec<String>),
217}
218
219impl SchemaType {
220    /// Get the primary type (first type if array, or the single type)
221    fn primary(&self) -> Option<&str> {
222        match self {
223            Self::Single(s) => Some(s.as_str()),
224            Self::Multiple(v) => v.first().map(|s| s.as_str()),
225        }
226    }
227}
228
229/// Map from $ref paths to their enum options
230type EnumValuesMap = HashMap<String, Vec<EnumOption>>;
231
232/// Parse the JSON Schema and build the category tree
233pub fn parse_schema(schema_json: &str) -> Result<Vec<SettingCategory>, serde_json::Error> {
234    let raw: RawSchema = serde_json::from_str(schema_json)?;
235
236    let defs = raw.defs.unwrap_or_default();
237    let properties = raw.properties.unwrap_or_default();
238
239    // Build enum values map from x-enum-values entries
240    let enum_values_map = build_enum_values_map(&raw.extensible_enum_values);
241
242    let mut categories = Vec::new();
243    let mut top_level_settings = Vec::new();
244
245    // Process each top-level property (sorted for deterministic output)
246    let mut sorted_props: Vec<_> = properties.into_iter().collect();
247    sorted_props.sort_by(|a, b| a.0.cmp(&b.0));
248    for (name, prop) in sorted_props {
249        let path = format!("/{}", name);
250        let display_name = humanize_name(&name);
251
252        // Resolve references
253        let resolved = resolve_ref(&prop, &defs);
254
255        // Check if this property should be a standalone category (for Map types)
256        if prop.standalone_category {
257            // Create a category with the Map setting as its only content
258            let setting = parse_setting(&name, &path, &prop, &defs, &enum_values_map);
259            categories.push(SettingCategory {
260                name: display_name,
261                path: path.clone(),
262                description: prop.description.clone().or(resolved.description.clone()),
263                settings: vec![setting],
264                subcategories: Vec::new(),
265            });
266        } else if let Some(ref inner_props) = resolved.properties {
267            // This is a category with nested settings
268            let settings = parse_properties(inner_props, &path, &defs, &enum_values_map);
269            categories.push(SettingCategory {
270                name: display_name,
271                path: path.clone(),
272                description: resolved.description.clone(),
273                settings,
274                subcategories: Vec::new(),
275            });
276        } else {
277            // This is a top-level setting
278            let setting = parse_setting(&name, &path, &prop, &defs, &enum_values_map);
279            top_level_settings.push(setting);
280        }
281    }
282
283    // If there are top-level settings, create a "General" category for them
284    if !top_level_settings.is_empty() {
285        // Sort top-level settings alphabetically
286        top_level_settings.sort_by(|a, b| a.name.cmp(&b.name));
287        categories.insert(
288            0,
289            SettingCategory {
290                name: "General".to_string(),
291                path: String::new(),
292                description: Some("General settings".to_string()),
293                settings: top_level_settings,
294                subcategories: Vec::new(),
295            },
296        );
297    }
298
299    // Sort categories alphabetically, but keep General first
300    categories.sort_by(|a, b| match (a.name.as_str(), b.name.as_str()) {
301        ("General", _) => std::cmp::Ordering::Less,
302        (_, "General") => std::cmp::Ordering::Greater,
303        (a, b) => a.cmp(b),
304    });
305
306    Ok(categories)
307}
308
309/// Build a map from $ref paths to their enum options
310fn build_enum_values_map(entries: &[EnumValueEntry]) -> EnumValuesMap {
311    let mut map: EnumValuesMap = HashMap::new();
312
313    for entry in entries {
314        let value_str = match &entry.value {
315            serde_json::Value::String(s) => s.clone(),
316            other => other.to_string(),
317        };
318
319        let option = EnumOption {
320            name: entry.name.clone().unwrap_or_else(|| value_str.clone()),
321            value: value_str,
322        };
323
324        map.entry(entry.ref_path.clone()).or_default().push(option);
325    }
326
327    map
328}
329
330/// Parse properties into settings
331fn parse_properties(
332    properties: &HashMap<String, RawSchema>,
333    parent_path: &str,
334    defs: &HashMap<String, RawSchema>,
335    enum_values_map: &EnumValuesMap,
336) -> Vec<SettingSchema> {
337    let mut settings = Vec::new();
338
339    for (name, prop) in properties {
340        let path = format!("{}/{}", parent_path, name);
341        let setting = parse_setting(name, &path, prop, defs, enum_values_map);
342        settings.push(setting);
343    }
344
345    // Sort settings: by x-order (if set) first, then alphabetically by name.
346    // Settings with x-order come before those without.
347    settings.sort_by(|a, b| match (a.order, b.order) {
348        (Some(a_ord), Some(b_ord)) => a_ord.cmp(&b_ord).then_with(|| a.name.cmp(&b.name)),
349        (Some(_), None) => std::cmp::Ordering::Less,
350        (None, Some(_)) => std::cmp::Ordering::Greater,
351        (None, None) => a.name.cmp(&b.name),
352    });
353
354    settings
355}
356
357/// Parse a single setting from its schema
358fn parse_setting(
359    name: &str,
360    path: &str,
361    schema: &RawSchema,
362    defs: &HashMap<String, RawSchema>,
363    enum_values_map: &EnumValuesMap,
364) -> SettingSchema {
365    let setting_type = determine_type(schema, defs, enum_values_map);
366
367    // Get description from resolved ref if not present on schema
368    let resolved = resolve_ref(schema, defs);
369    let description = schema
370        .description
371        .clone()
372        .or_else(|| resolved.description.clone());
373
374    // Check for readOnly flag on schema or resolved ref
375    let read_only = schema.read_only || resolved.read_only;
376
377    // Get section from schema or resolved ref
378    let section = schema.section.clone().or_else(|| resolved.section.clone());
379
380    // Get order from schema or resolved ref
381    let order = schema.order.or(resolved.order);
382
383    SettingSchema {
384        path: path.to_string(),
385        name: i18n_name(path, name),
386        description,
387        setting_type,
388        default: schema.default.clone(),
389        read_only,
390        section,
391        order,
392    }
393}
394
395/// Determine the SettingType from a schema
396fn determine_type(
397    schema: &RawSchema,
398    defs: &HashMap<String, RawSchema>,
399    enum_values_map: &EnumValuesMap,
400) -> SettingType {
401    // Check for extensible enum values via $ref
402    if let Some(ref ref_path) = schema.ref_path {
403        if let Some(options) = enum_values_map.get(ref_path) {
404            if !options.is_empty() {
405                return SettingType::Enum {
406                    options: options.clone(),
407                };
408            }
409        }
410    }
411
412    // Resolve ref for type checking
413    let resolved = resolve_ref(schema, defs);
414
415    // Check for inline enum values (on original schema or resolved ref)
416    let enum_values = schema
417        .enum_values
418        .as_ref()
419        .or(resolved.enum_values.as_ref());
420    if let Some(values) = enum_values {
421        let options: Vec<EnumOption> = values
422            .iter()
423            .filter_map(|v| {
424                if v.is_null() {
425                    // null in enum represents "auto-detect" or "default"
426                    Some(EnumOption {
427                        name: "Auto-detect".to_string(),
428                        value: String::new(), // Empty string represents null
429                    })
430                } else {
431                    v.as_str().map(|s| EnumOption {
432                        name: s.to_string(),
433                        value: s.to_string(),
434                    })
435                }
436            })
437            .collect();
438        if !options.is_empty() {
439            return SettingType::Enum { options };
440        }
441    }
442
443    // Check type field
444    match resolved.schema_type.as_ref().and_then(|t| t.primary()) {
445        Some("boolean") => SettingType::Boolean,
446        Some("integer") => {
447            let minimum = resolved.minimum.as_ref().and_then(|n| n.as_i64());
448            let maximum = resolved.maximum.as_ref().and_then(|n| n.as_i64());
449            SettingType::Integer { minimum, maximum }
450        }
451        Some("number") => {
452            let minimum = resolved.minimum.as_ref().and_then(|n| n.as_f64());
453            let maximum = resolved.maximum.as_ref().and_then(|n| n.as_f64());
454            SettingType::Number { minimum, maximum }
455        }
456        Some("string") => SettingType::String,
457        Some("array") => {
458            // Check if it's an array of strings, integers, or objects
459            if let Some(ref items) = resolved.items {
460                let item_resolved = resolve_ref(items, defs);
461                let item_type = item_resolved.schema_type.as_ref().and_then(|t| t.primary());
462                if item_type == Some("string") {
463                    return SettingType::StringArray;
464                }
465                if item_type == Some("integer") || item_type == Some("number") {
466                    return SettingType::IntegerArray;
467                }
468                // Check if items reference an object type
469                if items.ref_path.is_some() {
470                    // Parse the item schema from the referenced definition
471                    let item_schema =
472                        parse_setting("item", "", item_resolved, defs, enum_values_map);
473
474                    // Only create ObjectArray if the item is an object with properties
475                    if matches!(item_schema.setting_type, SettingType::Object { .. }) {
476                        // Get display_field from x-display-field in the referenced schema
477                        let display_field = item_resolved.display_field.clone();
478                        return SettingType::ObjectArray {
479                            item_schema: Box::new(item_schema),
480                            display_field,
481                        };
482                    }
483                }
484            }
485            SettingType::Complex
486        }
487        Some("object") => {
488            // Check for additionalProperties (map type)
489            if let Some(ref add_props) = resolved.additional_properties {
490                match add_props {
491                    AdditionalProperties::Schema(schema_box) => {
492                        let inner_resolved = resolve_ref(schema_box, defs);
493                        let value_schema =
494                            parse_setting("value", "", inner_resolved, defs, enum_values_map);
495
496                        // Get display_field from x-display-field in the referenced schema.
497                        // If the value schema is an array, also check the array items for display_field.
498                        let display_field = inner_resolved.display_field.clone().or_else(|| {
499                            inner_resolved.items.as_ref().and_then(|items| {
500                                let items_resolved = resolve_ref(items, defs);
501                                items_resolved.display_field.clone()
502                            })
503                        });
504
505                        // Get no_add from the parent schema (resolved)
506                        let no_add = resolved.no_add;
507
508                        return SettingType::Map {
509                            value_schema: Box::new(value_schema),
510                            display_field,
511                            no_add,
512                        };
513                    }
514                    AdditionalProperties::Bool(true) => {
515                        // additionalProperties: true means any value is allowed
516                        return SettingType::Complex;
517                    }
518                    AdditionalProperties::Bool(false) => {
519                        // additionalProperties: false means no additional properties
520                        // Fall through to check for fixed properties
521                    }
522                }
523            }
524            // Regular object with fixed properties
525            if let Some(ref props) = resolved.properties {
526                let properties = parse_properties(props, "", defs, enum_values_map);
527                return SettingType::Object { properties };
528            }
529            SettingType::Complex
530        }
531        _ => SettingType::Complex,
532    }
533}
534
535/// Resolve a $ref to its definition
536fn resolve_ref<'a>(schema: &'a RawSchema, defs: &'a HashMap<String, RawSchema>) -> &'a RawSchema {
537    if let Some(ref ref_path) = schema.ref_path {
538        // Parse ref path like "#/$defs/EditorConfig"
539        if let Some(def_name) = ref_path.strip_prefix("#/$defs/") {
540            if let Some(def) = defs.get(def_name) {
541                return def;
542            }
543        }
544    }
545    schema
546}
547
548/// Look up an i18n translation for a settings field, falling back to humanized name.
549///
550/// Derives a translation key from the schema path, e.g. `/editor/whitespace_show`
551/// becomes `settings.field.editor.whitespace_show`. If no translation is found,
552/// falls back to `humanize_name()`.
553fn i18n_name(path: &str, fallback_name: &str) -> String {
554    let key = format!("settings.field{}", path.replace('/', "."));
555    let translated = t!(&key);
556    if *translated == key {
557        humanize_name(fallback_name)
558    } else {
559        translated.to_string()
560    }
561}
562
563/// Convert snake_case to Title Case
564fn humanize_name(name: &str) -> String {
565    name.split('_')
566        .map(|word| {
567            let mut chars = word.chars();
568            match chars.next() {
569                None => String::new(),
570                Some(first) => first.to_uppercase().chain(chars).collect(),
571            }
572        })
573        .collect::<Vec<_>>()
574        .join(" ")
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580
581    const SAMPLE_SCHEMA: &str = r##"
582{
583  "$schema": "https://json-schema.org/draft/2020-12/schema",
584  "title": "Config",
585  "type": "object",
586  "properties": {
587    "theme": {
588      "description": "Color theme name",
589      "type": "string",
590      "default": "high-contrast"
591    },
592    "check_for_updates": {
593      "description": "Check for new versions on quit",
594      "type": "boolean",
595      "default": true
596    },
597    "editor": {
598      "description": "Editor settings",
599      "$ref": "#/$defs/EditorConfig"
600    }
601  },
602  "$defs": {
603    "EditorConfig": {
604      "description": "Editor behavior configuration",
605      "type": "object",
606      "properties": {
607        "tab_size": {
608          "description": "Number of spaces per tab",
609          "type": "integer",
610          "minimum": 1,
611          "maximum": 16,
612          "default": 4
613        },
614        "line_numbers": {
615          "description": "Show line numbers",
616          "type": "boolean",
617          "default": true
618        }
619      }
620    }
621  }
622}
623"##;
624
625    #[test]
626    fn test_parse_schema() {
627        let categories = parse_schema(SAMPLE_SCHEMA).unwrap();
628
629        // Should have General and Editor categories
630        assert_eq!(categories.len(), 2);
631        assert_eq!(categories[0].name, "General");
632        assert_eq!(categories[1].name, "Editor");
633    }
634
635    #[test]
636    fn test_general_category() {
637        let categories = parse_schema(SAMPLE_SCHEMA).unwrap();
638        let general = &categories[0];
639
640        // General should have theme and check_for_updates
641        assert_eq!(general.settings.len(), 2);
642
643        let theme = general
644            .settings
645            .iter()
646            .find(|s| s.path == "/theme")
647            .unwrap();
648        assert!(matches!(theme.setting_type, SettingType::String));
649
650        let updates = general
651            .settings
652            .iter()
653            .find(|s| s.path == "/check_for_updates")
654            .unwrap();
655        assert!(matches!(updates.setting_type, SettingType::Boolean));
656    }
657
658    #[test]
659    fn test_editor_category() {
660        let categories = parse_schema(SAMPLE_SCHEMA).unwrap();
661        let editor = &categories[1];
662
663        assert_eq!(editor.path, "/editor");
664        assert_eq!(editor.settings.len(), 2);
665
666        let tab_size = editor
667            .settings
668            .iter()
669            .find(|s| s.name == "Tab Size")
670            .unwrap();
671        if let SettingType::Integer { minimum, maximum } = &tab_size.setting_type {
672            assert_eq!(*minimum, Some(1));
673            assert_eq!(*maximum, Some(16));
674        } else {
675            panic!("Expected integer type");
676        }
677    }
678
679    #[test]
680    fn test_humanize_name() {
681        assert_eq!(humanize_name("tab_size"), "Tab Size");
682        assert_eq!(humanize_name("line_numbers"), "Line Numbers");
683        assert_eq!(humanize_name("check_for_updates"), "Check For Updates");
684        assert_eq!(humanize_name("lsp"), "Lsp");
685    }
686}