Skip to main content

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}