ts_bridge/provider/
mod.rs

1//! =============================================================================
2//! Tsserver Provider
3//! =============================================================================
4//!
5//! Responsible for locating `tsserver.js` (local node_modules, Yarn SDK, and
6//! PATH/global fallbacks) and reporting metadata (TypeScript version,
7//! plugin probe location).
8
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use serde_json::Value;
13
14const MAX_NESTED_SEARCH_DEPTH: usize = 4;
15
16/// Captures everything needed to spawn a tsserver instance.
17#[derive(Debug, Clone)]
18pub struct TsserverBinary {
19    pub executable: PathBuf,
20    pub plugin_probe: Option<PathBuf>,
21    pub version: Option<String>,
22    pub source: BinarySource,
23}
24
25impl TsserverBinary {
26    fn new(executable: PathBuf, plugin_probe: Option<PathBuf>, source: BinarySource) -> Self {
27        let version = infer_version(&executable);
28        Self {
29            executable,
30            plugin_probe,
31            version,
32            source,
33        }
34    }
35}
36
37#[derive(Debug, Clone, Copy)]
38pub enum BinarySource {
39    LocalNodeModules,
40    YarnSdk,
41    GlobalPath,
42}
43
44/// Caching the workspace root and lazily resolving
45/// binaries when the RPC service boots up.
46#[derive(Debug)]
47pub struct Provider {
48    workspace_root: PathBuf,
49}
50
51impl Provider {
52    pub fn new(workspace_root: impl Into<PathBuf>) -> Self {
53        let root = workspace_root
54            .into()
55            .canonicalize()
56            .unwrap_or_else(|_| PathBuf::from("."));
57        Self {
58            workspace_root: root,
59        }
60    }
61
62    /// Resolves the tsserver binary by inspecting (in order):
63    /// 1. `node_modules/typescript/lib/tsserver.js` in workspace ancestors.
64    /// 2. `.yarn/sdks/typescript/lib/tsserver.js` in ancestors.
65    /// 3. `tsserver` on PATH (via `which`).
66    pub fn resolve(&mut self) -> Result<TsserverBinary, ProviderError> {
67        if let Some(path) = self.find_local_node_modules() {
68            self.reanchor_workspace_root(&path);
69            let plugin_probe = path
70                .parent()
71                .and_then(|lib| lib.parent())
72                .and_then(|ts| ts.parent())
73                .map(Path::to_path_buf);
74            return Ok(TsserverBinary::new(
75                path,
76                plugin_probe,
77                BinarySource::LocalNodeModules,
78            ));
79        }
80
81        if let Some(path) = self.find_yarn_sdk() {
82            self.reanchor_workspace_root(&path);
83            let plugin_probe = path
84                .parent()
85                .and_then(|lib| lib.parent())
86                .and_then(|ts| ts.parent())
87                .map(Path::to_path_buf);
88            return Ok(TsserverBinary::new(
89                path,
90                plugin_probe,
91                BinarySource::YarnSdk,
92            ));
93        }
94
95        if let Some(path) = self.find_global_tsserver()? {
96            return Ok(TsserverBinary::new(path, None, BinarySource::GlobalPath));
97        }
98
99        Err(ProviderError::NotFound {
100            root: self.workspace_root.clone(),
101        })
102    }
103
104    fn find_local_node_modules(&self) -> Option<PathBuf> {
105        find_upwards(
106            &self.workspace_root,
107            &["node_modules", "typescript", "lib", "tsserver.js"],
108        )
109        .or_else(|| {
110            find_nested_match(
111                &self.workspace_root,
112                &["node_modules", "typescript", "lib", "tsserver.js"],
113                MAX_NESTED_SEARCH_DEPTH,
114            )
115        })
116    }
117
118    fn find_yarn_sdk(&self) -> Option<PathBuf> {
119        find_upwards(
120            &self.workspace_root,
121            &[".yarn", "sdks", "typescript", "lib", "tsserver.js"],
122        )
123        .or_else(|| {
124            find_nested_match(
125                &self.workspace_root,
126                &[".yarn", "sdks", "typescript", "lib", "tsserver.js"],
127                MAX_NESTED_SEARCH_DEPTH,
128            )
129        })
130    }
131
132    fn find_global_tsserver(&self) -> Result<Option<PathBuf>, ProviderError> {
133        match which::which("tsserver") {
134            Ok(path) => {
135                // Some distributions expose a wrapper script; if so try to backtrack to the JS file.
136                if path.file_name().and_then(|f| f.to_str()) == Some("tsserver.js") {
137                    Ok(Some(path))
138                } else {
139                    Ok(transform_wrapper_to_js(path))
140                }
141            }
142            Err(which::Error::CannotFindBinaryPath) => Ok(None),
143            Err(err) => Err(ProviderError::PathLookup(err)),
144        }
145    }
146
147    pub fn workspace_root(&self) -> &Path {
148        &self.workspace_root
149    }
150
151    fn reanchor_workspace_root(&mut self, tsserver_js: &Path) {
152        let Some(mut project_root) = project_root_from_tsserver(tsserver_js) else {
153            return;
154        };
155
156        let should_update = self.workspace_root.starts_with(&project_root)
157            || project_root.starts_with(&self.workspace_root);
158        if !should_update {
159            return;
160        }
161
162        if let Ok(canonical) = project_root.canonicalize() {
163            project_root = canonical;
164        }
165
166        self.workspace_root = project_root;
167    }
168}
169
170#[derive(thiserror::Error, Debug)]
171pub enum ProviderError {
172    #[error("unable to locate tsserver starting at {root:?}")]
173    NotFound { root: PathBuf },
174    #[error("failed to invoke `which tsserver`: {0}")]
175    PathLookup(which::Error),
176}
177
178fn transform_wrapper_to_js(wrapper: PathBuf) -> Option<PathBuf> {
179    let mut candidate = wrapper.clone();
180    candidate.pop(); // drop tsserver filename
181    candidate.pop(); // drop bin/
182    candidate.push("lib");
183    candidate.push("node_modules");
184    candidate.push("typescript");
185    candidate.push("lib");
186    candidate.push("tsserver.js");
187    candidate.canonicalize().ok().filter(|path| path.exists())
188}
189
190fn find_upwards(start: &Path, segments: &[&str]) -> Option<PathBuf> {
191    for ancestor in start.ancestors() {
192        let candidate = segments
193            .iter()
194            .fold(PathBuf::from(ancestor), |mut acc, segment| {
195                acc.push(segment);
196                acc
197            });
198
199        if candidate.is_file() {
200            return Some(candidate);
201        }
202    }
203
204    None
205}
206
207fn infer_version(tsserver: &Path) -> Option<String> {
208    let lib_dir = tsserver.parent()?;
209    let ts_dir = lib_dir.parent()?;
210    let package_json = ts_dir.join("package.json");
211    let contents = fs::read_to_string(package_json).ok()?;
212    let json: Value = serde_json::from_str(&contents).ok()?;
213    json.get("version")
214        .and_then(|v| v.as_str())
215        .map(|s| s.to_string())
216}
217
218fn project_root_from_tsserver(tsserver: &Path) -> Option<PathBuf> {
219    let project = tsserver
220        .parent()? // lib
221        .parent()? // typescript
222        .parent()? // node_modules or sdks
223        .parent()?; // project root
224    Some(project.to_path_buf())
225}
226
227fn find_nested_match(start: &Path, segments: &[&str], max_depth: usize) -> Option<PathBuf> {
228    fn helper(dir: &Path, segments: &[&str], depth: usize, max_depth: usize) -> Option<PathBuf> {
229        if depth > max_depth {
230            return None;
231        }
232
233        let candidate = segments
234            .iter()
235            .fold(PathBuf::from(dir), |mut acc, segment| {
236                acc.push(segment);
237                acc
238            });
239        if candidate.is_file() {
240            return Some(candidate);
241        }
242        if depth == max_depth {
243            return None;
244        }
245
246        let entries = match fs::read_dir(dir) {
247            Ok(entries) => entries,
248            Err(_) => return None,
249        };
250
251        for entry in entries.flatten() {
252            let Ok(file_type) = entry.file_type() else {
253                continue;
254            };
255            if !file_type.is_dir() || file_type.is_symlink() {
256                continue;
257            }
258            let name = entry.file_name();
259            if let Some(name_str) = name.to_str() {
260                if should_skip_dir(name_str) {
261                    continue;
262                }
263            }
264            if let Some(found) = helper(&entry.path(), segments, depth.saturating_add(1), max_depth)
265            {
266                return Some(found);
267            }
268        }
269
270        None
271    }
272
273    helper(start, segments, 0, max_depth)
274}
275
276fn should_skip_dir(name: &str) -> bool {
277    matches!(
278        name,
279        "node_modules"
280            | ".git"
281            | "target"
282            | "dist"
283            | "build"
284            | ".next"
285            | ".turbo"
286            | ".pnpm"
287            | "vendor"
288    )
289}