Skip to main content

shuck_server/session/
options.rs

1use lsp_types::Url;
2use rustc_hash::FxHashMap;
3use serde::{Deserialize, Deserializer};
4use shuck_config::{FormatConfig, LintConfig, ShuckConfig};
5
6use crate::session::settings::GlobalClientSettings;
7use crate::{Client, logging};
8
9pub(crate) type WorkspaceOptionsMap = FxHashMap<Url, ClientOptions>;
10
11/// Global initialization options accepted by the Shuck LSP server.
12#[derive(Debug, Deserialize, Default)]
13#[serde(rename_all = "camelCase")]
14pub struct GlobalOptions {
15    #[serde(flatten)]
16    client: ClientOptions,
17    #[serde(default)]
18    pub(crate) tracing: TracingOptions,
19}
20
21impl GlobalOptions {
22    /// Resolve client-provided options into runtime global settings.
23    pub fn into_settings(self, client: Client) -> GlobalClientSettings {
24        GlobalClientSettings::new(self.client, client)
25    }
26}
27
28/// Per-client or per-workspace Shuck options supplied through LSP settings.
29#[derive(Clone, Debug, Default, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub struct ClientOptions {
32    #[serde(default)]
33    /// Lint configuration overrides.
34    pub lint: Option<LintConfig>,
35    #[serde(default)]
36    /// Format configuration overrides.
37    pub format: Option<FormatConfig>,
38    #[serde(default)]
39    /// Whether source-level fix-all actions are enabled.
40    pub fix_all: Option<bool>,
41    #[serde(default)]
42    /// Whether unsafe fixes may be offered.
43    pub unsafe_fixes: Option<bool>,
44    #[serde(default)]
45    /// Whether parser diagnostics should be shown.
46    pub show_syntax_errors: Option<bool>,
47    #[serde(default)]
48    /// Server-only editor feature options.
49    pub server: ServerOptions,
50}
51
52impl ClientOptions {
53    pub(crate) fn to_config_overrides(&self) -> ShuckConfig {
54        ShuckConfig {
55            lint: self.lint.clone().unwrap_or_default(),
56            format: self.format.clone().unwrap_or_default(),
57            ..ShuckConfig::default()
58        }
59    }
60}
61
62/// Options for server-only editor features.
63#[derive(Clone, Debug, Default, PartialEq, Eq)]
64pub struct ServerOptions {
65    /// Workspace-wide symbol search configuration.
66    pub workspace_symbols: WorkspaceSymbolFeatureOptions,
67    /// Completion configuration.
68    pub completion: CompletionFeatureOptions,
69    /// Rename configuration.
70    pub rename: RenameFeatureOptions,
71    /// Cross-file call hierarchy configuration.
72    pub call_hierarchy: CallHierarchyFeatureOptions,
73    /// Workspace-wide diagnostic pull configuration.
74    pub workspace_diagnostics: WorkspaceDiagnosticsFeatureOptions,
75    workspace_symbols_overrides: WorkspaceSymbolFeatureOptionsOverrides,
76    completion_overrides: CompletionFeatureOptionsOverrides,
77    rename_overrides: RenameFeatureOptionsOverrides,
78    call_hierarchy_overrides: CallHierarchyFeatureOptionsOverrides,
79    workspace_diagnostics_overrides: WorkspaceDiagnosticsFeatureOptionsOverrides,
80}
81
82impl ServerOptions {
83    pub(crate) fn workspace_symbols_layered_over(
84        &self,
85        base: WorkspaceSymbolFeatureOptions,
86    ) -> WorkspaceSymbolFeatureOptions {
87        if self.workspace_symbols_overrides.has_overrides() {
88            self.workspace_symbols_overrides.apply_to(base)
89        } else if self.workspace_symbols != WorkspaceSymbolFeatureOptions::default() {
90            self.workspace_symbols
91        } else {
92            base
93        }
94    }
95
96    pub(crate) fn completion_layered_over(
97        &self,
98        base: CompletionFeatureOptions,
99    ) -> CompletionFeatureOptions {
100        if self.completion_overrides.has_overrides() {
101            self.completion_overrides.apply_to(base)
102        } else if self.completion != CompletionFeatureOptions::default() {
103            self.completion
104        } else {
105            base
106        }
107    }
108
109    pub(crate) fn rename_layered_over(&self, base: RenameFeatureOptions) -> RenameFeatureOptions {
110        if self.rename_overrides.has_overrides() {
111            self.rename_overrides.apply_to(base)
112        } else if self.rename != RenameFeatureOptions::default() {
113            self.rename
114        } else {
115            base
116        }
117    }
118
119    pub(crate) fn call_hierarchy_layered_over(
120        &self,
121        base: CallHierarchyFeatureOptions,
122    ) -> CallHierarchyFeatureOptions {
123        if self.call_hierarchy_overrides.has_overrides() {
124            self.call_hierarchy_overrides.apply_to(base)
125        } else if self.call_hierarchy != CallHierarchyFeatureOptions::default() {
126            self.call_hierarchy
127        } else {
128            base
129        }
130    }
131
132    pub(crate) fn workspace_diagnostics_layered_over(
133        &self,
134        base: WorkspaceDiagnosticsFeatureOptions,
135    ) -> WorkspaceDiagnosticsFeatureOptions {
136        if self.workspace_diagnostics_overrides.has_overrides() {
137            self.workspace_diagnostics_overrides.apply_to(base)
138        } else if self.workspace_diagnostics != WorkspaceDiagnosticsFeatureOptions::default() {
139            self.workspace_diagnostics
140        } else {
141            base
142        }
143    }
144}
145
146impl<'de> Deserialize<'de> for ServerOptions {
147    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
148    where
149        D: Deserializer<'de>,
150    {
151        #[derive(Deserialize, Default)]
152        #[serde(rename_all = "camelCase")]
153        struct RawServerOptions {
154            #[serde(default)]
155            workspace_symbols: WorkspaceSymbolFeatureOptionsOverrides,
156            #[serde(default)]
157            completion: CompletionFeatureOptionsOverrides,
158            #[serde(default)]
159            rename: RenameFeatureOptionsOverrides,
160            #[serde(default)]
161            call_hierarchy: CallHierarchyFeatureOptionsOverrides,
162            #[serde(default)]
163            workspace_diagnostics: WorkspaceDiagnosticsFeatureOptionsOverrides,
164        }
165
166        let raw = RawServerOptions::deserialize(deserializer)?;
167        Ok(Self {
168            workspace_symbols: raw
169                .workspace_symbols
170                .apply_to(WorkspaceSymbolFeatureOptions::default()),
171            completion: raw.completion.apply_to(CompletionFeatureOptions::default()),
172            rename: raw.rename.apply_to(RenameFeatureOptions::default()),
173            call_hierarchy: raw
174                .call_hierarchy
175                .apply_to(CallHierarchyFeatureOptions::default()),
176            workspace_diagnostics: raw
177                .workspace_diagnostics
178                .apply_to(WorkspaceDiagnosticsFeatureOptions::default()),
179            workspace_symbols_overrides: raw.workspace_symbols,
180            completion_overrides: raw.completion,
181            rename_overrides: raw.rename,
182            call_hierarchy_overrides: raw.call_hierarchy,
183            workspace_diagnostics_overrides: raw.workspace_diagnostics,
184        })
185    }
186}
187
188#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
189#[serde(rename_all = "camelCase")]
190struct WorkspaceSymbolFeatureOptionsOverrides {
191    #[serde(default)]
192    enabled: Option<bool>,
193    #[serde(default)]
194    max_files: Option<usize>,
195}
196
197impl WorkspaceSymbolFeatureOptionsOverrides {
198    fn has_overrides(self) -> bool {
199        self.enabled.is_some() || self.max_files.is_some()
200    }
201
202    fn apply_to(self, base: WorkspaceSymbolFeatureOptions) -> WorkspaceSymbolFeatureOptions {
203        WorkspaceSymbolFeatureOptions {
204            enabled: self.enabled.unwrap_or(base.enabled),
205            max_files: self.max_files.unwrap_or(base.max_files),
206        }
207    }
208}
209
210/// Configuration for `workspace/symbol`.
211#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
212#[serde(rename_all = "camelCase")]
213pub struct WorkspaceSymbolFeatureOptions {
214    /// Whether the workspace symbol index should serve requests.
215    #[serde(default = "default_workspace_symbols_enabled")]
216    pub enabled: bool,
217    /// Maximum number of closed workspace files to index.
218    #[serde(default = "default_workspace_symbols_max_files")]
219    pub max_files: usize,
220}
221
222#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
223#[serde(rename_all = "camelCase")]
224struct CompletionFeatureOptionsOverrides {
225    #[serde(default)]
226    include_runtime_names: Option<bool>,
227    #[serde(default)]
228    include_keywords: Option<bool>,
229}
230
231impl CompletionFeatureOptionsOverrides {
232    fn has_overrides(self) -> bool {
233        self.include_runtime_names.is_some() || self.include_keywords.is_some()
234    }
235
236    fn apply_to(self, base: CompletionFeatureOptions) -> CompletionFeatureOptions {
237        CompletionFeatureOptions {
238            include_runtime_names: self
239                .include_runtime_names
240                .unwrap_or(base.include_runtime_names),
241            include_keywords: self.include_keywords.unwrap_or(base.include_keywords),
242        }
243    }
244}
245
246/// Configuration for `textDocument/completion`.
247#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
248#[serde(rename_all = "camelCase")]
249pub struct CompletionFeatureOptions {
250    /// Include runtime-provided parameter names.
251    #[serde(default = "default_completion_include_runtime_names")]
252    pub include_runtime_names: bool,
253    /// Include shell keywords in command-position completion.
254    #[serde(default = "default_completion_include_keywords")]
255    pub include_keywords: bool,
256}
257
258impl Default for CompletionFeatureOptions {
259    fn default() -> Self {
260        Self {
261            include_runtime_names: true,
262            include_keywords: true,
263        }
264    }
265}
266
267#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
268#[serde(rename_all = "camelCase")]
269struct RenameFeatureOptionsOverrides {
270    #[serde(default)]
271    allow_cross_file: Option<bool>,
272}
273
274impl RenameFeatureOptionsOverrides {
275    fn has_overrides(self) -> bool {
276        self.allow_cross_file.is_some()
277    }
278
279    fn apply_to(self, base: RenameFeatureOptions) -> RenameFeatureOptions {
280        RenameFeatureOptions {
281            allow_cross_file: self.allow_cross_file.unwrap_or(base.allow_cross_file),
282        }
283    }
284}
285
286/// Configuration for rename requests.
287#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
288#[serde(rename_all = "camelCase")]
289pub struct RenameFeatureOptions {
290    /// Allow rename edits outside the current document.
291    #[serde(default = "default_cross_file_rename_enabled")]
292    pub allow_cross_file: bool,
293}
294
295impl Default for RenameFeatureOptions {
296    fn default() -> Self {
297        Self {
298            allow_cross_file: default_cross_file_rename_enabled(),
299        }
300    }
301}
302
303fn default_cross_file_rename_enabled() -> bool {
304    true
305}
306
307impl Default for WorkspaceSymbolFeatureOptions {
308    fn default() -> Self {
309        Self {
310            enabled: true,
311            max_files: 5000,
312        }
313    }
314}
315
316fn default_workspace_symbols_enabled() -> bool {
317    true
318}
319
320#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
321#[serde(rename_all = "camelCase")]
322struct CallHierarchyFeatureOptionsOverrides {
323    #[serde(default)]
324    max_files: Option<usize>,
325}
326
327impl CallHierarchyFeatureOptionsOverrides {
328    fn has_overrides(self) -> bool {
329        self.max_files.is_some()
330    }
331
332    fn apply_to(self, base: CallHierarchyFeatureOptions) -> CallHierarchyFeatureOptions {
333        CallHierarchyFeatureOptions {
334            max_files: self.max_files.unwrap_or(base.max_files),
335        }
336    }
337}
338
339/// Configuration for cross-file call hierarchy.
340#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
341#[serde(rename_all = "camelCase")]
342pub struct CallHierarchyFeatureOptions {
343    /// Maximum number of workspace files to index for the call graph.
344    #[serde(default = "default_call_hierarchy_max_files")]
345    pub max_files: usize,
346}
347
348impl Default for CallHierarchyFeatureOptions {
349    fn default() -> Self {
350        Self {
351            max_files: default_call_hierarchy_max_files(),
352        }
353    }
354}
355
356fn default_call_hierarchy_max_files() -> usize {
357    10_000
358}
359
360#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
361#[serde(rename_all = "camelCase")]
362struct WorkspaceDiagnosticsFeatureOptionsOverrides {
363    #[serde(default)]
364    enabled: Option<bool>,
365    #[serde(default)]
366    max_files: Option<usize>,
367    #[serde(default)]
368    max_entries: Option<usize>,
369    #[serde(default)]
370    max_source_bytes: Option<usize>,
371}
372
373impl WorkspaceDiagnosticsFeatureOptionsOverrides {
374    fn has_overrides(self) -> bool {
375        self.enabled.is_some()
376            || self.max_files.is_some()
377            || self.max_entries.is_some()
378            || self.max_source_bytes.is_some()
379    }
380
381    fn apply_to(
382        self,
383        base: WorkspaceDiagnosticsFeatureOptions,
384    ) -> WorkspaceDiagnosticsFeatureOptions {
385        WorkspaceDiagnosticsFeatureOptions {
386            enabled: self.enabled.unwrap_or(base.enabled),
387            max_files: self.max_files.unwrap_or(base.max_files),
388            max_entries: self.max_entries.unwrap_or(base.max_entries),
389            max_source_bytes: self.max_source_bytes.unwrap_or(base.max_source_bytes),
390        }
391    }
392}
393
394/// Configuration for bounded `workspace/diagnostic` requests.
395#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
396#[serde(rename_all = "camelCase")]
397pub struct WorkspaceDiagnosticsFeatureOptions {
398    /// Whether workspace diagnostic requests are advertised and served.
399    #[serde(default = "default_workspace_diagnostics_enabled")]
400    pub enabled: bool,
401    /// Maximum number of workspace files returned by one request.
402    #[serde(default = "default_workspace_diagnostics_max_files")]
403    pub max_files: usize,
404    /// Maximum number of filesystem entries visited by one request.
405    #[serde(default = "default_workspace_diagnostics_max_entries")]
406    pub max_entries: usize,
407    /// Maximum source bytes read and analyzed by one request.
408    #[serde(default = "default_workspace_diagnostics_max_source_bytes")]
409    pub max_source_bytes: usize,
410}
411
412impl Default for WorkspaceDiagnosticsFeatureOptions {
413    fn default() -> Self {
414        Self {
415            enabled: default_workspace_diagnostics_enabled(),
416            max_files: default_workspace_diagnostics_max_files(),
417            max_entries: default_workspace_diagnostics_max_entries(),
418            max_source_bytes: default_workspace_diagnostics_max_source_bytes(),
419        }
420    }
421}
422
423fn default_workspace_diagnostics_enabled() -> bool {
424    true
425}
426
427fn default_workspace_diagnostics_max_files() -> usize {
428    1_000
429}
430
431fn default_workspace_diagnostics_max_entries() -> usize {
432    10_000
433}
434
435fn default_workspace_diagnostics_max_source_bytes() -> usize {
436    32 * 1024 * 1024
437}
438
439fn default_workspace_symbols_max_files() -> usize {
440    5000
441}
442
443fn default_completion_include_runtime_names() -> bool {
444    true
445}
446
447fn default_completion_include_keywords() -> bool {
448    true
449}
450
451#[derive(Debug, Deserialize, Default)]
452#[serde(rename_all = "camelCase")]
453pub(crate) struct TracingOptions {
454    pub(crate) log_file: Option<std::path::PathBuf>,
455    pub(crate) log_level: Option<logging::LogLevel>,
456}
457
458#[derive(Debug, Default)]
459pub(crate) struct AllOptions {
460    pub(crate) global: GlobalOptions,
461    pub(crate) workspace: Option<WorkspaceOptionsMap>,
462}
463
464#[derive(Debug, Deserialize, Default)]
465#[serde(rename_all = "camelCase")]
466struct InitializationOptions {
467    #[serde(default)]
468    shuck: GlobalOptions,
469    #[serde(default)]
470    workspace: Option<WorkspaceOptionsMap>,
471}
472
473impl AllOptions {
474    pub(crate) fn from_value(value: serde_json::Value) -> Self {
475        if value
476            .as_object()
477            .is_some_and(|object| object.contains_key("shuck"))
478        {
479            let options =
480                serde_json::from_value::<InitializationOptions>(value).unwrap_or_default();
481            return Self {
482                global: options.shuck,
483                workspace: options.workspace,
484            };
485        }
486
487        let global = serde_json::from_value::<GlobalOptions>(value).unwrap_or_default();
488        Self {
489            global,
490            workspace: None,
491        }
492    }
493
494    pub(crate) fn workspace_diagnostics_enabled(&self) -> bool {
495        let global = self.global.client.server.workspace_diagnostics;
496        global.enabled
497            || self.workspace.as_ref().is_some_and(|workspaces| {
498                workspaces.values().any(|options| {
499                    options
500                        .server
501                        .workspace_diagnostics_layered_over(global)
502                        .enabled
503                })
504            })
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::AllOptions;
511
512    #[test]
513    fn workspace_diagnostics_are_enabled_by_default_and_can_be_disabled() {
514        let defaults = AllOptions::from_value(serde_json::json!({}));
515        assert!(defaults.workspace_diagnostics_enabled());
516
517        let disabled = AllOptions::from_value(serde_json::json!({
518            "shuck": {
519                "server": {
520                    "workspaceDiagnostics": {
521                        "enabled": false
522                    }
523                }
524            }
525        }));
526        assert!(!disabled.workspace_diagnostics_enabled());
527    }
528
529    #[test]
530    fn unrelated_workspace_options_preserve_the_global_diagnostic_opt_out() {
531        let disabled = AllOptions::from_value(serde_json::json!({
532            "shuck": {
533                "server": {
534                    "workspaceDiagnostics": {
535                        "enabled": false
536                    }
537                }
538            },
539            "workspace": {
540                "file:///workspace": {
541                    "showSyntaxErrors": true
542                }
543            }
544        }));
545        assert!(!disabled.workspace_diagnostics_enabled());
546
547        let enabled_for_workspace = AllOptions::from_value(serde_json::json!({
548            "shuck": {
549                "server": {
550                    "workspaceDiagnostics": {
551                        "enabled": false
552                    }
553                }
554            },
555            "workspace": {
556                "file:///workspace": {
557                    "server": {
558                        "workspaceDiagnostics": {
559                            "enabled": true
560                        }
561                    }
562                }
563            }
564        }));
565        assert!(enabled_for_workspace.workspace_diagnostics_enabled());
566    }
567}