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