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    /// Whether this setting accepts null (i.e., can be "unset" to inherit).
80    /// Derived from JSON Schema `"type": ["<type>", "null"]`.
81    pub nullable: bool,
82    /// Dynamic enum source path: derive dropdown options from the keys of
83    /// another config property at runtime (e.g., "/languages").
84    pub enum_from: Option<String>,
85    /// Path to the sibling dual-list setting (for cross-exclusion)
86    pub dual_list_sibling: Option<String>,
87    /// Whether this field can be dynamically extended with runtime options (e.g., custom tokens from plugins)
88    pub dynamically_extendable_status_bar_elements: bool,
89}
90
91/// Type of a setting, determines which control to render
92#[derive(Debug, Clone)]
93pub enum SettingType {
94    /// Boolean toggle
95    Boolean,
96    /// Integer number with optional min/max
97    Integer {
98        minimum: Option<i64>,
99        maximum: Option<i64>,
100    },
101    /// Floating point number
102    Number {
103        minimum: Option<f64>,
104        maximum: Option<f64>,
105    },
106    /// Free-form string
107    String,
108    /// String with enumerated options (display name, value)
109    Enum { options: Vec<EnumOption> },
110    /// Array of strings
111    StringArray,
112    /// Array of integers (rendered as TextList, values parsed as numbers)
113    IntegerArray,
114    /// Array of objects with a schema (for keybindings, etc.)
115    ObjectArray {
116        item_schema: Box<SettingSchema>,
117        /// JSON pointer to field within item to display as preview (e.g., "/action")
118        display_field: Option<String>,
119    },
120    /// Nested object (category)
121    Object { properties: Vec<SettingSchema> },
122    /// Map with string keys (for languages, lsp configs)
123    Map {
124        value_schema: Box<SettingSchema>,
125        /// JSON pointer to field within value to display as preview (e.g., "/command")
126        display_field: Option<String>,
127        /// Whether to disallow adding new entries (entries are auto-managed)
128        no_add: bool,
129    },
130    /// Dual-list: ordered subset of a fixed set of options with sibling cross-exclusion
131    DualList {
132        options: Vec<EnumOption>,
133        sibling_path: Option<String>,
134    },
135    /// Complex type we can't edit directly
136    Complex,
137}
138
139/// An option in an enum type
140#[derive(Debug, Clone)]
141pub struct EnumOption {
142    /// Display name shown in UI
143    pub name: String,
144    /// Actual value stored in config
145    pub value: String,
146}
147
148/// A category in the settings tree
149#[derive(Debug, Clone)]
150pub struct SettingCategory {
151    /// Category name (e.g., "Editor", "File Explorer")
152    pub name: String,
153    /// JSON path prefix for this category
154    pub path: String,
155    /// Description of this category
156    pub description: Option<String>,
157    /// Whether this category is nullable (e.g., `Option<LanguageConfig>`)
158    /// and can be cleared as a whole.
159    pub nullable: bool,
160    /// Settings in this category
161    pub settings: Vec<SettingSchema>,
162    /// Subcategories
163    pub subcategories: Vec<SettingCategory>,
164}
165
166/// Raw JSON Schema structure for deserialization
167#[derive(Debug, Deserialize)]
168struct RawSchema {
169    #[serde(rename = "type")]
170    schema_type: Option<SchemaType>,
171    description: Option<String>,
172    default: Option<serde_json::Value>,
173    properties: Option<HashMap<String, RawSchema>>,
174    items: Option<Box<RawSchema>>,
175    #[serde(rename = "enum")]
176    enum_values: Option<Vec<serde_json::Value>>,
177    minimum: Option<serde_json::Number>,
178    maximum: Option<serde_json::Number>,
179    #[serde(rename = "$ref")]
180    ref_path: Option<String>,
181    #[serde(rename = "$defs")]
182    defs: Option<HashMap<String, RawSchema>>,
183    #[serde(rename = "additionalProperties")]
184    additional_properties: Option<AdditionalProperties>,
185    /// Extensible enum values - see module docs for details
186    #[serde(rename = "x-enum-values", default)]
187    extensible_enum_values: Vec<EnumValueEntry>,
188    /// Custom extension: field to display as preview in Map/ObjectArray entries
189    /// e.g., "/command" for OnSaveAction, "/action" for Keybinding
190    #[serde(rename = "x-display-field")]
191    display_field: Option<String>,
192    /// Whether this field is read-only
193    #[serde(rename = "readOnly", default)]
194    read_only: bool,
195    /// Whether this Map-type property should be rendered as its own category
196    #[serde(rename = "x-standalone-category", default)]
197    standalone_category: bool,
198    /// Whether this Map should disallow adding new entries (entries are auto-managed)
199    #[serde(rename = "x-no-add", default)]
200    no_add: bool,
201    /// Section/group within the category for organizing related settings
202    #[serde(rename = "x-section")]
203    section: Option<String>,
204    /// Sort order override for field ordering in entry dialogs
205    #[serde(rename = "x-order")]
206    order: Option<i32>,
207    /// anyOf combinator (used by schemars for Option<T> where T is a struct)
208    #[serde(rename = "anyOf")]
209    any_of: Option<Vec<RawSchema>>,
210    /// Dynamic enum: derive dropdown options from the keys of another config
211    /// property at runtime (e.g., `"x-enum-from": "/languages"` populates
212    /// the dropdown with keys from the `languages` HashMap).
213    #[serde(rename = "x-enum-from")]
214    enum_from: Option<String>,
215    /// Dual-list options defined on the item schema (array of {value, name})
216    #[serde(rename = "x-dual-list-options", default)]
217    dual_list_options: Vec<DualListOptionEntry>,
218    /// Path to the sibling dual-list setting (for cross-exclusion)
219    #[serde(rename = "x-dual-list-sibling")]
220    dual_list_sibling: Option<String>,
221    /// Whether this field can be dynamically extended with runtime options (e.g., custom tokens from plugins)
222    #[serde(rename = "x-dynamically-extendable-status-bar-elements", default)]
223    dynamically_extendable_status_bar_elements: bool,
224}
225
226/// An entry in the x-enum-values array
227#[derive(Debug, Deserialize)]
228struct EnumValueEntry {
229    /// JSON pointer to the type being extended (e.g., "#/$defs/ThemeOptions")
230    #[serde(rename = "ref")]
231    ref_path: String,
232    /// Human-friendly display name (optional, defaults to value)
233    name: Option<String>,
234    /// The actual value (must match the referenced type)
235    value: serde_json::Value,
236}
237
238/// An option entry in x-dual-list-options
239#[derive(Debug, Deserialize)]
240struct DualListOptionEntry {
241    /// The actual value (e.g., "{filename}")
242    value: String,
243    /// Human-friendly display name
244    name: Option<String>,
245}
246
247/// additionalProperties can be a boolean or a schema object
248#[derive(Debug, Deserialize)]
249#[serde(untagged)]
250enum AdditionalProperties {
251    Bool(bool),
252    Schema(Box<RawSchema>),
253}
254
255/// JSON Schema type can be a single string or an array of strings
256#[derive(Debug, Deserialize)]
257#[serde(untagged)]
258enum SchemaType {
259    Single(String),
260    Multiple(Vec<String>),
261}
262
263impl SchemaType {
264    /// Get the primary type (first type if array, or the single type)
265    fn primary(&self) -> Option<&str> {
266        match self {
267            Self::Single(s) => Some(s.as_str()),
268            Self::Multiple(v) => v.first().map(|s| s.as_str()),
269        }
270    }
271
272    /// Check if this type includes "null" (i.e., the field is nullable/optional)
273    fn contains_null(&self) -> bool {
274        match self {
275            Self::Single(s) => s == "null",
276            Self::Multiple(v) => v.iter().any(|s| s == "null"),
277        }
278    }
279}
280
281/// Map from $ref paths to their enum options
282type EnumValuesMap = HashMap<String, Vec<EnumOption>>;
283
284/// Parse the JSON Schema and build the category tree
285pub fn parse_schema(schema_json: &str) -> Result<Vec<SettingCategory>, serde_json::Error> {
286    let raw: RawSchema = serde_json::from_str(schema_json)?;
287
288    let defs = raw.defs.unwrap_or_default();
289    let properties = raw.properties.unwrap_or_default();
290
291    // Build enum values map from x-enum-values entries
292    let enum_values_map = build_enum_values_map(&raw.extensible_enum_values);
293
294    let mut categories = Vec::new();
295    let mut top_level_settings = Vec::new();
296
297    // Process each top-level property (sorted for deterministic output)
298    let mut sorted_props: Vec<_> = properties.into_iter().collect();
299    sorted_props.sort_by(|a, b| a.0.cmp(&b.0));
300    for (name, prop) in sorted_props {
301        let path = format!("/{}", name);
302        let display_name = humanize_name(&name);
303
304        // Resolve references
305        let resolved = resolve_ref(&prop, &defs);
306
307        // Detect if this property is nullable (Option<T> generates anyOf with null variant)
308        let is_nullable = prop.any_of.as_ref().is_some_and(|variants| {
309            variants.iter().any(|v| {
310                v.schema_type
311                    .as_ref()
312                    .map(|t| t.primary() == Some("null"))
313                    .unwrap_or(false)
314            })
315        });
316
317        // Check if this property should be a standalone category (for Map types)
318        if prop.standalone_category {
319            // Create a category with the Map setting as its only content
320            let setting = parse_setting(&name, &path, &prop, &defs, &enum_values_map);
321            categories.push(SettingCategory {
322                name: display_name,
323                path: path.clone(),
324                description: prop.description.clone().or(resolved.description.clone()),
325                nullable: is_nullable,
326                settings: vec![setting],
327                subcategories: Vec::new(),
328            });
329        } else if let Some(ref inner_props) = resolved.properties {
330            // This is a category with nested settings.
331            let settings = parse_properties(inner_props, &path, &defs, &enum_values_map);
332            // Prefer the field-level doc comment (more specific to how the
333            // category is used) over the struct-level one (often generic
334            // boilerplate like "Editor configuration"). When both exist they
335            // tend to read as near-duplicates side by side, so we don't
336            // concatenate them.
337            let description = prop
338                .description
339                .clone()
340                .or_else(|| resolved.description.clone());
341            categories.push(SettingCategory {
342                name: display_name,
343                path: path.clone(),
344                description,
345                nullable: is_nullable,
346                settings,
347                subcategories: Vec::new(),
348            });
349        } else {
350            // This is a top-level setting
351            let setting = parse_setting(&name, &path, &prop, &defs, &enum_values_map);
352            top_level_settings.push(setting);
353        }
354    }
355
356    // If there are top-level settings, create a "General" category for them
357    if !top_level_settings.is_empty() {
358        // Sort top-level settings alphabetically
359        top_level_settings.sort_by(|a, b| a.name.cmp(&b.name));
360        categories.insert(
361            0,
362            SettingCategory {
363                name: "General".to_string(),
364                path: String::new(),
365                description: Some("General settings".to_string()),
366                nullable: false,
367                settings: top_level_settings,
368                subcategories: Vec::new(),
369            },
370        );
371    }
372
373    // Sort categories alphabetically, but keep General first
374    categories.sort_by(|a, b| match (a.name.as_str(), b.name.as_str()) {
375        ("General", _) => std::cmp::Ordering::Less,
376        (_, "General") => std::cmp::Ordering::Greater,
377        (a, b) => a.cmp(b),
378    });
379
380    Ok(categories)
381}
382
383/// Append a top-level "Plugin Settings" category whose subcategories are
384/// built from per-plugin schema sidecars (`<plugin_name>.schema.json`).
385///
386/// Each sub-category lives at JSON pointer `/plugins/<name>/settings`.
387/// Only the names passed in `enabled_plugins_with_schema` are rendered —
388/// disabled plugins are hidden from the Settings UI (per the design
389/// decision).
390pub fn append_plugin_settings_category(
391    categories: &mut Vec<SettingCategory>,
392    plugin_schemas: &HashMap<String, serde_json::Value>,
393    enabled_plugins_with_schema: &[String],
394) {
395    if enabled_plugins_with_schema.is_empty() {
396        return;
397    }
398
399    // Push each plugin as its own top-level category, prefixed so they
400    // cluster together in the left-panel alphabetical sort and don't
401    // collide with built-in names like "Editor" / "Plugins".
402    let mut added = 0;
403    for name in enabled_plugins_with_schema {
404        let Some(schema_value) = plugin_schemas.get(name) else {
405            continue;
406        };
407        let Some(mut category) = plugin_schema_to_category(name, schema_value) else {
408            continue;
409        };
410        category.name = format!("Plugin: {}", name);
411        categories.push(category);
412        added += 1;
413    }
414
415    if added == 0 {
416        return;
417    }
418
419    // Re-sort categories. "General" stays first; "Plugin: <name>"
420    // entries are pushed to the bottom of the list so plugin
421    // configuration doesn't interleave with built-in editor settings.
422    // Within each band the order is alphabetical.
423    categories.sort_by(|a, b| match (a.name.as_str(), b.name.as_str()) {
424        ("General", _) => std::cmp::Ordering::Less,
425        (_, "General") => std::cmp::Ordering::Greater,
426        (a, b) => match (a.starts_with("Plugin: "), b.starts_with("Plugin: ")) {
427            (true, false) => std::cmp::Ordering::Greater,
428            (false, true) => std::cmp::Ordering::Less,
429            _ => a.cmp(b),
430        },
431    });
432}
433
434fn plugin_schema_to_category(
435    plugin_name: &str,
436    schema_value: &serde_json::Value,
437) -> Option<SettingCategory> {
438    // Parse the plugin's schema as if it were a regular JSON Schema
439    // fragment, then walk its `properties` to build SettingSchema entries
440    // rooted at `/plugins/<name>/settings/...`.
441    let raw: RawSchema = serde_json::from_value(schema_value.clone()).ok()?;
442    let defs = HashMap::new();
443    let enum_values_map = HashMap::new();
444    let base_path = format!("/plugins/{}/settings", plugin_name);
445
446    let properties = raw.properties.as_ref()?;
447    let settings = parse_properties(properties, &base_path, &defs, &enum_values_map);
448    if settings.is_empty() {
449        return None;
450    }
451
452    Some(SettingCategory {
453        name: plugin_name.to_string(),
454        path: base_path,
455        description: raw.description.clone(),
456        nullable: false,
457        settings,
458        subcategories: Vec::new(),
459    })
460}
461
462/// Build a map from $ref paths to their enum options
463fn build_enum_values_map(entries: &[EnumValueEntry]) -> EnumValuesMap {
464    let mut map: EnumValuesMap = HashMap::new();
465
466    for entry in entries {
467        let value_str = match &entry.value {
468            serde_json::Value::String(s) => s.clone(),
469            other => other.to_string(),
470        };
471
472        let option = EnumOption {
473            name: entry.name.clone().unwrap_or_else(|| value_str.clone()),
474            value: value_str,
475        };
476
477        map.entry(entry.ref_path.clone()).or_default().push(option);
478    }
479
480    map
481}
482
483/// Parse properties into settings
484fn parse_properties(
485    properties: &HashMap<String, RawSchema>,
486    parent_path: &str,
487    defs: &HashMap<String, RawSchema>,
488    enum_values_map: &EnumValuesMap,
489) -> Vec<SettingSchema> {
490    let mut settings = Vec::new();
491
492    for (name, prop) in properties {
493        let path = format!("{}/{}", parent_path, name);
494        let setting = parse_setting(name, &path, prop, defs, enum_values_map);
495
496        settings.push(setting);
497    }
498
499    // Sort settings: by x-order (if set) first, then alphabetically by name.
500    // Settings with x-order come before those without.
501    settings.sort_by(|a, b| match (a.order, b.order) {
502        (Some(a_ord), Some(b_ord)) => a_ord.cmp(&b_ord).then_with(|| a.name.cmp(&b.name)),
503        (Some(_), None) => std::cmp::Ordering::Less,
504        (None, Some(_)) => std::cmp::Ordering::Greater,
505        (None, None) => a.name.cmp(&b.name),
506    });
507
508    settings
509}
510
511/// Parse a single setting from its schema
512fn parse_setting(
513    name: &str,
514    path: &str,
515    schema: &RawSchema,
516    defs: &HashMap<String, RawSchema>,
517    enum_values_map: &EnumValuesMap,
518) -> SettingSchema {
519    let setting_type = determine_type(schema, defs, enum_values_map);
520
521    // Get description from resolved ref if not present on schema
522    let resolved = resolve_ref(schema, defs);
523    let description = schema
524        .description
525        .clone()
526        .or_else(|| resolved.description.clone());
527
528    // Check for readOnly flag on schema or resolved ref
529    let read_only = schema.read_only || resolved.read_only;
530
531    // Get section from schema or resolved ref
532    let section = schema.section.clone().or_else(|| resolved.section.clone());
533
534    // Get order from schema or resolved ref
535    let order = schema.order.or(resolved.order);
536
537    // Detect nullability from type array containing "null" or anyOf containing a null variant
538    let nullable = resolved
539        .schema_type
540        .as_ref()
541        .map(|t| t.contains_null())
542        .unwrap_or(false)
543        || schema.any_of.as_ref().is_some_and(|variants| {
544            variants.iter().any(|v| {
545                v.schema_type
546                    .as_ref()
547                    .map(|t| t.primary() == Some("null"))
548                    .unwrap_or(false)
549            })
550        });
551
552    SettingSchema {
553        path: path.to_string(),
554        name: i18n_name(path, name),
555        description,
556        setting_type,
557        default: schema.default.clone(),
558        read_only,
559        section,
560        order,
561        nullable,
562        enum_from: schema
563            .enum_from
564            .clone()
565            .or_else(|| resolved.enum_from.clone()),
566        dual_list_sibling: schema
567            .dual_list_sibling
568            .clone()
569            .or_else(|| resolved.dual_list_sibling.clone()),
570        dynamically_extendable_status_bar_elements: schema
571            .dynamically_extendable_status_bar_elements
572            || resolved.dynamically_extendable_status_bar_elements,
573    }
574}
575
576/// Determine the SettingType from a schema
577fn determine_type(
578    schema: &RawSchema,
579    defs: &HashMap<String, RawSchema>,
580    enum_values_map: &EnumValuesMap,
581) -> SettingType {
582    // Check for extensible enum values via $ref
583    if let Some(ref ref_path) = schema.ref_path {
584        if let Some(options) = enum_values_map.get(ref_path) {
585            if !options.is_empty() {
586                return SettingType::Enum {
587                    options: options.clone(),
588                };
589            }
590        }
591    }
592
593    // Resolve ref for type checking
594    let resolved = resolve_ref(schema, defs);
595
596    // Check for inline enum values (on original schema or resolved ref)
597    let enum_values = schema
598        .enum_values
599        .as_ref()
600        .or(resolved.enum_values.as_ref());
601    if let Some(values) = enum_values {
602        let options: Vec<EnumOption> = values
603            .iter()
604            .filter_map(|v| {
605                if v.is_null() {
606                    // null in enum represents "auto-detect" or "default"
607                    Some(EnumOption {
608                        name: "Auto-detect".to_string(),
609                        value: String::new(), // Empty string represents null
610                    })
611                } else {
612                    v.as_str().map(|s| EnumOption {
613                        name: s.to_string(),
614                        value: s.to_string(),
615                    })
616                }
617            })
618            .collect();
619        if !options.is_empty() {
620            return SettingType::Enum { options };
621        }
622    }
623
624    // Check type field
625    match resolved.schema_type.as_ref().and_then(|t| t.primary()) {
626        Some("boolean") => SettingType::Boolean,
627        Some("integer") => {
628            let minimum = resolved.minimum.as_ref().and_then(|n| n.as_i64());
629            let maximum = resolved.maximum.as_ref().and_then(|n| n.as_i64());
630            SettingType::Integer { minimum, maximum }
631        }
632        Some("number") => {
633            let minimum = resolved.minimum.as_ref().and_then(|n| n.as_f64());
634            let maximum = resolved.maximum.as_ref().and_then(|n| n.as_f64());
635            SettingType::Number { minimum, maximum }
636        }
637        Some("string") => SettingType::String,
638        Some("array") => {
639            // Check if it's an array of strings, integers, or objects
640            if let Some(ref items) = resolved.items {
641                let item_resolved = resolve_ref(items, defs);
642                // Check for dual-list options on the item schema
643                if !item_resolved.dual_list_options.is_empty() {
644                    let options = item_resolved
645                        .dual_list_options
646                        .iter()
647                        .map(|entry| EnumOption {
648                            name: entry.name.clone().unwrap_or_else(|| entry.value.clone()),
649                            value: entry.value.clone(),
650                        })
651                        .collect();
652                    return SettingType::DualList {
653                        options,
654                        sibling_path: schema
655                            .dual_list_sibling
656                            .clone()
657                            .or_else(|| resolved.dual_list_sibling.clone()),
658                    };
659                }
660                let item_type = item_resolved.schema_type.as_ref().and_then(|t| t.primary());
661                if item_type == Some("string") {
662                    return SettingType::StringArray;
663                }
664                if item_type == Some("integer") || item_type == Some("number") {
665                    return SettingType::IntegerArray;
666                }
667                // Check if items reference an object type
668                if items.ref_path.is_some() {
669                    // Parse the item schema from the referenced definition
670                    let item_schema =
671                        parse_setting("item", "", item_resolved, defs, enum_values_map);
672
673                    // Only create ObjectArray if the item is an object with properties
674                    if matches!(item_schema.setting_type, SettingType::Object { .. }) {
675                        // Get display_field from x-display-field in the referenced schema
676                        let display_field = item_resolved.display_field.clone();
677                        return SettingType::ObjectArray {
678                            item_schema: Box::new(item_schema),
679                            display_field,
680                        };
681                    }
682                }
683            }
684            SettingType::Complex
685        }
686        Some("object") => {
687            // Check for additionalProperties (map type)
688            if let Some(ref add_props) = resolved.additional_properties {
689                match add_props {
690                    AdditionalProperties::Schema(schema_box) => {
691                        let inner_resolved = resolve_ref(schema_box, defs);
692                        let value_schema =
693                            parse_setting("value", "", inner_resolved, defs, enum_values_map);
694
695                        // Get display_field from x-display-field in the referenced schema.
696                        // If the value schema is an array, also check the array items for display_field.
697                        let display_field = inner_resolved.display_field.clone().or_else(|| {
698                            inner_resolved.items.as_ref().and_then(|items| {
699                                let items_resolved = resolve_ref(items, defs);
700                                items_resolved.display_field.clone()
701                            })
702                        });
703
704                        // Get no_add from the parent schema (resolved)
705                        let no_add = resolved.no_add;
706
707                        return SettingType::Map {
708                            value_schema: Box::new(value_schema),
709                            display_field,
710                            no_add,
711                        };
712                    }
713                    AdditionalProperties::Bool(true) => {
714                        // additionalProperties: true means any value is allowed
715                        return SettingType::Complex;
716                    }
717                    AdditionalProperties::Bool(false) => {
718                        // additionalProperties: false means no additional properties
719                        // Fall through to check for fixed properties
720                    }
721                }
722            }
723            // Regular object with fixed properties
724            if let Some(ref props) = resolved.properties {
725                let properties = parse_properties(props, "", defs, enum_values_map);
726                return SettingType::Object { properties };
727            }
728            SettingType::Complex
729        }
730        _ => SettingType::Complex,
731    }
732}
733
734/// Resolve a $ref to its definition.
735///
736/// Also resolves through `anyOf` patterns generated by schemars for `Option<T>`:
737///   `anyOf: [{ "$ref": "#/$defs/Foo" }, { "type": "null" }]`
738/// In this case, the non-null `$ref` variant is resolved.
739fn resolve_ref<'a>(schema: &'a RawSchema, defs: &'a HashMap<String, RawSchema>) -> &'a RawSchema {
740    // Direct $ref
741    if let Some(ref ref_path) = schema.ref_path {
742        if let Some(def_name) = ref_path.strip_prefix("#/$defs/") {
743            if let Some(def) = defs.get(def_name) {
744                return def;
745            }
746        }
747    }
748    // anyOf: find the non-null variant and resolve it
749    if let Some(ref variants) = schema.any_of {
750        for variant in variants {
751            let is_null = variant
752                .schema_type
753                .as_ref()
754                .map(|t| t.primary() == Some("null"))
755                .unwrap_or(false);
756            if !is_null {
757                return resolve_ref(variant, defs);
758            }
759        }
760    }
761    schema
762}
763
764/// Look up an i18n translation for a settings field, falling back to humanized name.
765///
766/// Derives a translation key from the schema path, e.g. `/editor/whitespace_show`
767/// becomes `settings.field.editor.whitespace_show`. If no translation is found,
768/// falls back to `humanize_name()`.
769fn i18n_name(path: &str, fallback_name: &str) -> String {
770    let key = format!("settings.field{}", path.replace('/', "."));
771    let translated = t!(&key);
772    if *translated == key {
773        humanize_name(fallback_name)
774    } else {
775        translated.to_string()
776    }
777}
778
779/// Convert snake_case to Title Case
780fn humanize_name(name: &str) -> String {
781    name.split('_')
782        .map(|word| {
783            let mut chars = word.chars();
784            match chars.next() {
785                None => String::new(),
786                Some(first) => first.to_uppercase().chain(chars).collect(),
787            }
788        })
789        .collect::<Vec<_>>()
790        .join(" ")
791}
792
793#[cfg(test)]
794mod tests {
795    use super::*;
796
797    const SAMPLE_SCHEMA: &str = r##"
798{
799  "$schema": "https://json-schema.org/draft/2020-12/schema",
800  "title": "Config",
801  "type": "object",
802  "properties": {
803    "theme": {
804      "description": "Color theme name",
805      "type": "string",
806      "default": "high-contrast"
807    },
808    "check_for_updates": {
809      "description": "Check for new versions on quit",
810      "type": "boolean",
811      "default": true
812    },
813    "editor": {
814      "description": "Editor settings",
815      "$ref": "#/$defs/EditorConfig"
816    }
817  },
818  "$defs": {
819    "EditorConfig": {
820      "description": "Editor behavior configuration",
821      "type": "object",
822      "properties": {
823        "tab_size": {
824          "description": "Number of spaces per tab",
825          "type": "integer",
826          "minimum": 1,
827          "maximum": 16,
828          "default": 4
829        },
830        "line_numbers": {
831          "description": "Show line numbers",
832          "type": "boolean",
833          "default": true
834        }
835      }
836    }
837  }
838}
839"##;
840
841    #[test]
842    fn test_parse_schema() {
843        let categories = parse_schema(SAMPLE_SCHEMA).unwrap();
844
845        // Should have General and Editor categories
846        assert_eq!(categories.len(), 2);
847        assert_eq!(categories[0].name, "General");
848        assert_eq!(categories[1].name, "Editor");
849    }
850
851    #[test]
852    fn test_general_category() {
853        let categories = parse_schema(SAMPLE_SCHEMA).unwrap();
854        let general = &categories[0];
855
856        // General should have theme and check_for_updates
857        assert_eq!(general.settings.len(), 2);
858
859        let theme = general
860            .settings
861            .iter()
862            .find(|s| s.path == "/theme")
863            .unwrap();
864        assert!(matches!(theme.setting_type, SettingType::String));
865
866        let updates = general
867            .settings
868            .iter()
869            .find(|s| s.path == "/check_for_updates")
870            .unwrap();
871        assert!(matches!(updates.setting_type, SettingType::Boolean));
872    }
873
874    #[test]
875    fn test_editor_category() {
876        let categories = parse_schema(SAMPLE_SCHEMA).unwrap();
877        let editor = &categories[1];
878
879        assert_eq!(editor.path, "/editor");
880        assert_eq!(editor.settings.len(), 2);
881
882        let tab_size = editor
883            .settings
884            .iter()
885            .find(|s| s.name == "Tab Size")
886            .unwrap();
887        if let SettingType::Integer { minimum, maximum } = &tab_size.setting_type {
888            assert_eq!(*minimum, Some(1));
889            assert_eq!(*maximum, Some(16));
890        } else {
891            panic!("Expected integer type");
892        }
893    }
894
895    #[test]
896    fn test_any_of_nullable_object() {
897        // Tests that anyOf: [{$ref: "..."}, {type: "null"}] resolves to an Object type
898        // and is marked as nullable. This is the pattern schemars generates for Option<T>.
899        let schema_json = r##"
900{
901  "$schema": "https://json-schema.org/draft/2020-12/schema",
902  "title": "Config",
903  "type": "object",
904  "properties": {
905    "fallback": {
906      "description": "Fallback language config",
907      "anyOf": [
908        { "$ref": "#/$defs/LanguageConfig" },
909        { "type": "null" }
910      ],
911      "default": null
912    }
913  },
914  "$defs": {
915    "LanguageConfig": {
916      "description": "Language-specific configuration",
917      "type": "object",
918      "properties": {
919        "grammar": {
920          "description": "Grammar name",
921          "type": "string",
922          "default": ""
923        },
924        "comment_prefix": {
925          "description": "Comment prefix",
926          "type": ["string", "null"],
927          "default": null
928        },
929        "auto_indent": {
930          "description": "Enable auto-indent",
931          "type": "boolean",
932          "default": false
933        }
934      }
935    }
936  }
937}
938"##;
939        let categories = parse_schema(schema_json).unwrap();
940
941        // anyOf with $ref should resolve through to LanguageConfig's properties,
942        // creating a category (like "Editor") with individual sub-field controls
943        let fallback_cat = categories
944            .iter()
945            .find(|c| c.path == "/fallback")
946            .expect("fallback should be a category");
947        assert_eq!(fallback_cat.settings.len(), 3);
948
949        // Verify sub-fields are properly typed
950        let grammar = fallback_cat
951            .settings
952            .iter()
953            .find(|s| s.name == "Grammar")
954            .unwrap();
955        assert!(matches!(grammar.setting_type, SettingType::String));
956
957        let auto_indent = fallback_cat
958            .settings
959            .iter()
960            .find(|s| s.name == "Auto Indent")
961            .unwrap();
962        assert!(matches!(auto_indent.setting_type, SettingType::Boolean));
963    }
964
965    #[test]
966    fn test_humanize_name() {
967        assert_eq!(humanize_name("tab_size"), "Tab Size");
968        assert_eq!(humanize_name("line_numbers"), "Line Numbers");
969        assert_eq!(humanize_name("check_for_updates"), "Check For Updates");
970        assert_eq!(humanize_name("lsp"), "Lsp");
971    }
972
973    #[test]
974    fn test_enum_from_parsed_from_schema() {
975        let schema_json = r##"{
976            "type": "object",
977            "properties": {
978                "default_language": {
979                    "type": ["string", "null"],
980                    "x-enum-from": "/languages"
981                },
982                "theme": {
983                    "type": "string"
984                }
985            }
986        }"##;
987
988        let categories = parse_schema(schema_json).unwrap();
989        let general = &categories[0];
990        let default_lang = general
991            .settings
992            .iter()
993            .find(|s| s.name == "Default Language")
994            .expect("should have Default Language setting");
995
996        assert_eq!(
997            default_lang.enum_from.as_deref(),
998            Some("/languages"),
999            "enum_from should be parsed from x-enum-from"
1000        );
1001        assert!(default_lang.nullable, "should be nullable");
1002
1003        // theme should not have enum_from
1004        let theme = general
1005            .settings
1006            .iter()
1007            .find(|s| s.name == "Theme")
1008            .expect("should have Theme setting");
1009        assert!(theme.enum_from.is_none());
1010    }
1011
1012    #[test]
1013    fn test_dual_list_parsed_from_schema() {
1014        let schema_json = r##"{
1015            "type": "object",
1016            "properties": {
1017                "tags": {
1018                    "type": "array",
1019                    "items": {
1020                        "type": "string",
1021                        "x-dual-list-options": [
1022                            {"value": "red", "name": "Red"},
1023                            {"value": "green", "name": "Green"},
1024                            {"value": "blue", "name": "Blue"}
1025                        ]
1026                    },
1027                    "x-dual-list-sibling": "/other_tags"
1028                }
1029            }
1030        }"##;
1031        let categories = parse_schema(schema_json).unwrap();
1032        let general = &categories[0];
1033        let tags = general
1034            .settings
1035            .iter()
1036            .find(|s| s.path == "/tags")
1037            .expect("tags setting");
1038
1039        match &tags.setting_type {
1040            SettingType::DualList {
1041                options,
1042                sibling_path,
1043            } => {
1044                assert_eq!(options.len(), 3);
1045                assert_eq!(options[0].value, "red");
1046                assert_eq!(options[0].name, "Red");
1047                assert_eq!(sibling_path.as_deref(), Some("/other_tags"));
1048            }
1049            other => panic!("expected DualList, got {:?}", other),
1050        }
1051        assert_eq!(tags.dual_list_sibling.as_deref(), Some("/other_tags"));
1052    }
1053}