ts_bridge/config/
mod.rs

1//! =============================================================================
2//! Configuration And Settings
3//! =============================================================================
4//!
5//! It owns every user facing knob (diagnostic strategy, formatting preferences, code lens modes,
6//! jsx helpers, tsserver memory limits, etc.) and exposes typed structures that
7//! other subsystems borrow.
8
9use std::path::PathBuf;
10
11use serde_json::{Map, Value};
12
13/// Settings that are evaluated once during plugin setup (analogous to the Lua
14/// `settings` table).  Additional fields will be introduced as we port features.
15#[derive(Debug, Clone, PartialEq)]
16pub struct PluginSettings {
17    /// Whether we spin up a paired semantic tsserver dedicated to diagnostics.
18    pub separate_diagnostic_server: bool,
19    /// Determines when diagnostics are requested (`"insert_leave"` vs
20    /// `"change"` originally); kept simple for now.
21    pub publish_diagnostic_on: DiagnosticPublishMode,
22    /// Launch arguments and logging preferences forwarded to tsserver.
23    pub tsserver: TsserverLaunchOptions,
24    /// User preferences forwarded to the tsserver `configure` command.
25    pub tsserver_preferences: Map<String, Value>,
26    /// Formatting options forwarded to the tsserver `configure` command.
27    pub tsserver_format_options: Map<String, Value>,
28    /// Gate for tsserver-backed inlay hints; allows users to disable the feature entirely.
29    pub enable_inlay_hints: bool,
30}
31
32impl Default for PluginSettings {
33    fn default() -> Self {
34        Self {
35            separate_diagnostic_server: true,
36            publish_diagnostic_on: DiagnosticPublishMode::InsertLeave,
37            tsserver: TsserverLaunchOptions::default(),
38            tsserver_preferences: Map::new(),
39            tsserver_format_options: Map::new(),
40            enable_inlay_hints: true,
41        }
42    }
43}
44
45/// Diagnostic scheduling
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum DiagnosticPublishMode {
48    InsertLeave,
49    Change,
50}
51
52impl DiagnosticPublishMode {
53    /// Parses a string-based setting (e.g. loaded via serde/JSON) into the enum.
54    pub fn from_str(value: &str) -> Self {
55        match value {
56            "change" => Self::Change,
57            _ => Self::InsertLeave,
58        }
59    }
60}
61
62/// Global configuration facade that exposes read-only handles to each settings struct.
63#[derive(Debug, Default, Clone, PartialEq)]
64pub struct Config {
65    plugin: PluginSettings,
66}
67
68impl Config {
69    pub fn new(plugin: PluginSettings) -> Self {
70        Self { plugin }
71    }
72
73    pub fn plugin_mut(&mut self) -> &mut PluginSettings {
74        &mut self.plugin
75    }
76
77    pub fn plugin(&self) -> &PluginSettings {
78        &self.plugin
79    }
80
81    /// Applies workspace/didChangeConfiguration payloads to the cached
82    /// settings. Returns `true` when any recognized option changed.
83    pub fn apply_workspace_settings(&mut self, settings: &Value) -> bool {
84        apply_settings_tree(settings, &mut self.plugin)
85    }
86}
87
88fn apply_settings_tree(value: &Value, plugin: &mut PluginSettings) -> bool {
89    let mut changed = false;
90    if let Some(map) = value.as_object() {
91        changed |= plugin.update_from_map(map);
92
93        for key in POSSIBLE_SETTING_ROOTS {
94            if let Some(candidate) = map.get(*key) {
95                changed |= apply_settings_tree(candidate, plugin);
96            }
97        }
98
99        if let Some(plugin_section) = map.get("plugin") {
100            changed |= apply_settings_tree(plugin_section, plugin);
101        }
102    }
103    changed
104}
105
106const POSSIBLE_SETTING_ROOTS: &[&str] = &["ts-bridge", "tsBridge", "tsbridge", "ts_bridge"];
107
108impl PluginSettings {
109    fn update_from_map(&mut self, map: &Map<String, Value>) -> bool {
110        let mut changed = false;
111
112        if let Some(value) = map
113            .get("separate_diagnostic_server")
114            .and_then(|v| v.as_bool())
115        {
116            if self.separate_diagnostic_server != value {
117                self.separate_diagnostic_server = value;
118                changed = true;
119            }
120        }
121
122        if let Some(value) = map.get("publish_diagnostic_on").and_then(|v| v.as_str()) {
123            let mode = DiagnosticPublishMode::from_str(value);
124            if self.publish_diagnostic_on != mode {
125                self.publish_diagnostic_on = mode;
126                changed = true;
127            }
128        }
129
130        if let Some(tsserver) = map.get("tsserver") {
131            changed |= self.tsserver.update_from_value(tsserver);
132            if let Some(tsserver_map) = tsserver.as_object() {
133                if tsserver_map.contains_key("preferences") {
134                    let next = tsserver_map
135                        .get("preferences")
136                        .and_then(|v| v.as_object())
137                        .cloned()
138                        .unwrap_or_default();
139                    if self.tsserver_preferences != next {
140                        self.tsserver_preferences = next;
141                        changed = true;
142                    }
143                }
144
145                let format_value = if tsserver_map.contains_key("format_options") {
146                    tsserver_map.get("format_options")
147                } else if tsserver_map.contains_key("formatOptions") {
148                    tsserver_map.get("formatOptions")
149                } else {
150                    None
151                };
152
153                if let Some(value) = format_value {
154                    let next = value.as_object().cloned().unwrap_or_default();
155                    if self.tsserver_format_options != next {
156                        self.tsserver_format_options = next;
157                        changed = true;
158                    }
159                }
160            }
161        }
162
163        if let Some(value) = map.get("enable_inlay_hints").and_then(|v| v.as_bool()) {
164            if self.enable_inlay_hints != value {
165                self.enable_inlay_hints = value;
166                changed = true;
167            }
168        }
169
170        changed
171    }
172}
173
174/// Launch-related knobs for the underlying `tsserver` Node process.
175#[derive(Debug, Clone, Default, PartialEq, Eq)]
176pub struct TsserverLaunchOptions {
177    pub locale: Option<String>,
178    pub log_directory: Option<PathBuf>,
179    pub log_verbosity: Option<TsserverLogVerbosity>,
180    pub max_old_space_size: Option<u32>,
181    pub global_plugins: Vec<String>,
182    pub plugin_probe_dirs: Vec<PathBuf>,
183    pub extra_args: Vec<String>,
184}
185
186impl TsserverLaunchOptions {
187    fn update_from_value(&mut self, value: &Value) -> bool {
188        let map = match value.as_object() {
189            Some(map) => map,
190            None => return false,
191        };
192        let mut changed = false;
193
194        if map.contains_key("locale") {
195            let next = map
196                .get("locale")
197                .and_then(|v| v.as_str())
198                .map(|s| s.to_string());
199            if self.locale != next {
200                self.locale = next;
201                changed = true;
202            }
203        }
204
205        if map.contains_key("log_directory") {
206            let next = map
207                .get("log_directory")
208                .and_then(|v| v.as_str())
209                .map(PathBuf::from);
210            if self.log_directory != next {
211                self.log_directory = next;
212                changed = true;
213            }
214        }
215
216        if map.contains_key("log_verbosity") {
217            let next = map
218                .get("log_verbosity")
219                .and_then(|v| v.as_str())
220                .and_then(TsserverLogVerbosity::from_str);
221            if self.log_verbosity != next {
222                self.log_verbosity = next;
223                changed = true;
224            }
225        }
226
227        if map.contains_key("max_old_space_size") {
228            let next = map
229                .get("max_old_space_size")
230                .and_then(|v| v.as_u64())
231                .and_then(|v| v.try_into().ok());
232            if self.max_old_space_size != next {
233                self.max_old_space_size = next;
234                changed = true;
235            }
236        }
237
238        if let Some(list) = map
239            .get("global_plugins")
240            .and_then(|value| string_list(value))
241        {
242            if self.global_plugins != list {
243                self.global_plugins = list;
244                changed = true;
245            }
246        }
247
248        if let Some(list) = map
249            .get("plugin_probe_dirs")
250            .and_then(|value| string_list(value))
251            .map(|entries| entries.into_iter().map(PathBuf::from).collect::<Vec<_>>())
252        {
253            if self.plugin_probe_dirs != list {
254                self.plugin_probe_dirs = list;
255                changed = true;
256            }
257        }
258
259        if let Some(list) = map.get("extra_args").and_then(|value| string_list(value)) {
260            if self.extra_args != list {
261                self.extra_args = list;
262                changed = true;
263            }
264        }
265
266        changed
267    }
268}
269
270fn string_list(value: &Value) -> Option<Vec<String>> {
271    let array = value.as_array()?;
272    let mut result = Vec::with_capacity(array.len());
273    for entry in array {
274        let Some(text) = entry.as_str() else {
275            continue;
276        };
277        result.push(text.to_string());
278    }
279    Some(result)
280}
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub enum TsserverLogVerbosity {
284    Terse,
285    Normal,
286    RequestTime,
287    Verbose,
288}
289
290impl TsserverLogVerbosity {
291    pub fn from_str(value: &str) -> Option<Self> {
292        match value {
293            "terse" => Some(Self::Terse),
294            "normal" => Some(Self::Normal),
295            "requestTime" | "request_time" => Some(Self::RequestTime),
296            "verbose" => Some(Self::Verbose),
297            _ => None,
298        }
299    }
300
301    pub fn as_cli_flag(&self) -> &'static str {
302        match self {
303            Self::Terse => "terse",
304            Self::Normal => "normal",
305            Self::RequestTime => "requestTime",
306            Self::Verbose => "verbose",
307        }
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use serde_json::json;
315
316    #[test]
317    fn apply_workspace_settings_updates_tsserver_preferences_and_format_options() {
318        let mut config = Config::new(PluginSettings::default());
319        let settings = json!({
320            "ts-bridge": {
321                "tsserver": {
322                    "preferences": {
323                        "importModuleSpecifierPreference": "relative"
324                    },
325                    "format_options": {
326                        "indentSize": 4
327                    }
328                }
329            }
330        });
331
332        let changed = config.apply_workspace_settings(&settings);
333
334        assert!(changed);
335        assert_eq!(
336            config
337                .plugin()
338                .tsserver_preferences
339                .get("importModuleSpecifierPreference")
340                .and_then(|value| value.as_str()),
341            Some("relative")
342        );
343        assert_eq!(
344            config
345                .plugin()
346                .tsserver_format_options
347                .get("indentSize")
348                .and_then(|value| value.as_i64()),
349            Some(4)
350        );
351    }
352
353    #[test]
354    fn apply_workspace_settings_accepts_format_options_camel_case() {
355        let mut config = Config::new(PluginSettings::default());
356        let settings = json!({
357            "ts-bridge": {
358                "tsserver": {
359                    "formatOptions": {
360                        "tabSize": 2
361                    }
362                }
363            }
364        });
365
366        let changed = config.apply_workspace_settings(&settings);
367
368        assert!(changed);
369        assert_eq!(
370            config
371                .plugin()
372                .tsserver_format_options
373                .get("tabSize")
374                .and_then(|value| value.as_i64()),
375            Some(2)
376        );
377    }
378}