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    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
76/// Find the Cargo workspace that owns `file_path`.
77///
78/// Rust Analyzer loads the complete Cargo workspace supplied by the client. A
79/// member crate's manifest is therefore not a suitable server root: opening a
80/// second member would otherwise start another full-workspace analyzer. The
81/// optional project root bounds the walk so an enclosing checkout cannot claim
82/// a nested session as one of its workspaces.
83pub 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        // Cargo permits a package to be colocated with a `[workspace]` table
174        // without listing the package in `members`. Prefer the nearest ancestor
175        // manifest that defines a workspace because it still describes the
176        // broader Cargo workspace for the analyzer.
177        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/// Composite key for caching server instances.
188/// Each unique (ServerKind, workspace_root) pair gets its own server process.
189#[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        // Expectations go through the same normalization as production:
220        // bare fs::canonicalize returns verbatim (\\?\) paths on Windows,
221        // which find_workspace_root deliberately strips.
222        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    /// Regression test for #174: the workspace root returned for a nested file
301    /// must never carry a Windows verbatim (`\\?\`) prefix, because it flows
302    /// into `LspClient::spawn` -> `Command::current_dir`, and `CreateProcess`
303    /// rejects verbatim paths as `lpCurrentDirectory` (every LSP spawn on
304    /// Windows would otherwise fail with "The system cannot find the path
305    /// specified").
306    ///
307    /// This runs on every platform (not `cfg(windows)`-gated) because the
308    /// normalization is platform-independent: on Unix `canonicalize_normalized`
309    /// is identity-equivalent to `fs::canonicalize` plus lexical `.`/`..`
310    /// collapse, so the assertion is a no-op there; on Windows (MSVC CI) it
311    /// asserts the verbatim strip. The byte-equality check against
312    /// `canonicalize_normalized` is the platform-independent property that
313    /// fails locally on macOS if the roots.rs chokepoint is reverted to a bare
314    /// `fs::canonicalize` (whose output diverges from the normalized form only
315    /// on Windows, but the equality contract holds everywhere).
316    #[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        // No verbatim prefix on any platform.
330        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        // Byte-for-byte equality with the shared normalizer for the same
337        // fixture. This is the platform-independent mutation control: reverting
338        // roots.rs to a bare `fs::canonicalize` makes `find_workspace_root`
339        // diverge from `canonicalize_normalized` on Windows, and on Unix the
340        // equality still holds (both reduce to the canonical form), so the
341        // test fails on Windows CI while passing locally on macOS.
342        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}