Skip to main content

shuck_server/
session.rs

1#![allow(dead_code)]
2
3use std::path::Path;
4use std::sync::Arc;
5
6use lsp_types::{ClientCapabilities, FileEvent, Url};
7
8use crate::analysis::{DocumentAnalysis, DocumentAnalysisCache};
9use crate::edit::{DocumentKey, DocumentVersion};
10use crate::session::request_queue::RequestQueue;
11use crate::session::settings::GlobalClientSettings;
12use crate::workspace::Workspaces;
13use crate::{PositionEncoding, TextDocument};
14
15pub(crate) use self::capabilities::ResolvedClientCapabilities;
16pub use self::index::DocumentQuery;
17pub(crate) use self::index::WorkspaceSettingsSnapshot;
18pub(crate) use self::options::{AllOptions, WorkspaceOptionsMap};
19pub use self::options::{
20    ClientOptions, CompletionFeatureOptions, GlobalOptions, RenameFeatureOptions,
21    WorkspaceDiagnosticsFeatureOptions, WorkspaceSymbolFeatureOptions,
22};
23pub(crate) use self::request_queue::RequestCancellationToken;
24pub(crate) use self::settings::ClientSettings;
25pub(crate) use self::settings::ShuckSettings;
26pub use client::Client;
27
28mod capabilities;
29mod client;
30mod index;
31mod options;
32mod request_queue;
33mod settings;
34
35/// Mutable LSP session state for open documents, workspaces, and settings.
36pub struct Session {
37    index: index::Index,
38    position_encoding: PositionEncoding,
39    global_settings: GlobalClientSettings,
40    resolved_client_capabilities: Arc<ResolvedClientCapabilities>,
41    workspace_symbols: Arc<crate::symbols::WorkspaceSymbolIndex>,
42    workspace_diagnostics: Arc<crate::workspace_diagnostics::WorkspaceDiagnosticCache>,
43    analysis_cache: Arc<DocumentAnalysisCache>,
44    workspace_function_index: Arc<crate::workspace_functions::WorkspaceFunctionIndexCache>,
45    request_queue: RequestQueue,
46    shutdown_requested: bool,
47}
48
49/// Immutable view of one document plus resolved settings.
50#[derive(Clone)]
51pub struct DocumentSnapshot {
52    resolved_client_capabilities: Arc<ResolvedClientCapabilities>,
53    client_settings: Arc<ClientSettings>,
54    document_ref: index::DocumentQuery,
55    position_encoding: PositionEncoding,
56    analysis_cache: Arc<DocumentAnalysisCache>,
57    analysis_settings_epoch: u64,
58}
59
60#[derive(Clone)]
61pub(crate) struct WorkspaceDocumentSnapshotFactory {
62    resolved_client_capabilities: Arc<ResolvedClientCapabilities>,
63    position_encoding: PositionEncoding,
64    analysis_cache: Arc<DocumentAnalysisCache>,
65    analysis_settings_epoch: u64,
66}
67
68impl Session {
69    /// Create a session from client capabilities, global settings, and workspaces.
70    pub fn new(
71        client_capabilities: &ClientCapabilities,
72        position_encoding: PositionEncoding,
73        global: GlobalClientSettings,
74        workspaces: &Workspaces,
75        client: &Client,
76    ) -> crate::Result<Self> {
77        Ok(Self {
78            index: index::Index::new(workspaces, &global, client)?,
79            position_encoding,
80            global_settings: global,
81            resolved_client_capabilities: Arc::new(ResolvedClientCapabilities::new(
82                client_capabilities,
83            )),
84            workspace_symbols: Arc::new(crate::symbols::WorkspaceSymbolIndex::default()),
85            workspace_diagnostics: Arc::new(
86                crate::workspace_diagnostics::WorkspaceDiagnosticCache::default(),
87            ),
88            analysis_cache: Arc::new(DocumentAnalysisCache::new()),
89            workspace_function_index: Arc::new(
90                crate::workspace_functions::WorkspaceFunctionIndexCache::default(),
91            ),
92            request_queue: RequestQueue::new(),
93            shutdown_requested: false,
94        })
95    }
96
97    pub(crate) fn request_queue(&self) -> &RequestQueue {
98        &self.request_queue
99    }
100
101    pub(crate) fn request_queue_mut(&mut self) -> &mut RequestQueue {
102        &mut self.request_queue
103    }
104
105    pub(crate) fn is_shutdown_requested(&self) -> bool {
106        self.shutdown_requested
107    }
108
109    pub(crate) fn set_shutdown_requested(&mut self, requested: bool) {
110        self.shutdown_requested = requested;
111    }
112
113    /// Return the document key for an LSP document URL.
114    pub fn key_from_url(&self, url: Url) -> DocumentKey {
115        self.index.key_from_url(url)
116    }
117
118    /// Capture a document snapshot for diagnostics, hovers, or code actions.
119    pub fn take_snapshot(&self, url: Url) -> Option<DocumentSnapshot> {
120        let (settings, client_settings) = self
121            .index
122            .resolve_snapshot_settings(&url, self.global_settings.options());
123        let key = self.key_from_url(url);
124        Some(DocumentSnapshot {
125            resolved_client_capabilities: self.resolved_client_capabilities.clone(),
126            client_settings,
127            document_ref: self.index.make_document_ref(key, settings)?,
128            position_encoding: self.position_encoding,
129            analysis_cache: self.analysis_cache.clone(),
130            analysis_settings_epoch: self.analysis_cache.current_settings_epoch(),
131        })
132    }
133
134    pub(crate) fn update_text_document(
135        &mut self,
136        key: &DocumentKey,
137        content_changes: Vec<lsp_types::TextDocumentContentChangeEvent>,
138        new_version: DocumentVersion,
139    ) -> crate::Result<()> {
140        let result =
141            self.index
142                .update_text_document(key, content_changes, new_version, self.encoding());
143        if result.is_ok() {
144            self.analysis_cache.invalidate_uri(&key.clone().into_url());
145            self.workspace_diagnostics
146                .invalidate_uri(&key.clone().into_url());
147            self.workspace_function_index.invalidate();
148        }
149        result
150    }
151
152    /// Open or replace an in-memory text document.
153    pub fn open_text_document(&mut self, url: Url, document: TextDocument) {
154        self.analysis_cache.invalidate_uri(&url);
155        self.workspace_diagnostics.invalidate_uri(&url);
156        self.workspace_function_index.invalidate();
157        self.index.open_text_document(url, document);
158    }
159
160    pub(crate) fn close_document(&mut self, key: &DocumentKey) -> crate::Result<()> {
161        self.index.close_document(key)?;
162        self.analysis_cache.invalidate_uri(&key.clone().into_url());
163        self.workspace_diagnostics
164            .invalidate_uri(&key.clone().into_url());
165        self.workspace_function_index.invalidate();
166        self.workspace_symbols
167            .invalidate_uri(&key.clone().into_url());
168        Ok(())
169    }
170
171    pub(crate) fn reload_settings(&mut self, changes: &[FileEvent], client: &Client) {
172        self.index.reload_settings(changes, client);
173        self.analysis_cache.clear();
174        self.workspace_diagnostics.invalidate_all();
175        self.workspace_function_index.invalidate();
176        self.workspace_symbols.invalidate_file_events(changes);
177    }
178
179    pub(crate) fn open_workspace_folder(&mut self, url: Url, client: &Client) -> crate::Result<()> {
180        self.index
181            .open_workspace_folder(url, &self.global_settings, client)?;
182        self.analysis_cache.clear();
183        self.workspace_diagnostics.invalidate_all();
184        self.workspace_function_index.invalidate();
185        self.workspace_symbols.invalidate_all();
186        Ok(())
187    }
188
189    pub(crate) fn close_workspace_folder(&mut self, url: &Url) -> crate::Result<()> {
190        self.index.close_workspace_folder(url)?;
191        self.analysis_cache.clear();
192        self.workspace_diagnostics.invalidate_all();
193        self.workspace_function_index.invalidate();
194        self.workspace_symbols.invalidate_all();
195        Ok(())
196    }
197
198    pub(crate) fn resolved_client_capabilities(&self) -> &ResolvedClientCapabilities {
199        &self.resolved_client_capabilities
200    }
201
202    pub(crate) fn encoding(&self) -> PositionEncoding {
203        self.position_encoding
204    }
205
206    pub(crate) fn config_file_paths(&self) -> impl Iterator<Item = &Path> {
207        self.index.config_file_paths()
208    }
209
210    pub(crate) fn set_project_settings_cache_enabled(&mut self, enabled: bool) {
211        self.index.set_project_settings_cache_enabled(enabled);
212    }
213
214    pub(crate) fn update_client_options(&mut self, options: ClientOptions) {
215        self.analysis_cache.clear();
216        self.workspace_diagnostics.invalidate_all();
217        self.workspace_function_index.invalidate();
218        self.workspace_symbols.invalidate_all();
219        self.global_settings.update_options(options);
220        self.index.clear_project_settings_cache();
221    }
222
223    pub(crate) fn update_configuration(
224        &mut self,
225        options: ClientOptions,
226        workspace_options: Option<WorkspaceOptionsMap>,
227    ) {
228        self.analysis_cache.clear();
229        self.workspace_diagnostics.invalidate_all();
230        self.workspace_function_index.invalidate();
231        self.workspace_symbols.invalidate_all();
232        self.global_settings.update_options(options);
233        if let Some(workspace_options) = workspace_options {
234            self.index.update_workspace_options(workspace_options);
235        } else {
236            self.index.clear_project_settings_cache();
237        }
238    }
239
240    pub(crate) fn open_document_count(&self) -> usize {
241        self.index.open_document_count()
242    }
243
244    pub(crate) fn workspace_roots(&self) -> &[std::path::PathBuf] {
245        self.index.workspace_roots()
246    }
247
248    pub(crate) fn workspace_document_snapshot_factory(&self) -> WorkspaceDocumentSnapshotFactory {
249        WorkspaceDocumentSnapshotFactory {
250            resolved_client_capabilities: self.resolved_client_capabilities.clone(),
251            position_encoding: self.position_encoding,
252            analysis_cache: self.analysis_cache.clone(),
253            analysis_settings_epoch: self.analysis_cache.current_settings_epoch(),
254        }
255    }
256
257    pub(crate) fn workspace_diagnostic_context(
258        &self,
259        cancellation: RequestCancellationToken,
260    ) -> crate::workspace_diagnostics::WorkspaceDiagnosticContext {
261        let workspace_settings = self.index.workspace_settings_snapshot();
262        let workspace_roots = self.index.workspace_roots().to_vec();
263        let mut settings_workspace_roots = workspace_roots.clone();
264        for workspace in &workspace_settings {
265            let Some(canonical_root) = &workspace.canonical_root else {
266                continue;
267            };
268            if !settings_workspace_roots.contains(canonical_root) {
269                settings_workspace_roots.push(canonical_root.clone());
270            }
271        }
272        let open_documents = self
273            .index
274            .open_documents_snapshot()
275            .into_iter()
276            .filter_map(|document| {
277                let path = document.uri.to_file_path().ok()?;
278                let snapshot = self.take_snapshot(document.uri.clone())?;
279                Some(
280                    crate::workspace_diagnostics::WorkspaceDiagnosticOpenDocument {
281                        uri: document.uri,
282                        path,
283                        snapshot,
284                    },
285                )
286            })
287            .collect();
288        crate::workspace_diagnostics::WorkspaceDiagnosticContext {
289            options: self.global_settings.options().server.workspace_diagnostics,
290            global_options: self.global_settings.options().clone(),
291            workspace_settings,
292            workspace_roots,
293            settings_workspace_roots,
294            open_documents,
295            snapshot_factory: self.workspace_document_snapshot_factory(),
296            cache: self.workspace_diagnostics.clone(),
297            cache_generation: self.workspace_diagnostics.generation(),
298            cancellation,
299        }
300    }
301
302    pub(crate) fn workspace_symbol_context(&self) -> crate::symbols::WorkspaceSymbolContext {
303        let workspace_settings = self.index.workspace_settings_snapshot();
304        let workspace_roots = self.index.workspace_roots().to_vec();
305        let mut settings_workspace_roots = workspace_roots.clone();
306        for workspace in &workspace_settings {
307            let Some(canonical_root) = &workspace.canonical_root else {
308                continue;
309            };
310            if !settings_workspace_roots
311                .iter()
312                .any(|root| root == canonical_root)
313            {
314                settings_workspace_roots.push(canonical_root.clone());
315            }
316        }
317
318        crate::symbols::WorkspaceSymbolContext {
319            index: self.workspace_symbols.clone(),
320            options: self.global_settings.workspace_symbol_options(),
321            global_options: self.global_settings.options().clone(),
322            workspace_settings,
323            workspace_roots,
324            settings_workspace_roots,
325            open_documents: self.index.open_documents_snapshot(),
326            encoding: self.position_encoding,
327        }
328    }
329
330    /// Build the immutable context used by cross-file function features.
331    pub(crate) fn workspace_function_context(
332        &self,
333        cancellation: RequestCancellationToken,
334    ) -> crate::workspace_functions::WorkspaceFunctionContext {
335        let workspace_settings = self.index.workspace_settings_snapshot();
336        let workspace_roots = self.index.workspace_roots().to_vec();
337        let mut settings_workspace_roots = workspace_roots.clone();
338        for workspace in &workspace_settings {
339            let Some(canonical_root) = &workspace.canonical_root else {
340                continue;
341            };
342            if !settings_workspace_roots.contains(canonical_root) {
343                settings_workspace_roots.push(canonical_root.clone());
344            }
345        }
346        crate::workspace_functions::WorkspaceFunctionContext {
347            workspace_roots,
348            settings_workspace_roots,
349            workspace_settings,
350            global_options: self.global_settings.options().clone(),
351            open_documents: self.index.open_documents_snapshot(),
352            encoding: self.position_encoding,
353            max_files: self
354                .global_settings
355                .options()
356                .server
357                .call_hierarchy
358                .max_files,
359            epoch: self.workspace_function_index.current_epoch(),
360            cache: self.workspace_function_index.clone(),
361            cancellation,
362        }
363    }
364}
365
366impl DocumentSnapshot {
367    pub(crate) fn resolved_client_capabilities(&self) -> &ResolvedClientCapabilities {
368        &self.resolved_client_capabilities
369    }
370
371    pub(crate) fn client_settings(&self) -> &ClientSettings {
372        &self.client_settings
373    }
374
375    pub(crate) fn shuck_settings(&self) -> &ShuckSettings {
376        self.document_ref.settings()
377    }
378
379    /// Return the query object used to access the underlying document and settings.
380    pub fn query(&self) -> &index::DocumentQuery {
381        &self.document_ref
382    }
383
384    pub(crate) fn encoding(&self) -> PositionEncoding {
385        self.position_encoding
386    }
387
388    pub(crate) fn analysis(&self) -> Option<Arc<DocumentAnalysis>> {
389        self.analysis_cache.get_or_build(self)
390    }
391
392    pub(crate) fn analysis_settings_epoch(&self) -> u64 {
393        self.analysis_settings_epoch
394    }
395}
396
397impl WorkspaceDocumentSnapshotFactory {
398    pub(crate) fn snapshot(
399        &self,
400        uri: Url,
401        document: Arc<TextDocument>,
402        settings: Arc<ShuckSettings>,
403        client_settings: Arc<ClientSettings>,
404    ) -> DocumentSnapshot {
405        DocumentSnapshot {
406            resolved_client_capabilities: self.resolved_client_capabilities.clone(),
407            client_settings,
408            document_ref: DocumentQuery::Text {
409                file_url: uri,
410                document,
411                settings,
412            },
413            position_encoding: self.position_encoding,
414            analysis_cache: self.analysis_cache.clone(),
415            analysis_settings_epoch: self.analysis_settings_epoch,
416        }
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use crossbeam::channel;
423    use lsp_types::{
424        ClientCapabilities, DidChangeWatchedFilesClientCapabilities, FileChangeType, FileEvent,
425        TextDocumentContentChangeEvent, Url, WorkspaceClientCapabilities,
426    };
427
428    use super::*;
429    use crate::{ClientOptions, GlobalOptions, TextDocument, Workspace, Workspaces};
430
431    fn client_capabilities_with_dynamic_watched_files() -> ClientCapabilities {
432        ClientCapabilities {
433            workspace: Some(WorkspaceClientCapabilities {
434                did_change_watched_files: Some(DidChangeWatchedFilesClientCapabilities {
435                    dynamic_registration: Some(true),
436                    relative_pattern_support: None,
437                }),
438                ..WorkspaceClientCapabilities::default()
439            }),
440            ..ClientCapabilities::default()
441        }
442    }
443
444    fn make_test_session() -> (tempfile::TempDir, Session, Url) {
445        let workspace = tempfile::tempdir().expect("workspace should be created");
446        let workspace_uri =
447            Url::from_file_path(workspace.path()).expect("workspace path should convert");
448        let workspaces = Workspaces::new(vec![Workspace::default(workspace_uri)]);
449        let (main_loop_sender, _main_loop_receiver) = channel::unbounded();
450        let (client_sender, _client_receiver) = channel::unbounded();
451        let client = Client::new(main_loop_sender, client_sender);
452        let global = GlobalOptions::default().into_settings(client.clone());
453        let mut session = Session::new(
454            &ClientCapabilities::default(),
455            PositionEncoding::UTF16,
456            global,
457            &workspaces,
458            &client,
459        )
460        .expect("test session should initialize");
461        let uri = Url::from_file_path(workspace.path().join("script.sh"))
462            .expect("script path should convert to a URL");
463        session.open_text_document(
464            uri.clone(),
465            TextDocument::new("#!/bin/bash\nname=value\necho \"$name\"\n".to_owned(), 1)
466                .with_language_id("shellscript"),
467        );
468        (workspace, session, uri)
469    }
470
471    #[test]
472    fn document_analysis_cache_reuses_same_document_version() {
473        let (_workspace, session, uri) = make_test_session();
474        let first = session
475            .take_snapshot(uri.clone())
476            .expect("test document should produce a snapshot")
477            .analysis()
478            .expect("shell document should have analysis");
479        let second = session
480            .take_snapshot(uri)
481            .expect("test document should produce a snapshot")
482            .analysis()
483            .expect("shell document should have analysis");
484
485        assert!(Arc::ptr_eq(&first, &second));
486    }
487
488    #[test]
489    fn document_analysis_cache_invalidates_after_document_change() {
490        let (_workspace, mut session, uri) = make_test_session();
491        let before = session
492            .take_snapshot(uri.clone())
493            .expect("test document should produce a snapshot")
494            .analysis()
495            .expect("shell document should have analysis");
496        let key = session.key_from_url(uri.clone());
497
498        session
499            .update_text_document(
500                &key,
501                vec![TextDocumentContentChangeEvent {
502                    range: None,
503                    range_length: None,
504                    text: "#!/bin/bash\nother=value\necho \"$other\"\n".to_owned(),
505                }],
506                2,
507            )
508            .expect("document change should apply");
509
510        let after = session
511            .take_snapshot(uri)
512            .expect("test document should produce a snapshot")
513            .analysis()
514            .expect("shell document should have analysis");
515
516        assert!(!Arc::ptr_eq(&before, &after));
517        assert!(after.source().contains("other=value"));
518    }
519
520    #[test]
521    fn workspace_function_index_invalidates_after_closed_file_event() {
522        let (workspace, mut session, _uri) = make_test_session();
523        let before = session
524            .workspace_function_context(RequestCancellationToken::default())
525            .epoch;
526        let (main_loop_sender, _main_loop_receiver) = channel::unbounded();
527        let (client_sender, _client_receiver) = channel::unbounded();
528        let client = Client::new(main_loop_sender, client_sender);
529        let closed_uri = Url::from_file_path(workspace.path().join("closed.sh"))
530            .expect("closed file path should convert to a URL");
531
532        session.reload_settings(
533            &[FileEvent {
534                uri: closed_uri,
535                typ: FileChangeType::CHANGED,
536            }],
537            &client,
538        );
539
540        assert!(
541            session
542                .workspace_function_context(RequestCancellationToken::default())
543                .epoch
544                > before
545        );
546    }
547
548    #[test]
549    fn document_analysis_cache_invalidates_after_configuration_change() {
550        let (_workspace, mut session, uri) = make_test_session();
551        let stale_snapshot = session
552            .take_snapshot(uri.clone())
553            .expect("test document should produce a snapshot");
554        let before = stale_snapshot
555            .analysis()
556            .expect("shell document should have analysis");
557
558        session.update_client_options(ClientOptions {
559            lint: Some(shuck_config::LintConfig {
560                select: Some(vec!["C006".to_owned()]),
561                ..shuck_config::LintConfig::default()
562            }),
563            ..ClientOptions::default()
564        });
565
566        let stale_after_clear = stale_snapshot
567            .analysis()
568            .expect("stale snapshot can still analyze its own settings epoch");
569        let after = session
570            .take_snapshot(uri)
571            .expect("test document should produce a snapshot")
572            .analysis()
573            .expect("shell document should have analysis");
574
575        assert!(!Arc::ptr_eq(&before, &after));
576        assert!(!Arc::ptr_eq(&stale_after_clear, &after));
577    }
578
579    #[test]
580    fn take_snapshot_merges_global_and_workspace_options() {
581        let workspace_one = tempfile::tempdir().expect("workspace should be created");
582        let workspace_two = tempfile::tempdir().expect("workspace should be created");
583        let workspace_one_uri =
584            Url::from_file_path(workspace_one.path()).expect("workspace path should convert");
585        let workspace_two_uri =
586            Url::from_file_path(workspace_two.path()).expect("workspace path should convert");
587
588        let workspaces = Workspaces::new(vec![
589            Workspace::default(workspace_one_uri),
590            Workspace::new(workspace_two_uri.clone()).with_options(ClientOptions {
591                lint: Some(shuck_config::LintConfig {
592                    select: Some(vec!["C006".to_owned()]),
593                    ..shuck_config::LintConfig::default()
594                }),
595                format: Some(shuck_config::FormatConfig {
596                    indent_width: Some(2),
597                    ..shuck_config::FormatConfig::default()
598                }),
599                fix_all: Some(false),
600                ..ClientOptions::default()
601            }),
602        ]);
603        let (main_loop_sender, _main_loop_receiver) = channel::unbounded();
604        let (client_sender, _client_receiver) = channel::unbounded();
605        let client = Client::new(main_loop_sender, client_sender);
606        let global = GlobalOptions::default().into_settings(client.clone());
607        let mut session = Session::new(
608            &client_capabilities_with_dynamic_watched_files(),
609            PositionEncoding::UTF16,
610            global,
611            &workspaces,
612            &client,
613        )
614        .expect("test session should initialize");
615        session.set_project_settings_cache_enabled(true);
616        session.update_client_options(ClientOptions {
617            lint: Some(shuck_config::LintConfig {
618                select: Some(vec!["C001".to_owned()]),
619                ..shuck_config::LintConfig::default()
620            }),
621            format: Some(shuck_config::FormatConfig {
622                indent_style: Some("space".to_owned()),
623                ..shuck_config::FormatConfig::default()
624            }),
625            show_syntax_errors: Some(true),
626            ..ClientOptions::default()
627        });
628
629        let uri = Url::from_file_path(workspace_two.path().join("script.sh"))
630            .expect("test path should convert to a URL");
631        session.open_text_document(
632            uri.clone(),
633            TextDocument::new("foo=1\n".to_owned(), 1).with_language_id("shellscript"),
634        );
635
636        let snapshot = session
637            .take_snapshot(uri)
638            .expect("test document should produce a snapshot");
639
640        assert!(
641            snapshot
642                .shuck_settings()
643                .linter()
644                .rules
645                .contains(shuck_linter::Rule::UndefinedVariable)
646        );
647        assert_eq!(snapshot.shuck_settings().linter().rules.len(), 1);
648        assert_eq!(
649            snapshot.shuck_settings().formatter().indent_style(),
650            shuck_formatter::IndentStyle::Space
651        );
652        assert_eq!(snapshot.shuck_settings().formatter().indent_width(), 2);
653        assert!(!snapshot.client_settings().fix_all());
654        assert!(snapshot.client_settings().show_syntax_errors());
655    }
656
657    #[test]
658    fn update_configuration_updates_workspace_specific_options() {
659        let workspace_one = tempfile::tempdir().expect("workspace should be created");
660        let workspace_two = tempfile::tempdir().expect("workspace should be created");
661        let workspace_one_uri =
662            Url::from_file_path(workspace_one.path()).expect("workspace path should convert");
663        let workspace_two_uri =
664            Url::from_file_path(workspace_two.path()).expect("workspace path should convert");
665
666        let workspaces = Workspaces::new(vec![
667            Workspace::default(workspace_one_uri),
668            Workspace::new(workspace_two_uri.clone()).with_options(ClientOptions {
669                lint: Some(shuck_config::LintConfig {
670                    select: Some(vec!["C006".to_owned()]),
671                    ..shuck_config::LintConfig::default()
672                }),
673                ..ClientOptions::default()
674            }),
675        ]);
676        let (main_loop_sender, _main_loop_receiver) = channel::unbounded();
677        let (client_sender, _client_receiver) = channel::unbounded();
678        let client = Client::new(main_loop_sender, client_sender);
679        let global = GlobalOptions::default().into_settings(client.clone());
680        let mut session = Session::new(
681            &client_capabilities_with_dynamic_watched_files(),
682            PositionEncoding::UTF16,
683            global,
684            &workspaces,
685            &client,
686        )
687        .expect("test session should initialize");
688        session.set_project_settings_cache_enabled(true);
689
690        let uri = Url::from_file_path(workspace_two.path().join("script.sh"))
691            .expect("test path should convert to a URL");
692        session.open_text_document(
693            uri.clone(),
694            TextDocument::new("foo=1\n".to_owned(), 1).with_language_id("shellscript"),
695        );
696
697        let before = session
698            .take_snapshot(uri.clone())
699            .expect("test document should produce a snapshot");
700        assert!(
701            before
702                .shuck_settings()
703                .linter()
704                .rules
705                .contains(shuck_linter::Rule::UndefinedVariable)
706        );
707        assert_eq!(before.shuck_settings().linter().rules.len(), 1);
708
709        let mut workspace_options = WorkspaceOptionsMap::default();
710        workspace_options.insert(
711            workspace_two_uri,
712            ClientOptions {
713                lint: Some(shuck_config::LintConfig {
714                    select: Some(vec!["C001".to_owned()]),
715                    ..shuck_config::LintConfig::default()
716                }),
717                ..ClientOptions::default()
718            },
719        );
720        session.update_configuration(ClientOptions::default(), Some(workspace_options));
721
722        let after = session
723            .take_snapshot(uri)
724            .expect("test document should produce a snapshot");
725        assert!(
726            after
727                .shuck_settings()
728                .linter()
729                .rules
730                .contains(shuck_linter::Rule::UnusedAssignment)
731        );
732        assert_eq!(after.shuck_settings().linter().rules.len(), 1);
733    }
734
735    #[test]
736    fn update_client_options_invalidates_cached_project_settings() {
737        let workspace = tempfile::tempdir().expect("workspace should be created");
738        std::fs::write(
739            workspace.path().join(".shuck.toml"),
740            "[lint]\nselect = ['C001']\n",
741        )
742        .expect("config should be written");
743        let workspace_uri =
744            Url::from_file_path(workspace.path()).expect("workspace path should convert");
745        let workspaces = Workspaces::new(vec![Workspace::default(workspace_uri)]);
746        let (main_loop_sender, _main_loop_receiver) = channel::unbounded();
747        let (client_sender, _client_receiver) = channel::unbounded();
748        let client = Client::new(main_loop_sender, client_sender);
749        let global = GlobalOptions::default().into_settings(client.clone());
750        let mut session = Session::new(
751            &client_capabilities_with_dynamic_watched_files(),
752            PositionEncoding::UTF16,
753            global,
754            &workspaces,
755            &client,
756        )
757        .expect("test session should initialize");
758        session.set_project_settings_cache_enabled(true);
759
760        let uri = Url::from_file_path(workspace.path().join("script.sh"))
761            .expect("test path should convert to a URL");
762        session.open_text_document(
763            uri.clone(),
764            TextDocument::new("foo=1\n".to_owned(), 1).with_language_id("shellscript"),
765        );
766
767        let before = session
768            .take_snapshot(uri.clone())
769            .expect("test document should produce a snapshot");
770        assert!(
771            before
772                .shuck_settings()
773                .linter()
774                .rules
775                .contains(shuck_linter::Rule::UnusedAssignment)
776        );
777        assert_eq!(before.shuck_settings().linter().rules.len(), 1);
778
779        session.update_client_options(ClientOptions {
780            lint: Some(shuck_config::LintConfig {
781                select: Some(vec!["C006".to_owned()]),
782                ..shuck_config::LintConfig::default()
783            }),
784            ..ClientOptions::default()
785        });
786
787        let after = session
788            .take_snapshot(uri)
789            .expect("test document should produce a snapshot");
790        assert!(
791            after
792                .shuck_settings()
793                .linter()
794                .rules
795                .contains(shuck_linter::Rule::UndefinedVariable)
796        );
797        assert_eq!(after.shuck_settings().linter().rules.len(), 1);
798    }
799
800    #[test]
801    fn nested_config_creation_switches_to_a_new_cache_key() {
802        let workspace = tempfile::tempdir().expect("workspace should be created");
803        std::fs::write(
804            workspace.path().join(".shuck.toml"),
805            "[lint]\nselect = ['C001']\n",
806        )
807        .expect("config should be written");
808        let nested = workspace.path().join("nested");
809        std::fs::create_dir_all(&nested).expect("nested dir should be created");
810        let workspace_uri =
811            Url::from_file_path(workspace.path()).expect("workspace path should convert");
812        let workspaces = Workspaces::new(vec![Workspace::default(workspace_uri)]);
813        let (main_loop_sender, _main_loop_receiver) = channel::unbounded();
814        let (client_sender, _client_receiver) = channel::unbounded();
815        let client = Client::new(main_loop_sender, client_sender);
816        let global = GlobalOptions::default().into_settings(client.clone());
817        let mut session = Session::new(
818            &client_capabilities_with_dynamic_watched_files(),
819            PositionEncoding::UTF16,
820            global,
821            &workspaces,
822            &client,
823        )
824        .expect("test session should initialize");
825        session.set_project_settings_cache_enabled(true);
826
827        let uri = Url::from_file_path(nested.join("script.sh"))
828            .expect("test path should convert to a URL");
829        session.open_text_document(
830            uri.clone(),
831            TextDocument::new("foo=1\n".to_owned(), 1).with_language_id("shellscript"),
832        );
833
834        let before = session
835            .take_snapshot(uri.clone())
836            .expect("test document should produce a snapshot");
837        assert_eq!(
838            before.shuck_settings().project_root(),
839            Some(workspace.path())
840        );
841        assert!(
842            before
843                .shuck_settings()
844                .linter()
845                .rules
846                .contains(shuck_linter::Rule::UnusedAssignment)
847        );
848
849        std::fs::write(nested.join(".shuck.toml"), "[lint]\nselect = ['C006']\n")
850            .expect("nested config should be written");
851
852        let after = session
853            .take_snapshot(uri)
854            .expect("test document should produce a snapshot");
855        assert_eq!(
856            after.shuck_settings().project_root(),
857            Some(nested.as_path())
858        );
859        assert!(
860            after
861                .shuck_settings()
862                .linter()
863                .rules
864                .contains(shuck_linter::Rule::UndefinedVariable)
865        );
866        assert_eq!(after.shuck_settings().linter().rules.len(), 1);
867    }
868}