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