Skip to main content

css_variable_lsp/
workspace.rs

1use globset::{Glob, GlobSetBuilder};
2use ls_types::Uri;
3use std::collections::HashSet;
4use tokio::fs;
5use walkdir::WalkDir;
6
7use crate::config_analysis::{is_supported_config_path, parse_config_document, MAX_CONFIG_BYTES};
8use crate::manager::CssVariableManager;
9use crate::parsers::{parse_css_document, parse_html_document};
10
11/// Scan workspace folders for CSS and HTML files.
12///
13/// Uses the configured `lookup_files` glob patterns to discover files and the
14/// `ignore_globs` patterns to exclude them. Document kind is resolved via
15/// `document_kind::resolve_document_kind` so that workspace scanning stays in
16/// sync with completion / hover / goto-definition behavior.
17pub async fn scan_workspace(
18    folders: Vec<Uri>,
19    manager: &CssVariableManager,
20    mut on_progress: impl FnMut(usize, usize),
21) -> Result<(), String> {
22    let config = manager.get_config().await;
23
24    // Build glob matchers for lookup patterns
25    let mut lookup_builder = GlobSetBuilder::new();
26    for pattern in &config.lookup_files {
27        if let Ok(glob) = Glob::new(pattern) {
28            lookup_builder.add(glob);
29        }
30    }
31    let lookup_set = lookup_builder
32        .build()
33        .map_err(|e| format!("Failed to build lookup glob set: {}", e))?;
34
35    // Build glob matchers for ignore patterns
36    let mut ignore_builder = GlobSetBuilder::new();
37    for pattern in &config.ignore_globs {
38        if let Ok(glob) = Glob::new(pattern) {
39            ignore_builder.add(glob);
40        }
41    }
42    let ignore_set = ignore_builder
43        .build()
44        .map_err(|e| format!("Failed to build ignore glob set: {}", e))?;
45
46    // Collect all files from all folders
47    let mut all_files = HashSet::new();
48    let mut scanned_folder_paths = Vec::new();
49
50    for folder_uri in folders {
51        let folder_path = match crate::path_display::to_normalized_fs_path(&folder_uri) {
52            Some(path) => path,
53            None => continue,
54        };
55        scanned_folder_paths.push(folder_path.clone());
56
57        for entry in WalkDir::new(&folder_path)
58            .follow_links(false)
59            .into_iter()
60            .filter_map(|e| e.ok())
61        {
62            let path = entry.path();
63
64            // Skip if not a file
65            if !path.is_file() {
66                continue;
67            }
68
69            // Get relative path for glob matching
70            let relative = match path.strip_prefix(&folder_path) {
71                Ok(rel) => rel,
72                Err(_) => continue,
73            };
74
75            // Convert to string for glob matching
76            let path_str = relative.to_string_lossy();
77
78            // Skip if matches ignore pattern
79            if ignore_set.is_match(&*path_str) {
80                continue;
81            }
82
83            // Framework configuration sources are discovered by exact basename instead of
84            // requiring users to eagerly scan every JavaScript or TypeScript file.
85            if lookup_set.is_match(&*path_str) || is_supported_config_path(relative) {
86                all_files.insert(path.to_path_buf());
87            }
88        }
89    }
90
91    let mut all_files: Vec<_> = all_files.into_iter().collect();
92    all_files.sort();
93    let total = all_files.len();
94
95    let discovered_config_uris: HashSet<Uri> = all_files
96        .iter()
97        .filter(|path| is_supported_config_path(path))
98        .filter_map(Uri::from_file_path)
99        .collect();
100    let stale_config_uris: HashSet<Uri> = manager
101        .get_document_uris()
102        .await
103        .into_iter()
104        .filter(|uri| {
105            let Some(path) = crate::path_display::to_normalized_fs_path(uri) else {
106                return false;
107            };
108            is_supported_config_path(&path)
109                && scanned_folder_paths
110                    .iter()
111                    .any(|folder| path.starts_with(folder))
112                && !discovered_config_uris.contains(uri)
113        })
114        .collect();
115    manager.remove_documents(&stale_config_uris).await;
116
117    // Normal discovered files are cleared as a batch. Configuration sources replace their
118    // definitions atomically after successful analysis, preserving the last valid state while
119    // an editor or disk file is temporarily malformed.
120    let file_uris: HashSet<Uri> = all_files
121        .iter()
122        .filter(|path| !is_supported_config_path(path))
123        .filter_map(Uri::from_file_path)
124        .collect();
125    manager.remove_documents(&file_uris).await;
126
127    // Build the extension->kind map once for the whole scan so we agree with the
128    // editor on what counts as CSS vs HTML vs JS without re-deriving it per file.
129    let lookup_map = crate::document_kind::build_lookup_extension_map(&config.lookup_files);
130
131    // Parse each file
132    for (i, file_path) in all_files.iter().enumerate() {
133        // Report progress
134        on_progress(i + 1, total);
135
136        let is_config = is_supported_config_path(file_path);
137        if is_config {
138            let oversized = fs::metadata(file_path)
139                .await
140                .is_ok_and(|metadata| metadata.len() > MAX_CONFIG_BYTES as u64);
141            if oversized {
142                tracing::debug!(
143                    file = %file_path.display(),
144                    limit = MAX_CONFIG_BYTES,
145                    "workspace scan: skipped oversized configuration source"
146                );
147                continue;
148            }
149        }
150
151        // Read file content
152        let content = match fs::read_to_string(file_path).await {
153            Ok(c) => c,
154            Err(_) => continue,
155        };
156
157        // Convert to URI
158        let file_uri = match Uri::from_file_path(file_path) {
159            Some(u) => u,
160            None => continue,
161        };
162
163        if is_config {
164            if let Err(e) = parse_config_document(&content, &file_uri, manager).await {
165                tracing::debug!(
166                    file = %file_path.display(),
167                    error = %e,
168                    "workspace scan: configuration analysis error"
169                );
170            }
171            continue;
172        }
173
174        // Determine file type and parse using the extension->kind map built once above.
175        let path_str = file_path.to_string_lossy();
176        let kind = match crate::document_kind::resolve_document_kind(&path_str, None, &lookup_map) {
177            Some(kind) => kind,
178            None => continue,
179        };
180        let result = match kind {
181            crate::document_kind::DocumentKind::Html => {
182                parse_html_document(&content, &file_uri, manager).await
183            }
184            crate::document_kind::DocumentKind::Css => {
185                parse_css_document(&content, &file_uri, manager).await
186            }
187            // JS/CSS-in-JS files are not scanned eagerly from disk: their CSS lives
188            // inside string/template literals that we only parse when the editor is
189            // actually showing us the file (did_open/did_change).
190            crate::document_kind::DocumentKind::Js => continue,
191        };
192
193        // Log errors but continue so a single malformed file does not abort the
194        // whole workspace scan. Only logged when the user has opted into tracing
195        // via CSS_LSP_ENABLE_LOGS=1 so we keep LSP stdio clean by default.
196        if let Err(e) = result {
197            tracing::debug!(file = %file_path.display(), error = %e, "workspace scan: parse error");
198        }
199    }
200
201    manager.rebuild_color_index().await;
202
203    Ok(())
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::types::Config;
210
211    #[tokio::test]
212    async fn repeated_scans_replace_existing_document_state() {
213        let root = std::env::temp_dir().join(format!(
214            "css-variable-lsp-repeat-scan-{}",
215            std::process::id()
216        ));
217        std::fs::create_dir_all(&root).unwrap();
218        std::fs::write(root.join("variables.css"), ":root { --primary: red; }").unwrap();
219
220        let manager = CssVariableManager::new(Config::default());
221        let root_uri = Uri::from_file_path(&root).unwrap();
222        scan_workspace(vec![root_uri.clone()], &manager, |_, _| {})
223            .await
224            .unwrap();
225        scan_workspace(vec![root_uri], &manager, |_, _| {})
226            .await
227            .unwrap();
228
229        assert_eq!(manager.get_variables("--primary").await.len(), 1);
230        std::fs::remove_dir_all(root).unwrap();
231    }
232
233    #[tokio::test]
234    async fn rescans_remove_deleted_and_newly_ignored_astro_configs() {
235        let root = std::env::temp_dir().join(format!(
236            "css-variable-lsp-config-rescan-{}",
237            std::process::id()
238        ));
239        std::fs::create_dir_all(&root).unwrap();
240        let config_path = root.join("astro.config.ts");
241        let config_text = r#"export default { fonts: [{ cssVariable: "--font-scan" }] };"#;
242        std::fs::write(&config_path, config_text).unwrap();
243
244        let manager = CssVariableManager::new(Config::default());
245        let root_uri = Uri::from_file_path(&root).unwrap();
246        scan_workspace(vec![root_uri.clone()], &manager, |_, _| {})
247            .await
248            .unwrap();
249        assert_eq!(manager.get_variables("--font-scan").await.len(), 1);
250
251        std::fs::remove_file(&config_path).unwrap();
252        scan_workspace(vec![root_uri.clone()], &manager, |_, _| {})
253            .await
254            .unwrap();
255        assert!(manager.get_variables("--font-scan").await.is_empty());
256
257        std::fs::write(&config_path, config_text).unwrap();
258        scan_workspace(vec![root_uri.clone()], &manager, |_, _| {})
259            .await
260            .unwrap();
261        assert_eq!(manager.get_variables("--font-scan").await.len(), 1);
262
263        let mut config = manager.get_config().await;
264        config.ignore_globs.push("astro.config.ts".to_string());
265        manager.set_config(config).await;
266        scan_workspace(vec![root_uri], &manager, |_, _| {})
267            .await
268            .unwrap();
269        assert!(manager.get_variables("--font-scan").await.is_empty());
270
271        std::fs::remove_dir_all(root).unwrap();
272    }
273
274    #[tokio::test]
275    async fn rescans_remove_deleted_vite_configs() {
276        let root = std::env::temp_dir().join(format!(
277            "css-variable-lsp-vite-config-rescan-{}",
278            std::process::id()
279        ));
280        std::fs::create_dir_all(&root).unwrap();
281        let config_path = root.join("vite.config.ts");
282        std::fs::write(
283            &config_path,
284            r#"
285                export default {
286                    css: {
287                        preprocessorOptions: {
288                            scss: {
289                                additionalData: ":root { --vite-scan: red; }",
290                            },
291                        },
292                    },
293                };
294            "#,
295        )
296        .unwrap();
297
298        let manager = CssVariableManager::new(Config::default());
299        let root_uri = Uri::from_file_path(&root).unwrap();
300        scan_workspace(vec![root_uri.clone()], &manager, |_, _| {})
301            .await
302            .unwrap();
303        assert_eq!(manager.get_variables("--vite-scan").await.len(), 1);
304
305        std::fs::remove_file(&config_path).unwrap();
306        scan_workspace(vec![root_uri], &manager, |_, _| {})
307            .await
308            .unwrap();
309        assert!(manager.get_variables("--vite-scan").await.is_empty());
310
311        std::fs::remove_dir_all(root).unwrap();
312    }
313
314    #[tokio::test]
315    async fn rescans_remove_deleted_usage_only_vite_configs() {
316        let root = std::env::temp_dir().join(format!(
317            "css-variable-lsp-vite-usage-rescan-{}",
318            std::process::id()
319        ));
320        std::fs::create_dir_all(&root).unwrap();
321        let config_path = root.join("vite.config.ts");
322        std::fs::write(
323            &config_path,
324            r#"export default { css: { preprocessorOptions: { scss: { additionalData: ":root { color: var(--external); }" } } } };"#,
325        )
326        .unwrap();
327        let root_uri = Uri::from_file_path(&root).unwrap();
328        let manager = CssVariableManager::new(Config::default());
329
330        scan_workspace(vec![root_uri.clone()], &manager, |_, _| {})
331            .await
332            .unwrap();
333        assert_eq!(manager.get_usages("--external").await.len(), 1);
334
335        std::fs::remove_file(config_path).unwrap();
336        scan_workspace(vec![root_uri], &manager, |_, _| {})
337            .await
338            .unwrap();
339        assert!(manager.get_usages("--external").await.is_empty());
340
341        std::fs::remove_dir_all(root).unwrap();
342    }
343}