1use std::path::{Path, PathBuf};
2
3use globset::Glob;
4
5use crate::lsp::registry::ServerKind;
6
7pub fn find_workspace_root<S>(file_path: &Path, markers: &[S]) -> Option<PathBuf>
8where
9 S: AsRef<str>,
10{
11 find_workspace_root_within(file_path, markers, None)
12}
13
14pub fn find_workspace_root_within<S>(
16 file_path: &Path,
17 markers: &[S],
18 project_root: Option<&Path>,
19) -> Option<PathBuf>
20where
21 S: AsRef<str>,
22{
23 let resolved_path = crate::inspect::job::canonicalize_normalized(file_path);
37
38 let start_dir = if resolved_path.is_dir() {
39 resolved_path
40 } else {
41 resolved_path.parent()?.to_path_buf()
42 };
43
44 let project_root = project_root.map(crate::inspect::job::canonicalize_normalized);
45 if project_root
46 .as_ref()
47 .is_some_and(|boundary| !start_dir.starts_with(boundary))
48 {
49 return None;
50 }
51
52 let mut current = Some(start_dir.as_path());
53 while let Some(dir) = current {
54 if project_root
55 .as_ref()
56 .is_some_and(|boundary| !dir.starts_with(boundary))
57 {
58 break;
59 }
60 if markers
61 .iter()
62 .any(|marker| dir.join(marker.as_ref()).exists())
63 {
64 return Some(dir.to_path_buf());
65 }
66
67 if project_root.as_deref() == Some(dir) {
68 break;
69 }
70 current = dir.parent();
71 }
72
73 None
74}
75
76pub fn find_rust_workspace_root(file_path: &Path, project_root: Option<&Path>) -> Option<PathBuf> {
84 let resolved_path = crate::inspect::job::canonicalize_normalized(file_path);
85 let start_dir = if resolved_path.is_dir() {
86 resolved_path
87 } else {
88 resolved_path.parent()?.to_path_buf()
89 };
90 let project_root = project_root.map(crate::inspect::job::canonicalize_normalized);
91
92 if project_root
93 .as_ref()
94 .is_some_and(|boundary| !start_dir.starts_with(boundary))
95 {
96 return None;
97 }
98
99 let crate_root = nearest_cargo_manifest_dir(&start_dir, project_root.as_deref())?;
100 let mut current = Some(crate_root.as_path());
101 while let Some(dir) = current {
102 if project_root
103 .as_ref()
104 .is_some_and(|boundary| !dir.starts_with(boundary))
105 {
106 break;
107 }
108 if cargo_workspace_contains_crate(dir, &crate_root) {
109 return Some(crate::inspect::job::canonicalize_normalized(dir));
110 }
111 if project_root.as_deref() == Some(dir) {
112 break;
113 }
114 current = dir.parent();
115 }
116
117 None
118}
119
120fn nearest_cargo_manifest_dir(start_dir: &Path, project_root: Option<&Path>) -> Option<PathBuf> {
121 let mut current = Some(start_dir);
122 while let Some(dir) = current {
123 if project_root.is_some_and(|boundary| !dir.starts_with(boundary)) {
124 break;
125 }
126 if dir.join("Cargo.toml").is_file() {
127 return Some(dir.to_path_buf());
128 }
129 if project_root == Some(dir) {
130 break;
131 }
132 current = dir.parent();
133 }
134 None
135}
136
137fn cargo_workspace_contains_crate(workspace_root: &Path, crate_root: &Path) -> bool {
138 let Ok(contents) = std::fs::read_to_string(workspace_root.join("Cargo.toml")) else {
139 return false;
140 };
141 let Ok(manifest) = contents.parse::<toml::Value>() else {
142 return false;
143 };
144 let Some(workspace) = manifest.get("workspace").and_then(toml::Value::as_table) else {
145 return false;
146 };
147
148 if workspace_root == crate_root {
149 return true;
150 }
151
152 let Ok(crate_relative_path) = crate_root.strip_prefix(workspace_root) else {
153 return false;
154 };
155 if workspace
156 .get("exclude")
157 .and_then(toml::Value::as_array)
158 .is_some_and(|patterns| {
159 patterns
160 .iter()
161 .filter_map(toml::Value::as_str)
162 .any(|pattern| cargo_member_pattern_matches(pattern, crate_relative_path))
163 })
164 {
165 return false;
166 }
167
168 match workspace.get("members").and_then(toml::Value::as_array) {
169 Some(patterns) => patterns
170 .iter()
171 .filter_map(toml::Value::as_str)
172 .any(|pattern| cargo_member_pattern_matches(pattern, crate_relative_path)),
173 None => true,
178 }
179}
180
181fn cargo_member_pattern_matches(pattern: &str, crate_relative_path: &Path) -> bool {
182 Glob::new(pattern.trim())
183 .map(|glob| glob.compile_matcher().is_match(crate_relative_path))
184 .unwrap_or(false)
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Hash)]
190pub struct ServerKey {
191 pub kind: ServerKind,
192 pub root: PathBuf,
193}
194
195#[cfg(test)]
196mod tests {
197 use std::fs;
198 use std::path::PathBuf;
199
200 use tempfile::tempdir;
201
202 use super::{
203 find_rust_workspace_root, find_workspace_root, find_workspace_root_within, ServerKey,
204 };
205 use crate::inspect::job::canonicalize_normalized;
206 use crate::lsp::registry::ServerKind;
207
208 #[test]
209 fn test_find_root_with_cargo_toml() {
210 let temp_dir = tempdir().unwrap();
211 let root = temp_dir.path().join("workspace");
212 let src_dir = root.join("src");
213 let file = src_dir.join("lib.rs");
214
215 fs::create_dir_all(&src_dir).unwrap();
216 fs::write(root.join("Cargo.toml"), "[package]\nname = \"demo\"\n").unwrap();
217 fs::write(&file, "fn main() {}\n").unwrap();
218
219 let expected_root = crate::inspect::job::canonicalize_normalized(&root);
223 assert_eq!(
224 find_workspace_root(&file, &["Cargo.toml"]),
225 Some(expected_root)
226 );
227 }
228
229 #[test]
230 fn test_find_root_nested() {
231 let temp_dir = tempdir().unwrap();
232 let repo_root = temp_dir.path().join("repo");
233 let crate_root = repo_root.join("crates").join("foo");
234 let src_dir = crate_root.join("src");
235 let file = src_dir.join("lib.rs");
236
237 fs::create_dir_all(&src_dir).unwrap();
238 fs::write(repo_root.join("Cargo.toml"), "[workspace]\n").unwrap();
239 fs::write(crate_root.join("Cargo.toml"), "[package]\nname = \"foo\"\n").unwrap();
240 fs::write(&file, "fn main() {}\n").unwrap();
241
242 let expected_root = crate::inspect::job::canonicalize_normalized(&crate_root);
243 assert_eq!(
244 find_workspace_root(&file, &["Cargo.toml"]),
245 Some(expected_root)
246 );
247 }
248
249 #[test]
250 fn test_find_root_none() {
251 let temp_dir = tempdir().unwrap();
252 let src_dir = temp_dir.path().join("src");
253 let file = src_dir.join("main.rs");
254
255 fs::create_dir_all(&src_dir).unwrap();
256 fs::write(&file, "fn main() {}\n").unwrap();
257
258 assert_eq!(find_workspace_root(&file, &["Cargo.toml"]), None);
259 }
260
261 #[test]
262 fn test_find_root_multiple_markers() {
263 let temp_dir = tempdir().unwrap();
264 let root = temp_dir.path().join("web");
265 let src_dir = root.join("src");
266 let file = src_dir.join("index.ts");
267
268 fs::create_dir_all(&src_dir).unwrap();
269 fs::write(root.join("tsconfig.json"), "{}\n").unwrap();
270 fs::create_dir(root.join("package.json")).unwrap();
271 fs::write(&file, "export {};\n").unwrap();
272
273 let expected_root = crate::inspect::job::canonicalize_normalized(&root);
274 assert_eq!(
275 find_workspace_root(&file, &["tsconfig.json", "package.json"]),
276 Some(expected_root)
277 );
278 }
279
280 #[test]
281 fn test_server_key_equality() {
282 let root = PathBuf::from("/tmp/workspace");
283 let same = ServerKey {
284 kind: ServerKind::Rust,
285 root: root.clone(),
286 };
287 let equal = ServerKey {
288 kind: ServerKind::Rust,
289 root,
290 };
291 let different = ServerKey {
292 kind: ServerKind::Rust,
293 root: PathBuf::from("/tmp/other"),
294 };
295
296 assert_eq!(same, equal);
297 assert_ne!(same, different);
298 }
299
300 #[test]
317 fn test_find_root_strips_windows_verbatim_prefix() {
318 let temp_dir = tempdir().unwrap();
319 let root = temp_dir.path().join("workspace");
320 let src_dir = root.join("src");
321 let nested = src_dir.join("deep").join("lib.rs");
322
323 fs::create_dir_all(nested.parent().unwrap()).unwrap();
324 fs::write(root.join("Cargo.toml"), "[package]\nname = \"demo\"\n").unwrap();
325 fs::write(&nested, "fn main() {}\n").unwrap();
326
327 let found = find_workspace_root(&nested, &["Cargo.toml"]).expect("root found");
328
329 let display = found.to_string_lossy();
331 assert!(
332 !display.starts_with("\\\\?\\"),
333 "workspace root must not carry a Windows verbatim prefix: {display}"
334 );
335
336 let expected = canonicalize_normalized(&root);
343 assert_eq!(found, expected);
344 }
345
346 #[test]
347 fn rust_workspace_root_prefers_owning_workspace_over_member_manifest() {
348 let temp_dir = tempdir().unwrap();
349 let workspace = temp_dir.path().join("workspace");
350 let crate_root = workspace.join("crates").join("member");
351 let source = crate_root.join("src").join("lib.rs");
352
353 fs::create_dir_all(source.parent().unwrap()).unwrap();
354 fs::write(
355 workspace.join("Cargo.toml"),
356 "[workspace]\nmembers = [\"crates/member\"]\nresolver = \"2\"\n",
357 )
358 .unwrap();
359 fs::write(
360 crate_root.join("Cargo.toml"),
361 "[package]\nname = \"member\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
362 )
363 .unwrap();
364 fs::write(&source, "pub fn answer() -> u32 { 42 }\n").unwrap();
365
366 assert_eq!(
367 find_rust_workspace_root(&source, Some(&workspace)),
368 Some(canonicalize_normalized(&workspace))
369 );
370 }
371
372 #[test]
373 fn rust_workspace_root_keeps_crate_excluded_from_parent_workspace_standalone() {
374 let temp_dir = tempdir().unwrap();
375 let workspace = temp_dir.path().join("workspace");
376 let member_root = workspace.join("crates").join("member");
377 let standalone_root = workspace.join("tools").join("standalone");
378 let source = standalone_root.join("src").join("lib.rs");
379
380 fs::create_dir_all(member_root.join("src")).unwrap();
381 fs::create_dir_all(source.parent().unwrap()).unwrap();
382 fs::write(
383 workspace.join("Cargo.toml"),
384 "[workspace]\nmembers = [\"crates/member\"]\nresolver = \"2\"\n",
385 )
386 .unwrap();
387 fs::write(
388 member_root.join("Cargo.toml"),
389 "[package]\nname = \"member\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
390 )
391 .unwrap();
392 fs::write(
393 standalone_root.join("Cargo.toml"),
394 "[package]\nname = \"standalone\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
395 )
396 .unwrap();
397 fs::write(&source, "pub fn answer() -> u32 { 42 }\n").unwrap();
398
399 assert_eq!(
400 find_rust_workspace_root(&source, Some(&workspace)),
401 None,
402 "the parent workspace does not list this crate"
403 );
404 }
405
406 #[test]
407 fn rust_workspace_root_does_not_walk_above_project_root() {
408 let temp_dir = tempdir().unwrap();
409 let outer_workspace = temp_dir.path().join("outer-workspace");
410 let project_root = outer_workspace.join("nested-project");
411 let crate_root = project_root.join("crate");
412 let source = crate_root.join("src").join("lib.rs");
413
414 fs::create_dir_all(source.parent().unwrap()).unwrap();
415 fs::write(
416 outer_workspace.join("Cargo.toml"),
417 "[workspace]\nmembers = [\"nested-project/crate\"]\nresolver = \"2\"\n",
418 )
419 .unwrap();
420 fs::write(
421 crate_root.join("Cargo.toml"),
422 "[package]\nname = \"nested\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
423 )
424 .unwrap();
425 fs::write(&source, "pub fn answer() -> u32 { 42 }\n").unwrap();
426 let loose_source = project_root.join("loose.rs");
427 fs::write(&loose_source, "pub fn loose() {}\n").unwrap();
428
429 assert_eq!(
430 find_rust_workspace_root(&source, Some(&project_root)),
431 None,
432 "the enclosing workspace belongs to a different session root"
433 );
434 assert_eq!(
435 find_workspace_root_within(&loose_source, &["Cargo.toml"], Some(&project_root)),
436 None,
437 "marker lookup must not cross the session project root"
438 );
439 }
440}