lanekeep_types/resolve.rs
1//! Turning a module specifier into a file this provider may read.
2//!
3//! Node-shaped and no wider than a declaration lookup needs. Nothing existing is reusable:
4//! `RuleRoot::resolve` is anchored at the rules root and refuses bare specifiers, and the
5//! cross-file rules' `resolveImport` resolves only into the discovered corpus, which never
6//! contains `node_modules` because discovery honors gitignore.
7//!
8//! # Every probe is a tracked read
9//!
10//! Hit or miss, through the caller's [`FileAccess`], so an absent `dist/index.d.ts` is
11//! recorded with a null hash and its later appearance invalidates the importing file's cache
12//! entry. Probe order is fixed, so the recorded dependency list is a function of the input
13//! rather than of which candidate happened to exist.
14//!
15//! # Nothing above the project root, ever
16//!
17//! The walk stops at the root, and the two ways past it fail differently.
18//!
19//! A `node_modules` **hoisted above the root** — a monorepo checked from a package directory —
20//! is never probed at all: the walk simply stops, so no path above the root is read and none
21//! appears in the dependency list. In-root candidates are probed and recorded as absent, the
22//! answer is `None`, and the importing file is reported incomplete.
23//!
24//! A package **symlinked out of the root** — the pnpm store, reached through an in-root
25//! `node_modules/pkg` — *is* probed, because the path names something inside the root. The
26//! read is refused after canonicalizing, and `FileAccess` records the refusal as an absent
27//! dependency, so the day that path becomes a real in-root file the answer that rested on the
28//! refusal is invalidated rather than served forever.
29//!
30//! The remedy for both is the same: point lanekeep at the workspace root — the directory
31//! `node_modules` lives in — rather than at a package inside it. `--config` does not move the
32//! root. It is in `docs/type-aware-rules.md`.
33
34use std::path::Path;
35
36use lanekeep_core::FilePath;
37use lanekeep_core::files::{FileAccess, normalize};
38
39/// Extensions tried for a relative specifier, in order.
40///
41/// The source file before the declaration file: a `.d.ts` beside a `.ts` in one tree is a
42/// build artifact that can be stale, and the source is what the program means. `.tsx` sits
43/// directly after `.ts`, and `/index.tsx` after `/index.ts` — TypeScript's own resolution
44/// order, which reads the source before the declaration file beside it.
45///
46/// **`.tsx` is probed; a `.jsx` *file* remains deliberately absent.** A `.jsx` file is
47/// JavaScript, which this provider does not read at all, so probing it could only record
48/// eight absent reads per import — a `.jsx` *specifier* is another matter, and `relative`
49/// strips it the way it strips `.js`, because it is how a `.tsx` module is imported under
50/// `moduleResolution: node16`. A `.tsx` file is TypeScript — one grammar wider — and is
51/// reached often enough on a React codebase that refusing it made most sibling imports
52/// resolve to nothing, every name they brought in answer `undefined`, and the importing file
53/// `complete() == false`. What a `.tsx` costs is a second *parser*: the provider that cannot
54/// parse JSX honestly — one built with no tsx grammar — still refuses it, one step later than
55/// here, at `walk_export` and `complete()`, which do not read a file in a dialect they have
56/// no grammar for: the importing file is reported incomplete for every name it takes from it,
57/// and every such name answers `undefined`. An honest incompleteness rather than a silent
58/// wrong answer, which is the property the old refusal existed to protect — and a parse in
59/// the wrong dialect is wrong even when it is clean, since `<Foo>bar` is a type assertion to
60/// one grammar and JSX to the other.
61const RELATIVE_SUFFIXES: &[&str] = &[
62 ".ts",
63 ".tsx",
64 ".mts",
65 ".cts",
66 ".d.ts",
67 "/index.ts",
68 "/index.tsx",
69 "/index.d.ts",
70];
71
72/// Resolve `specifier`, written in `from`, to a file inside the project root.
73///
74/// `None` when nothing readable answers it, which is an ordinary result rather than a
75/// failure: the type answer that needed it is then `undefined` and the importing file is
76/// incomplete.
77#[must_use]
78pub fn resolve_specifier(files: &FileAccess, from: &FilePath, specifier: &str) -> Option<FilePath> {
79 if specifier.starts_with("./") || specifier.starts_with("../") {
80 return relative(files, from, specifier);
81 }
82 // A rooted specifier leaves the project by construction and a bare `.` or `..` is not a
83 // module. Neither is probed, so neither is recorded — the same reason `FileAccess` records
84 // only the *symlink* refusal and not a lexical one: a path that can never name something
85 // inside the root is not a dependency any future filesystem state can make relevant.
86 if specifier.starts_with('/') || specifier.starts_with('.') || specifier.is_empty() {
87 return None;
88 }
89 bare(files, from, specifier)
90}
91
92/// A specifier resolved against the importing file's own directory.
93fn relative(files: &FileAccess, from: &FilePath, specifier: &str) -> Option<FilePath> {
94 // TypeScript's ESM spelling names the *emitted* file; the declaration sits at the same
95 // stem. Stripping the suffix here rather than adding four more probe entries keeps the
96 // recorded dependency list short, which is a cache-entry-size decision as much as a
97 // correctness one. `.jsx` is the emitted name of a `.tsx` module — the spelling
98 // `moduleResolution: node16` requires for one — and it strips like `.js` does.
99 let stem = [".js", ".jsx", ".mjs", ".cjs"]
100 .iter()
101 .find_map(|suffix| specifier.strip_suffix(suffix))
102 .unwrap_or(specifier);
103
104 let base = within_root(&join(parent_of(from.as_str()), stem))?;
105 for suffix in RELATIVE_SUFFIXES {
106 let candidate = format!("{base}{suffix}");
107 if files.exists(&candidate).unwrap_or(false) {
108 return Some(FilePath::new(&candidate));
109 }
110 }
111 None
112}
113
114/// Everything before the last `/`, or the empty string for a file at the root.
115fn parent_of(path: &str) -> &str {
116 match path.rfind('/') {
117 Some(at) => &path[..at],
118 None => "",
119 }
120}
121
122/// Join two project-relative fragments with `/`, tolerating an empty left side.
123fn join(left: &str, right: &str) -> String {
124 if left.is_empty() {
125 right.to_owned()
126 } else {
127 format!("{left}/{right}")
128 }
129}
130
131/// Collapse `.` and `..` lexically, refusing anything that ends up above the root.
132///
133/// [`normalize`] keeps a leading `..` as a marker precisely so a caller can see it — see its
134/// own documentation for why a later `..` must not pop that marker. `FileAccess` would refuse
135/// such a path anyway; refusing it here is what keeps it from being *probed*, so an escape
136/// attempt records nothing at all.
137fn within_root(path: &str) -> Option<String> {
138 let normalized = normalize(Path::new(path))
139 .to_string_lossy()
140 .replace('\\', "/");
141 if normalized.is_empty() || normalized == ".." || normalized.starts_with("../") {
142 return None;
143 }
144 Some(normalized)
145}
146
147/// A bare specifier, resolved by walking `node_modules` upward and stopping at the root.
148fn bare(files: &FileAccess, from: &FilePath, specifier: &str) -> Option<FilePath> {
149 let (package, subpath) = split_specifier(specifier)?;
150 let types_package = at_types_name(&package);
151
152 let mut directory = parent_of(from.as_str()).to_owned();
153 loop {
154 for name in [package.as_str(), types_package.as_str()] {
155 let root = join(&directory, &format!("node_modules/{name}"));
156 if let Some(found) = in_package(files, &root, &subpath) {
157 return Some(found);
158 }
159 }
160 if directory.is_empty() {
161 // The project root. Nothing above it is readable, ever.
162 return None;
163 }
164 directory = parent_of(&directory).to_owned();
165 }
166}
167
168/// Split `@scope/name/deep/path` into the package and the subpath after it.
169///
170/// `None` for an empty package name, which is not a specifier any resolver should probe for.
171fn split_specifier(specifier: &str) -> Option<(String, String)> {
172 let scoped = specifier.starts_with('@');
173 let mut parts = specifier.splitn(if scoped { 3 } else { 2 }, '/');
174 let first = parts.next()?;
175 if first.is_empty() {
176 return None;
177 }
178 if scoped {
179 let name = parts.next()?;
180 if name.is_empty() {
181 return None;
182 }
183 Some((
184 format!("{first}/{name}"),
185 parts.next().unwrap_or_default().to_owned(),
186 ))
187 } else {
188 Some((
189 first.to_owned(),
190 parts.next().unwrap_or_default().to_owned(),
191 ))
192 }
193}
194
195/// The DefinitelyTyped package for a name: `@scope/x` flattens to `@types/scope__x`.
196fn at_types_name(package: &str) -> String {
197 match package.strip_prefix('@') {
198 Some(rest) => format!("@types/{}", rest.replacen('/', "__", 1)),
199 None => format!("@types/{package}"),
200 }
201}
202
203/// Find a declaration file inside one package directory.
204///
205/// `exports` first, then `types`, then `typings`, then the conventional index. That is the
206/// order TypeScript itself resolves in, and a fixed order is what makes the recorded
207/// dependency list a function of the input rather than of which file happened to exist.
208fn in_package(files: &FileAccess, root: &str, subpath: &str) -> Option<FilePath> {
209 if let Ok(Some(text)) = files.read(&join(root, "package.json"))
210 && let Ok(manifest) = serde_json::from_str::<serde_json::Value>(&text)
211 {
212 if let Some(target) = exports_target(&manifest, subpath)
213 && let Some(found) = candidate(files, root, &target)
214 {
215 return Some(found);
216 }
217 if subpath.is_empty() {
218 for field in ["types", "typings"] {
219 if let Some(target) = manifest.get(field).and_then(serde_json::Value::as_str)
220 && let Some(found) = candidate(files, root, target)
221 {
222 return Some(found);
223 }
224 }
225 }
226 }
227
228 // No manifest, or a manifest that says nothing about types. A package without one still
229 // ships `index.d.ts` far more often than not, and `@types/*` packages ship nothing else.
230 let fallback = if subpath.is_empty() {
231 "index.d.ts".to_owned()
232 } else {
233 format!("{subpath}/index.d.ts")
234 };
235 candidate(files, root, &fallback)
236}
237
238/// Probe one target inside a package directory.
239///
240/// The extra spellings are tried only for a target with no `.ts` extension, which keeps a
241/// `"types": "./index.d.ts"` to a single recorded read rather than three. Entry size is the
242/// reason: every probe is a dependency, and a package resolved on every file that imports it
243/// multiplies whatever this costs.
244fn candidate(files: &FileAccess, root: &str, target: &str) -> Option<FilePath> {
245 let target = target.strip_prefix("./").unwrap_or(target);
246 let mut spellings = vec![target.to_owned()];
247 if !Path::new(target)
248 .extension()
249 .is_some_and(|ext| ext.eq_ignore_ascii_case("ts"))
250 {
251 spellings.push(format!("{target}.d.ts"));
252 spellings.push(format!("{target}/index.d.ts"));
253 }
254 for spelling in spellings {
255 let Some(path) = within_root(&join(root, &spelling)) else {
256 continue;
257 };
258 if files.exists(&path).unwrap_or(false) {
259 return Some(FilePath::new(&path));
260 }
261 }
262 None
263}
264
265/// The `exports` entry for a subpath, read through the `types` condition.
266///
267/// Exact keys before `*` patterns, and the longest literal prefix before a shorter one, which
268/// is what Node itself specifies — `./deep/*` has to beat `./*` or a package that publishes
269/// both resolves to the wrong file.
270fn exports_target(manifest: &serde_json::Value, subpath: &str) -> Option<String> {
271 let exports = manifest.get("exports")?;
272 let key = if subpath.is_empty() {
273 ".".to_owned()
274 } else {
275 format!("./{subpath}")
276 };
277
278 // A string, or an object with no subpath keys at all, is the `.` entry written short.
279 let subpaths = exports
280 .as_object()
281 .is_some_and(|map| map.keys().any(|k| k.starts_with('.')));
282 if !subpaths {
283 return if key == "." {
284 types_condition(exports)
285 } else {
286 None
287 };
288 }
289
290 let map = exports.as_object()?;
291 if let Some(target) = map.get(&key).and_then(types_condition) {
292 return Some(target);
293 }
294
295 let mut patterns: Vec<(&String, &serde_json::Value)> =
296 map.iter().filter(|(k, _)| k.contains('*')).collect();
297 // Longest key first, and the key itself as the tiebreak so two keys of one length cannot
298 // depend on iteration order. `serde_json`'s `Map` is a `BTreeMap` here — the workspace
299 // does not enable `preserve_order` — so the input order is already sorted, and this makes
300 // the dependence on that explicit rather than assumed.
301 patterns.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then_with(|| a.0.cmp(b.0)));
302 for (pattern, value) in patterns {
303 if let Some(matched) = star_match(pattern, &key)
304 && let Some(target) = types_condition(value)
305 {
306 return Some(target.replace('*', &matched));
307 }
308 }
309 None
310}
311
312/// The `types` condition of an export entry, however deeply it is nested.
313///
314/// Only `types`. A package that publishes its declarations solely under `default` or `import`
315/// resolves to nothing here, which is the honest answer for a resolver that cannot read
316/// JavaScript: following `default` would hand this provider a `.js` file to parse as
317/// TypeScript, and the cost of that mistake is a confidently wrong type rather than none.
318///
319/// The nested search iterates a `BTreeMap` — see [`exports_target`] — so a manifest with two
320/// nested condition objects resolves the same way on every run.
321fn types_condition(value: &serde_json::Value) -> Option<String> {
322 match value {
323 serde_json::Value::String(target) => Some(target.clone()),
324 serde_json::Value::Object(map) => map.get("types").and_then(under_types).or_else(|| {
325 map.values().find_map(|nested| match nested {
326 serde_json::Value::Object(_) => types_condition(nested),
327 _ => None,
328 })
329 }),
330 _ => None,
331 }
332}
333
334/// The file named *under* a `types` condition, which may itself be a conditions object.
335///
336/// `{"types": {"import": "./index.d.mts", "require": "./index.d.ts"}}` is the shape a package
337/// shipping both module systems publishes. Once inside `types`, every leaf is a declaration
338/// file whatever condition names it, so a string leaf is accepted here where
339/// [`types_condition`] refuses one — outside `types`, `default` and `import` name the emitted
340/// JavaScript, and following one would hand this provider a `.js` file to parse as TypeScript.
341///
342/// The order is the map's own: `serde_json`'s `Map` is a `BTreeMap` here, so the conditions are
343/// visited in sorted key order and a package publishing several resolves the same way on every
344/// run. Which one is chosen is arbitrary in the sense that Node would consult the *importer*'s
345/// module system to decide; both name declarations for the same package, and a fixed choice is
346/// what keeps the recorded dependency list a function of the input.
347fn under_types(value: &serde_json::Value) -> Option<String> {
348 match value {
349 serde_json::Value::String(target) => Some(target.clone()),
350 serde_json::Value::Object(map) => map
351 .get("types")
352 .and_then(under_types)
353 .or_else(|| map.values().find_map(under_types)),
354 _ => None,
355 }
356}
357
358/// What `*` stood for, when `pattern` matches `key`.
359fn star_match(pattern: &str, key: &str) -> Option<String> {
360 let (head, tail) = pattern.split_once('*')?;
361 let rest = key.strip_prefix(head)?;
362 Some(rest.strip_suffix(tail)?.to_owned())
363}