Skip to main content

nifty_lsp/
workspace.rs

1//! The open documents and the projects they belong to. A document inside a project folder is
2//! analysed with every other `.nf` file of that project, open or not; a document outside one
3//! is a project of its own. Open documents' text wins over what is saved on disk.
4
5use std::collections::HashMap;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use lsp_types::{PublishDiagnosticsParams, Uri};
10use nifty_syntax::FileId;
11use nifty_syntax::project::{self, root_of};
12use url::Url;
13
14use crate::document::Encoding;
15use crate::features::diagnostics;
16use crate::project::{Cursor, Project, Source};
17
18#[derive(Debug)]
19struct OpenDocument {
20    text: String,
21    version: i32,
22    /// Where the document is saved; `None` for one that isn't a file, such as an untitled
23    /// buffer.
24    path: Option<PathBuf>,
25}
26
27/// What changed after a document was opened, edited or closed.
28#[derive(Debug, Default)]
29pub(crate) struct Refreshed {
30    /// The diagnostics of every file whose project was analysed again, and an empty list for
31    /// every file no longer in an analysed project.
32    pub diagnostics: Vec<PublishDiagnosticsParams>,
33    /// Files of the project that couldn't be read, which are left out of the analysis.
34    pub warnings: Vec<String>,
35}
36
37#[derive(Debug, Default)]
38pub(crate) struct Workspace {
39    open: HashMap<Uri, OpenDocument>,
40    projects: Vec<Project>,
41}
42
43impl Workspace {
44    pub(crate) fn update(
45        &mut self,
46        uri: &Uri,
47        text: String,
48        version: i32,
49        encoding: Encoding,
50    ) -> Refreshed {
51        let path = path_of(uri);
52        self.open.insert(
53            uri.clone(),
54            OpenDocument {
55                text,
56                version,
57                path,
58            },
59        );
60        self.refresh(uri, encoding)
61    }
62
63    pub(crate) fn close(&mut self, uri: &Uri, encoding: Encoding) -> Refreshed {
64        self.open.remove(uri);
65        self.refresh(uri, encoding)
66    }
67
68    /// The project file a request points into, and the byte offset it points at.
69    pub(crate) fn locate(
70        &self,
71        uri: &Uri,
72        position: lsp_types::Position,
73        encoding: Encoding,
74    ) -> Option<Cursor<'_>> {
75        let (project, file) = self.file(uri)?;
76        let offset = project.document(file).offset(position, encoding);
77        Some(Cursor {
78            project,
79            file,
80            offset,
81        })
82    }
83
84    /// The project holding `uri`, and which of its files it is.
85    pub(crate) fn file(&self, uri: &Uri) -> Option<(&Project, FileId)> {
86        self.projects
87            .iter()
88            .find_map(|project| Some((project, project.file_of(uri)?)))
89    }
90
91    /// Analyses again the project `uri` belongs to. When `uri` was just closed, the project
92    /// stays only while another of its files is open.
93    fn refresh(&mut self, uri: &Uri, encoding: Encoding) -> Refreshed {
94        let old = self
95            .projects
96            .iter()
97            .position(|project| project.file_of(uri).is_some())
98            .map(|index| self.projects.swap_remove(index));
99        let still_open = if self.open.contains_key(uri) {
100            Some(uri.clone())
101        } else {
102            old.as_ref().and_then(|project| {
103                project
104                    .files()
105                    .map(|(_, uri)| uri)
106                    .find(|uri| self.open.contains_key(uri))
107                    .cloned()
108            })
109        };
110
111        let mut refreshed = Refreshed::default();
112        let new = still_open.map(|uri| Project::new(self.sources(&uri, &mut refreshed.warnings)));
113        if let Some(old) = &old {
114            for (_, uri) in old.files() {
115                if new.as_ref().is_none_or(|new| new.file_of(uri).is_none()) {
116                    refreshed.diagnostics.push(self.publish(uri, Vec::new()));
117                }
118            }
119        }
120        if let Some(new) = new {
121            for (file, uri) in new.files() {
122                let found = diagnostics::diagnostics(&new, file, encoding);
123                refreshed.diagnostics.push(self.publish(uri, found));
124            }
125            self.projects.push(new);
126        }
127        refreshed
128    }
129
130    /// The files of the project the open document `uri` belongs to: the entry file first,
131    /// with the text of open documents and of the rest as saved.
132    fn sources(&self, uri: &Uri, warnings: &mut Vec<String>) -> Vec<Source> {
133        let alone = |document: &OpenDocument| {
134            vec![Source {
135                uri: uri.clone(),
136                text: document.text.clone(),
137            }]
138        };
139        let Some(document) = self.open.get(uri) else {
140            return Vec::new();
141        };
142        let Some(root) = document.path.as_deref().and_then(root_of) else {
143            return alone(document);
144        };
145        let listed = match project::source_files(root) {
146            Ok(listed) => listed,
147            Err(error) => {
148                warnings.push(format!(
149                    "can't list the files of {}: {error}",
150                    root.display()
151                ));
152                return alone(document);
153            }
154        };
155        let mut sources: Vec<Source> = listed
156            .iter()
157            .filter_map(|relative| self.source(&root.join(relative), warnings))
158            .collect();
159        if !sources.iter().any(|source| source.uri == *uri) {
160            sources.extend(alone(document));
161        }
162        sources
163    }
164
165    /// The source saved at `path`, or its open document's text when it is open.
166    fn source(&self, path: &Path, warnings: &mut Vec<String>) -> Option<Source> {
167        let open = self
168            .open
169            .iter()
170            .find(|(_, document)| document.path.as_deref() == Some(path));
171        if let Some((uri, document)) = open {
172            return Some(Source {
173                uri: uri.clone(),
174                text: document.text.clone(),
175            });
176        }
177        let Some(uri) = uri_of(path) else {
178            warnings.push(format!("can't address {} as a URI", path.display()));
179            return None;
180        };
181        match fs::read_to_string(path) {
182            Ok(text) => Some(Source { uri, text }),
183            Err(error) => {
184                warnings.push(format!("can't read {}: {error}", path.display()));
185                None
186            }
187        }
188    }
189
190    fn publish(
191        &self,
192        uri: &Uri,
193        diagnostics: Vec<lsp_types::Diagnostic>,
194    ) -> PublishDiagnosticsParams {
195        PublishDiagnosticsParams {
196            uri: uri.clone(),
197            diagnostics,
198            version: self.open.get(uri).map(|document| document.version),
199        }
200    }
201}
202
203/// The file a `file:` URI names; other schemes have none.
204fn path_of(uri: &Uri) -> Option<PathBuf> {
205    Url::parse(uri.as_str()).ok()?.to_file_path().ok()
206}
207
208fn uri_of(path: &Path) -> Option<Uri> {
209    Url::from_file_path(path).ok()?.as_str().parse().ok()
210}