ts_bridge/provider/
mod.rs1use std::fs;
10use std::path::{Path, PathBuf};
11
12use serde_json::Value;
13
14const MAX_NESTED_SEARCH_DEPTH: usize = 4;
15
16#[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#[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 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 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(); candidate.pop(); 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()? .parent()? .parent()? .parent()?; 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}