Skip to main content

aft/lsp/
roots.rs

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
14/// Find a marker root without walking above an optional session project root.
15pub 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    // Route canonicalization through `canonicalize_normalized` so the returned
24    // root is never a Windows verbatim (`\\?\C:\...`) path. This root flows into
25    // `LspClient::spawn` -> `Command::current_dir(&root)`, and `CreateProcess`
26    // rejects extended-length verbatim paths as `lpCurrentDirectory` (documented
27    // Win32 limitation: "The lpCurrentDirectory string ... must not be a \\?\
28    // prefixed path"). Without this strip EVERY LSP spawn on Windows fails
29    // with "The system cannot find the path specified" and aft_inspect reports
30    // servers as not installed (#174). On Unix `canonicalize_normalized` is
31    // identity-equivalent to `fs::canonicalize` followed by lexical `.`/`..`
32    // collapse, so non-Windows behavior is unchanged.
33    // `canonicalize_normalized` falls back to lexical `.`/`..` collapse when
34    // `fs::canonicalize` fails (e.g. the file is gone), matching the prior
35    // fallback to the raw `file_path` for paths without `.`/`..` components.
36    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    // Do not walk upward out of a mounted start directory. This keeps an explicit
46    // file on that mount usable while preventing root discovery from crossing it;
47    // recursive descendants avoid ReadDir::drop's ENXIO daemon-abort failure too.
48    let device_boundary = crate::walk_boundary::DeviceBoundary::for_root(&start_dir).ok();
49    if project_root
50        .as_ref()
51        .is_some_and(|boundary| !start_dir.starts_with(boundary))
52    {
53        return None;
54    }
55
56    let mut current = Some(start_dir.as_path());
57    while let Some(dir) = current {
58        if device_boundary
59            .as_ref()
60            .is_some_and(|boundary| !boundary.should_descend(dir).unwrap_or(false))
61        {
62            break;
63        }
64        if project_root
65            .as_ref()
66            .is_some_and(|boundary| !dir.starts_with(boundary))
67        {
68            break;
69        }
70        if markers
71            .iter()
72            .any(|marker| dir.join(marker.as_ref()).exists())
73        {
74            return Some(dir.to_path_buf());
75        }
76
77        if project_root.as_deref() == Some(dir) {
78            break;
79        }
80        current = dir.parent();
81    }
82
83    None
84}
85
86/// Find the Cargo workspace that owns `file_path`.
87///
88/// Rust Analyzer loads the complete Cargo workspace supplied by the client. A
89/// member crate's manifest is therefore not a suitable server root: opening a
90/// second member would otherwise start another full-workspace analyzer. The
91/// optional project root bounds the walk so an enclosing checkout cannot claim
92/// a nested session as one of its workspaces.
93pub fn find_rust_workspace_root(file_path: &Path, project_root: Option<&Path>) -> Option<PathBuf> {
94    let resolved_path = crate::inspect::job::canonicalize_normalized(file_path);
95    let start_dir = if resolved_path.is_dir() {
96        resolved_path
97    } else {
98        resolved_path.parent()?.to_path_buf()
99    };
100    let project_root = project_root.map(crate::inspect::job::canonicalize_normalized);
101    // See `find_workspace_root_within`: stay on the start filesystem while
102    // discovering an LSP root, but permit the explicitly named mount itself.
103    let device_boundary = crate::walk_boundary::DeviceBoundary::for_root(&start_dir).ok();
104
105    if project_root
106        .as_ref()
107        .is_some_and(|boundary| !start_dir.starts_with(boundary))
108    {
109        return None;
110    }
111
112    let crate_root = nearest_cargo_manifest_dir(&start_dir, project_root.as_deref())?;
113    let mut current = Some(crate_root.as_path());
114    while let Some(dir) = current {
115        if device_boundary
116            .as_ref()
117            .is_some_and(|boundary| !boundary.should_descend(dir).unwrap_or(false))
118        {
119            break;
120        }
121        if project_root
122            .as_ref()
123            .is_some_and(|boundary| !dir.starts_with(boundary))
124        {
125            break;
126        }
127        if cargo_workspace_contains_crate(dir, &crate_root) {
128            return Some(crate::inspect::job::canonicalize_normalized(dir));
129        }
130        if project_root.as_deref() == Some(dir) {
131            break;
132        }
133        current = dir.parent();
134    }
135
136    None
137}
138
139fn nearest_cargo_manifest_dir(start_dir: &Path, project_root: Option<&Path>) -> Option<PathBuf> {
140    let device_boundary = crate::walk_boundary::DeviceBoundary::for_root(start_dir).ok();
141    let mut current = Some(start_dir);
142    while let Some(dir) = current {
143        if device_boundary
144            .as_ref()
145            .is_some_and(|boundary| !boundary.should_descend(dir).unwrap_or(false))
146        {
147            break;
148        }
149        if project_root.is_some_and(|boundary| !dir.starts_with(boundary)) {
150            break;
151        }
152        if dir.join("Cargo.toml").is_file() {
153            return Some(dir.to_path_buf());
154        }
155        if project_root == Some(dir) {
156            break;
157        }
158        current = dir.parent();
159    }
160    None
161}
162
163fn cargo_workspace_contains_crate(workspace_root: &Path, crate_root: &Path) -> bool {
164    let Ok(contents) = std::fs::read_to_string(workspace_root.join("Cargo.toml")) else {
165        return false;
166    };
167    let Ok(manifest) = contents.parse::<toml::Value>() else {
168        return false;
169    };
170    let Some(workspace) = manifest.get("workspace").and_then(toml::Value::as_table) else {
171        return false;
172    };
173
174    if workspace_root == crate_root {
175        return true;
176    }
177
178    let Ok(crate_relative_path) = crate_root.strip_prefix(workspace_root) else {
179        return false;
180    };
181    if workspace
182        .get("exclude")
183        .and_then(toml::Value::as_array)
184        .is_some_and(|patterns| {
185            patterns
186                .iter()
187                .filter_map(toml::Value::as_str)
188                .any(|pattern| cargo_member_pattern_matches(pattern, crate_relative_path))
189        })
190    {
191        return false;
192    }
193
194    match workspace.get("members").and_then(toml::Value::as_array) {
195        Some(patterns) => patterns
196            .iter()
197            .filter_map(toml::Value::as_str)
198            .any(|pattern| cargo_member_pattern_matches(pattern, crate_relative_path)),
199        // Cargo permits a package to be colocated with a `[workspace]` table
200        // without listing the package in `members`. Prefer the nearest ancestor
201        // manifest that defines a workspace because it still describes the
202        // broader Cargo workspace for the analyzer.
203        None => true,
204    }
205}
206
207fn cargo_member_pattern_matches(pattern: &str, crate_relative_path: &Path) -> bool {
208    Glob::new(pattern.trim())
209        .map(|glob| glob.compile_matcher().is_match(crate_relative_path))
210        .unwrap_or(false)
211}
212
213/// Composite key for caching server instances.
214/// Each unique (ServerKind, workspace_root) pair gets its own server process.
215#[derive(Debug, Clone, PartialEq, Eq, Hash)]
216pub struct ServerKey {
217    pub kind: ServerKind,
218    pub root: PathBuf,
219}
220
221#[cfg(test)]
222mod tests {
223    use std::fs;
224    use std::path::PathBuf;
225
226    use tempfile::tempdir;
227
228    use super::{
229        find_rust_workspace_root, find_workspace_root, find_workspace_root_within, ServerKey,
230    };
231    use crate::inspect::job::canonicalize_normalized;
232    use crate::lsp::registry::ServerKind;
233
234    #[test]
235    fn test_find_root_with_cargo_toml() {
236        let temp_dir = tempdir().unwrap();
237        let root = temp_dir.path().join("workspace");
238        let src_dir = root.join("src");
239        let file = src_dir.join("lib.rs");
240
241        fs::create_dir_all(&src_dir).unwrap();
242        fs::write(root.join("Cargo.toml"), "[package]\nname = \"demo\"\n").unwrap();
243        fs::write(&file, "fn main() {}\n").unwrap();
244
245        // Expectations go through the same normalization as production:
246        // bare fs::canonicalize returns verbatim (\\?\) paths on Windows,
247        // which find_workspace_root deliberately strips.
248        let expected_root = crate::inspect::job::canonicalize_normalized(&root);
249        assert_eq!(
250            find_workspace_root(&file, &["Cargo.toml"]),
251            Some(expected_root)
252        );
253    }
254
255    #[test]
256    fn test_find_root_nested() {
257        let temp_dir = tempdir().unwrap();
258        let repo_root = temp_dir.path().join("repo");
259        let crate_root = repo_root.join("crates").join("foo");
260        let src_dir = crate_root.join("src");
261        let file = src_dir.join("lib.rs");
262
263        fs::create_dir_all(&src_dir).unwrap();
264        fs::write(repo_root.join("Cargo.toml"), "[workspace]\n").unwrap();
265        fs::write(crate_root.join("Cargo.toml"), "[package]\nname = \"foo\"\n").unwrap();
266        fs::write(&file, "fn main() {}\n").unwrap();
267
268        let expected_root = crate::inspect::job::canonicalize_normalized(&crate_root);
269        assert_eq!(
270            find_workspace_root(&file, &["Cargo.toml"]),
271            Some(expected_root)
272        );
273    }
274
275    #[test]
276    fn test_find_root_none() {
277        let temp_dir = tempdir().unwrap();
278        let src_dir = temp_dir.path().join("src");
279        let file = src_dir.join("main.rs");
280
281        fs::create_dir_all(&src_dir).unwrap();
282        fs::write(&file, "fn main() {}\n").unwrap();
283
284        assert_eq!(find_workspace_root(&file, &["Cargo.toml"]), None);
285    }
286
287    #[test]
288    fn test_find_root_multiple_markers() {
289        let temp_dir = tempdir().unwrap();
290        let root = temp_dir.path().join("web");
291        let src_dir = root.join("src");
292        let file = src_dir.join("index.ts");
293
294        fs::create_dir_all(&src_dir).unwrap();
295        fs::write(root.join("tsconfig.json"), "{}\n").unwrap();
296        fs::create_dir(root.join("package.json")).unwrap();
297        fs::write(&file, "export {};\n").unwrap();
298
299        let expected_root = crate::inspect::job::canonicalize_normalized(&root);
300        assert_eq!(
301            find_workspace_root(&file, &["tsconfig.json", "package.json"]),
302            Some(expected_root)
303        );
304    }
305
306    #[test]
307    fn test_server_key_equality() {
308        let root = PathBuf::from("/tmp/workspace");
309        let same = ServerKey {
310            kind: ServerKind::Rust,
311            root: root.clone(),
312        };
313        let equal = ServerKey {
314            kind: ServerKind::Rust,
315            root,
316        };
317        let different = ServerKey {
318            kind: ServerKind::Rust,
319            root: PathBuf::from("/tmp/other"),
320        };
321
322        assert_eq!(same, equal);
323        assert_ne!(same, different);
324    }
325
326    /// Regression test for #174: the workspace root returned for a nested file
327    /// must never carry a Windows verbatim (`\\?\`) prefix, because it flows
328    /// into `LspClient::spawn` -> `Command::current_dir`, and `CreateProcess`
329    /// rejects verbatim paths as `lpCurrentDirectory` (every LSP spawn on
330    /// Windows would otherwise fail with "The system cannot find the path
331    /// specified").
332    ///
333    /// This runs on every platform (not `cfg(windows)`-gated) because the
334    /// normalization is platform-independent: on Unix `canonicalize_normalized`
335    /// is identity-equivalent to `fs::canonicalize` plus lexical `.`/`..`
336    /// collapse, so the assertion is a no-op there; on Windows (MSVC CI) it
337    /// asserts the verbatim strip. The byte-equality check against
338    /// `canonicalize_normalized` is the platform-independent property that
339    /// fails locally on macOS if the roots.rs chokepoint is reverted to a bare
340    /// `fs::canonicalize` (whose output diverges from the normalized form only
341    /// on Windows, but the equality contract holds everywhere).
342    #[test]
343    fn test_find_root_strips_windows_verbatim_prefix() {
344        let temp_dir = tempdir().unwrap();
345        let root = temp_dir.path().join("workspace");
346        let src_dir = root.join("src");
347        let nested = src_dir.join("deep").join("lib.rs");
348
349        fs::create_dir_all(nested.parent().unwrap()).unwrap();
350        fs::write(root.join("Cargo.toml"), "[package]\nname = \"demo\"\n").unwrap();
351        fs::write(&nested, "fn main() {}\n").unwrap();
352
353        let found = find_workspace_root(&nested, &["Cargo.toml"]).expect("root found");
354
355        // No verbatim prefix on any platform.
356        let display = found.to_string_lossy();
357        assert!(
358            !display.starts_with("\\\\?\\"),
359            "workspace root must not carry a Windows verbatim prefix: {display}"
360        );
361
362        // Byte-for-byte equality with the shared normalizer for the same
363        // fixture. This is the platform-independent mutation control: reverting
364        // roots.rs to a bare `fs::canonicalize` makes `find_workspace_root`
365        // diverge from `canonicalize_normalized` on Windows, and on Unix the
366        // equality still holds (both reduce to the canonical form), so the
367        // test fails on Windows CI while passing locally on macOS.
368        let expected = canonicalize_normalized(&root);
369        assert_eq!(found, expected);
370    }
371
372    #[test]
373    fn rust_workspace_root_prefers_owning_workspace_over_member_manifest() {
374        let temp_dir = tempdir().unwrap();
375        let workspace = temp_dir.path().join("workspace");
376        let crate_root = workspace.join("crates").join("member");
377        let source = crate_root.join("src").join("lib.rs");
378
379        fs::create_dir_all(source.parent().unwrap()).unwrap();
380        fs::write(
381            workspace.join("Cargo.toml"),
382            "[workspace]\nmembers = [\"crates/member\"]\nresolver = \"2\"\n",
383        )
384        .unwrap();
385        fs::write(
386            crate_root.join("Cargo.toml"),
387            "[package]\nname = \"member\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
388        )
389        .unwrap();
390        fs::write(&source, "pub fn answer() -> u32 { 42 }\n").unwrap();
391
392        assert_eq!(
393            find_rust_workspace_root(&source, Some(&workspace)),
394            Some(canonicalize_normalized(&workspace))
395        );
396    }
397
398    #[test]
399    fn rust_workspace_root_keeps_crate_excluded_from_parent_workspace_standalone() {
400        let temp_dir = tempdir().unwrap();
401        let workspace = temp_dir.path().join("workspace");
402        let member_root = workspace.join("crates").join("member");
403        let standalone_root = workspace.join("tools").join("standalone");
404        let source = standalone_root.join("src").join("lib.rs");
405
406        fs::create_dir_all(member_root.join("src")).unwrap();
407        fs::create_dir_all(source.parent().unwrap()).unwrap();
408        fs::write(
409            workspace.join("Cargo.toml"),
410            "[workspace]\nmembers = [\"crates/member\"]\nresolver = \"2\"\n",
411        )
412        .unwrap();
413        fs::write(
414            member_root.join("Cargo.toml"),
415            "[package]\nname = \"member\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
416        )
417        .unwrap();
418        fs::write(
419            standalone_root.join("Cargo.toml"),
420            "[package]\nname = \"standalone\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
421        )
422        .unwrap();
423        fs::write(&source, "pub fn answer() -> u32 { 42 }\n").unwrap();
424
425        assert_eq!(
426            find_rust_workspace_root(&source, Some(&workspace)),
427            None,
428            "the parent workspace does not list this crate"
429        );
430    }
431
432    #[test]
433    fn rust_workspace_root_does_not_walk_above_project_root() {
434        let temp_dir = tempdir().unwrap();
435        let outer_workspace = temp_dir.path().join("outer-workspace");
436        let project_root = outer_workspace.join("nested-project");
437        let crate_root = project_root.join("crate");
438        let source = crate_root.join("src").join("lib.rs");
439
440        fs::create_dir_all(source.parent().unwrap()).unwrap();
441        fs::write(
442            outer_workspace.join("Cargo.toml"),
443            "[workspace]\nmembers = [\"nested-project/crate\"]\nresolver = \"2\"\n",
444        )
445        .unwrap();
446        fs::write(
447            crate_root.join("Cargo.toml"),
448            "[package]\nname = \"nested\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
449        )
450        .unwrap();
451        fs::write(&source, "pub fn answer() -> u32 { 42 }\n").unwrap();
452        let loose_source = project_root.join("loose.rs");
453        fs::write(&loose_source, "pub fn loose() {}\n").unwrap();
454
455        assert_eq!(
456            find_rust_workspace_root(&source, Some(&project_root)),
457            None,
458            "the enclosing workspace belongs to a different session root"
459        );
460        assert_eq!(
461            find_workspace_root_within(&loose_source, &["Cargo.toml"], Some(&project_root)),
462            None,
463            "marker lookup must not cross the session project root"
464        );
465    }
466}