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::manager::CssVariableManager;
8use crate::parsers::{parse_css_document, parse_html_document};
9
10/// Scan workspace folders for CSS and HTML files.
11///
12/// Uses the configured `lookup_files` glob patterns to discover files and the
13/// `ignore_globs` patterns to exclude them. Document kind is resolved via
14/// `document_kind::resolve_document_kind` so that workspace scanning stays in
15/// sync with completion / hover / goto-definition behavior.
16pub async fn scan_workspace(
17    folders: Vec<Uri>,
18    manager: &CssVariableManager,
19    mut on_progress: impl FnMut(usize, usize),
20) -> Result<(), String> {
21    let config = manager.get_config().await;
22
23    // Build glob matchers for lookup patterns
24    let mut lookup_builder = GlobSetBuilder::new();
25    for pattern in &config.lookup_files {
26        if let Ok(glob) = Glob::new(pattern) {
27            lookup_builder.add(glob);
28        }
29    }
30    let lookup_set = lookup_builder
31        .build()
32        .map_err(|e| format!("Failed to build lookup glob set: {}", e))?;
33
34    // Build glob matchers for ignore patterns
35    let mut ignore_builder = GlobSetBuilder::new();
36    for pattern in &config.ignore_globs {
37        if let Ok(glob) = Glob::new(pattern) {
38            ignore_builder.add(glob);
39        }
40    }
41    let ignore_set = ignore_builder
42        .build()
43        .map_err(|e| format!("Failed to build ignore glob set: {}", e))?;
44
45    // Collect all files from all folders
46    let mut all_files = HashSet::new();
47
48    for folder_uri in folders {
49        let folder_path = match crate::path_display::to_normalized_fs_path(&folder_uri) {
50            Some(path) => path,
51            None => continue,
52        };
53
54        for entry in WalkDir::new(&folder_path)
55            .follow_links(false)
56            .into_iter()
57            .filter_map(|e| e.ok())
58        {
59            let path = entry.path();
60
61            // Skip if not a file
62            if !path.is_file() {
63                continue;
64            }
65
66            // Get relative path for glob matching
67            let relative = match path.strip_prefix(&folder_path) {
68                Ok(rel) => rel,
69                Err(_) => continue,
70            };
71
72            // Convert to string for glob matching
73            let path_str = relative.to_string_lossy();
74
75            // Skip if matches ignore pattern
76            if ignore_set.is_match(&*path_str) {
77                continue;
78            }
79
80            // Include if matches lookup pattern
81            if lookup_set.is_match(&*path_str) {
82                all_files.insert(path.to_path_buf());
83            }
84        }
85    }
86
87    let mut all_files: Vec<_> = all_files.into_iter().collect();
88    all_files.sort();
89    let total = all_files.len();
90
91    // A scan replaces the previously indexed state for every discovered file. This
92    // keeps repeated scans and overlapping workspace folders from accumulating copies.
93    let file_uris: HashSet<Uri> = all_files.iter().filter_map(Uri::from_file_path).collect();
94    manager.remove_documents(&file_uris).await;
95
96    // Build the extension->kind map once for the whole scan so we agree with the
97    // editor on what counts as CSS vs HTML vs JS without re-deriving it per file.
98    let lookup_map = crate::document_kind::build_lookup_extension_map(&config.lookup_files);
99
100    // Parse each file
101    for (i, file_path) in all_files.iter().enumerate() {
102        // Report progress
103        on_progress(i + 1, total);
104
105        // Read file content
106        let content = match fs::read_to_string(file_path).await {
107            Ok(c) => c,
108            Err(_) => continue,
109        };
110
111        // Convert to URI
112        let file_uri = match Uri::from_file_path(file_path) {
113            Some(u) => u,
114            None => continue,
115        };
116
117        // Determine file type and parse using the extension->kind map built once above.
118        let path_str = file_path.to_string_lossy();
119        let kind = match crate::document_kind::resolve_document_kind(&path_str, None, &lookup_map) {
120            Some(kind) => kind,
121            None => continue,
122        };
123        let result = match kind {
124            crate::document_kind::DocumentKind::Html => {
125                parse_html_document(&content, &file_uri, manager).await
126            }
127            crate::document_kind::DocumentKind::Css => {
128                parse_css_document(&content, &file_uri, manager).await
129            }
130            // JS/CSS-in-JS files are not scanned eagerly from disk: their CSS lives
131            // inside string/template literals that we only parse when the editor is
132            // actually showing us the file (did_open/did_change).
133            crate::document_kind::DocumentKind::Js => continue,
134        };
135
136        // Log errors but continue so a single malformed file does not abort the
137        // whole workspace scan. Only logged when the user has opted into tracing
138        // via CSS_LSP_ENABLE_LOGS=1 so we keep LSP stdio clean by default.
139        if let Err(e) = result {
140            tracing::debug!(file = %file_path.display(), error = %e, "workspace scan: parse error");
141        }
142    }
143
144    manager.rebuild_color_index().await;
145
146    Ok(())
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::types::Config;
153
154    #[tokio::test]
155    async fn repeated_scans_replace_existing_document_state() {
156        let root = std::env::temp_dir().join(format!(
157            "css-variable-lsp-repeat-scan-{}",
158            std::process::id()
159        ));
160        std::fs::create_dir_all(&root).unwrap();
161        std::fs::write(root.join("variables.css"), ":root { --primary: red; }").unwrap();
162
163        let manager = CssVariableManager::new(Config::default());
164        let root_uri = Uri::from_file_path(&root).unwrap();
165        scan_workspace(vec![root_uri.clone()], &manager, |_, _| {})
166            .await
167            .unwrap();
168        scan_workspace(vec![root_uri], &manager, |_, _| {})
169            .await
170            .unwrap();
171
172        assert_eq!(manager.get_variables("--primary").await.len(), 1);
173        std::fs::remove_dir_all(root).unwrap();
174    }
175}