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
11pub 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 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 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 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 if !path.is_file() {
66 continue;
67 }
68
69 let relative = match path.strip_prefix(&folder_path) {
71 Ok(rel) => rel,
72 Err(_) => continue,
73 };
74
75 let path_str = relative.to_string_lossy();
77
78 if ignore_set.is_match(&*path_str) {
80 continue;
81 }
82
83 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 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 let lookup_map = crate::document_kind::build_lookup_extension_map(&config.lookup_files);
130
131 for (i, file_path) in all_files.iter().enumerate() {
133 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 let content = match fs::read_to_string(file_path).await {
153 Ok(c) => c,
154 Err(_) => continue,
155 };
156
157 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 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 crate::document_kind::DocumentKind::Js => continue,
191 };
192
193 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}