Skip to main content

aft/lsp/
roots.rs

1use std::path::{Path, PathBuf};
2
3use crate::lsp::registry::ServerKind;
4
5pub fn find_workspace_root<S>(file_path: &Path, markers: &[S]) -> Option<PathBuf>
6where
7    S: AsRef<str>,
8{
9    // Route canonicalization through `canonicalize_normalized` so the returned
10    // root is never a Windows verbatim (`\\?\C:\...`) path. This root flows into
11    // `LspClient::spawn` -> `Command::current_dir(&root)`, and `CreateProcess`
12    // rejects extended-length verbatim paths as `lpCurrentDirectory` (documented
13    // Win32 limitation: "The lpCurrentDirectory string ... must not be a \\?\
14    // prefixed path"). Without this strip EVERY LSP spawn on Windows fails
15    // with "The system cannot find the path specified" and aft_inspect reports
16    // servers as not installed (#174). On Unix `canonicalize_normalized` is
17    // identity-equivalent to `fs::canonicalize` followed by lexical `.`/`..`
18    // collapse, so non-Windows behavior is unchanged.
19    // `canonicalize_normalized` falls back to lexical `.`/`..` collapse when
20    // `fs::canonicalize` fails (e.g. the file is gone), matching the prior
21    // fallback to the raw `file_path` for paths without `.`/`..` components.
22    let resolved_path = crate::inspect::job::canonicalize_normalized(file_path);
23
24    let start_dir = if resolved_path.is_dir() {
25        resolved_path
26    } else {
27        resolved_path.parent()?.to_path_buf()
28    };
29
30    let mut current = Some(start_dir.as_path());
31    while let Some(dir) = current {
32        if markers
33            .iter()
34            .any(|marker| dir.join(marker.as_ref()).exists())
35        {
36            return Some(dir.to_path_buf());
37        }
38
39        current = dir.parent();
40    }
41
42    None
43}
44
45/// Composite key for caching server instances.
46/// Each unique (ServerKind, workspace_root) pair gets its own server process.
47#[derive(Debug, Clone, PartialEq, Eq, Hash)]
48pub struct ServerKey {
49    pub kind: ServerKind,
50    pub root: PathBuf,
51}
52
53#[cfg(test)]
54mod tests {
55    use std::fs;
56    use std::path::PathBuf;
57
58    use tempfile::tempdir;
59
60    use super::{find_workspace_root, ServerKey};
61    use crate::inspect::job::canonicalize_normalized;
62    use crate::lsp::registry::ServerKind;
63
64    #[test]
65    fn test_find_root_with_cargo_toml() {
66        let temp_dir = tempdir().unwrap();
67        let root = temp_dir.path().join("workspace");
68        let src_dir = root.join("src");
69        let file = src_dir.join("lib.rs");
70
71        fs::create_dir_all(&src_dir).unwrap();
72        fs::write(root.join("Cargo.toml"), "[package]\nname = \"demo\"\n").unwrap();
73        fs::write(&file, "fn main() {}\n").unwrap();
74
75        // Expectations go through the same normalization as production:
76        // bare fs::canonicalize returns verbatim (\\?\) paths on Windows,
77        // which find_workspace_root deliberately strips.
78        let expected_root = crate::inspect::job::canonicalize_normalized(&root);
79        assert_eq!(
80            find_workspace_root(&file, &["Cargo.toml"]),
81            Some(expected_root)
82        );
83    }
84
85    #[test]
86    fn test_find_root_nested() {
87        let temp_dir = tempdir().unwrap();
88        let repo_root = temp_dir.path().join("repo");
89        let crate_root = repo_root.join("crates").join("foo");
90        let src_dir = crate_root.join("src");
91        let file = src_dir.join("lib.rs");
92
93        fs::create_dir_all(&src_dir).unwrap();
94        fs::write(repo_root.join("Cargo.toml"), "[workspace]\n").unwrap();
95        fs::write(crate_root.join("Cargo.toml"), "[package]\nname = \"foo\"\n").unwrap();
96        fs::write(&file, "fn main() {}\n").unwrap();
97
98        let expected_root = crate::inspect::job::canonicalize_normalized(&crate_root);
99        assert_eq!(
100            find_workspace_root(&file, &["Cargo.toml"]),
101            Some(expected_root)
102        );
103    }
104
105    #[test]
106    fn test_find_root_none() {
107        let temp_dir = tempdir().unwrap();
108        let src_dir = temp_dir.path().join("src");
109        let file = src_dir.join("main.rs");
110
111        fs::create_dir_all(&src_dir).unwrap();
112        fs::write(&file, "fn main() {}\n").unwrap();
113
114        assert_eq!(find_workspace_root(&file, &["Cargo.toml"]), None);
115    }
116
117    #[test]
118    fn test_find_root_multiple_markers() {
119        let temp_dir = tempdir().unwrap();
120        let root = temp_dir.path().join("web");
121        let src_dir = root.join("src");
122        let file = src_dir.join("index.ts");
123
124        fs::create_dir_all(&src_dir).unwrap();
125        fs::write(root.join("tsconfig.json"), "{}\n").unwrap();
126        fs::create_dir(root.join("package.json")).unwrap();
127        fs::write(&file, "export {};\n").unwrap();
128
129        let expected_root = crate::inspect::job::canonicalize_normalized(&root);
130        assert_eq!(
131            find_workspace_root(&file, &["tsconfig.json", "package.json"]),
132            Some(expected_root)
133        );
134    }
135
136    #[test]
137    fn test_server_key_equality() {
138        let root = PathBuf::from("/tmp/workspace");
139        let same = ServerKey {
140            kind: ServerKind::Rust,
141            root: root.clone(),
142        };
143        let equal = ServerKey {
144            kind: ServerKind::Rust,
145            root,
146        };
147        let different = ServerKey {
148            kind: ServerKind::Rust,
149            root: PathBuf::from("/tmp/other"),
150        };
151
152        assert_eq!(same, equal);
153        assert_ne!(same, different);
154    }
155
156    /// Regression test for #174: the workspace root returned for a nested file
157    /// must never carry a Windows verbatim (`\\?\`) prefix, because it flows
158    /// into `LspClient::spawn` -> `Command::current_dir`, and `CreateProcess`
159    /// rejects verbatim paths as `lpCurrentDirectory` (every LSP spawn on
160    /// Windows would otherwise fail with "The system cannot find the path
161    /// specified").
162    ///
163    /// This runs on every platform (not `cfg(windows)`-gated) because the
164    /// normalization is platform-independent: on Unix `canonicalize_normalized`
165    /// is identity-equivalent to `fs::canonicalize` plus lexical `.`/`..`
166    /// collapse, so the assertion is a no-op there; on Windows (MSVC CI) it
167    /// asserts the verbatim strip. The byte-equality check against
168    /// `canonicalize_normalized` is the platform-independent property that
169    /// fails locally on macOS if the roots.rs chokepoint is reverted to a bare
170    /// `fs::canonicalize` (whose output diverges from the normalized form only
171    /// on Windows, but the equality contract holds everywhere).
172    #[test]
173    fn test_find_root_strips_windows_verbatim_prefix() {
174        let temp_dir = tempdir().unwrap();
175        let root = temp_dir.path().join("workspace");
176        let src_dir = root.join("src");
177        let nested = src_dir.join("deep").join("lib.rs");
178
179        fs::create_dir_all(nested.parent().unwrap()).unwrap();
180        fs::write(root.join("Cargo.toml"), "[package]\nname = \"demo\"\n").unwrap();
181        fs::write(&nested, "fn main() {}\n").unwrap();
182
183        let found = find_workspace_root(&nested, &["Cargo.toml"]).expect("root found");
184
185        // No verbatim prefix on any platform.
186        let display = found.to_string_lossy();
187        assert!(
188            !display.starts_with("\\\\?\\"),
189            "workspace root must not carry a Windows verbatim prefix: {display}"
190        );
191
192        // Byte-for-byte equality with the shared normalizer for the same
193        // fixture. This is the platform-independent mutation control: reverting
194        // roots.rs to a bare `fs::canonicalize` makes `find_workspace_root`
195        // diverge from `canonicalize_normalized` on Windows, and on Unix the
196        // equality still holds (both reduce to the canonical form), so the
197        // test fails on Windows CI while passing locally on macOS.
198        let expected = canonicalize_normalized(&root);
199        assert_eq!(found, expected);
200    }
201}