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