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