1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
//! Module resolution utilities for multi-file type checking.
//!
//! This module provides shared utilities for building cross-file module
//! resolution context. Used by both the CLI and the tsz-server.
//!
//! Supports the following import forms:
//! - ES imports: `import { x } from "./module"`
//! - Require: `const x = require("./module")`
//! - Import equals: `import x = require("./module")`
//! - Dynamic import: `const x = await import("./module")`
//! - Re-exports: `export { x } from "./module"`
//!
//! All of these ultimately resolve against the same specifier-to-file mapping
//! built by `build_module_resolution_maps`.
use rustc_hash::FxHashMap;
use rustc_hash::FxHashSet;
use std::path::Path;
/// TypeScript file extensions in resolution priority order.
/// `.d.ts` must be checked before `.ts` to avoid `.d` being left as a stem artifact.
const TS_EXTENSIONS: &[&str] = &[
".d.ts", ".d.tsx", ".d.mts", ".d.cts", ".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs",
".cjs",
];
/// Strip a known TypeScript/JavaScript extension from a file path string.
/// Returns the path without the extension, or the original string if no known extension matched.
fn strip_ts_extension(path: &str) -> &str {
for ext in TS_EXTENSIONS {
if let Some(stripped) = path.strip_suffix(ext) {
return stripped;
}
}
path
}
/// Compute a relative path from `from_dir` to `to_path`, returning a string
/// suitable for use as a module specifier (with `./` or `../` prefix).
///
/// Returns `None` if a relative path cannot be computed (e.g., different drive roots on Windows).
fn relative_specifier(from_dir: &Path, to_path: &Path) -> Option<String> {
// Try the simple case: target is inside from_dir
if let Ok(rel) = to_path.strip_prefix(from_dir) {
let rel_str = rel.to_string_lossy();
let without_ext = strip_ts_extension(&rel_str);
return Some(format!("./{without_ext}"));
}
// Walk up from from_dir to find a common ancestor
let mut up_count = 0;
let mut ancestor = from_dir;
loop {
match ancestor.parent() {
Some(parent) => {
up_count += 1;
if let Ok(rel) = to_path.strip_prefix(parent) {
let rel_str = rel.to_string_lossy();
let without_ext = strip_ts_extension(&rel_str);
let prefix = "../".repeat(up_count);
return Some(format!("{prefix}{without_ext}"));
}
ancestor = parent;
}
None => return None,
}
}
}
/// Build module resolution maps from a list of file paths.
///
/// Returns:
/// - `resolved_module_paths`: Maps (`source_file_idx`, specifier) -> `target_file_idx`
/// - `resolved_modules`: Set of all valid module specifiers
///
/// This handles relative imports between files in the same project.
/// For example, if we have files `/tmp/test/main.ts` and `/tmp/test/types.ts`,
/// then from `main.ts`, the specifier `./types` will resolve to `types.ts`.
///
/// Also handles:
/// - Nested paths: `./lib/utils` resolving to `lib/utils.ts`
/// - Parent directory: `../sibling` resolving to `../sibling.ts`
/// - Index files: `./dir` resolving to `dir/index.ts`
/// - Declaration files: `./types` resolving to `types.d.ts`
/// - All TS/JS extensions: `.ts`, `.tsx`, `.d.ts`, `.js`, `.jsx`, `.mts`, `.cts`, etc.
pub fn build_module_resolution_maps(
file_names: &[String],
) -> (FxHashMap<(usize, String), usize>, FxHashSet<String>) {
let mut resolved_module_paths: FxHashMap<(usize, String), usize> = FxHashMap::default();
let mut resolved_modules: FxHashSet<String> = FxHashSet::default();
// Build a map from extensionless path -> file index for index file resolution
let mut stem_to_idx: FxHashMap<String, usize> = FxHashMap::default();
for (idx, name) in file_names.iter().enumerate() {
let without_ext = strip_ts_extension(name);
stem_to_idx.insert(without_ext.to_string(), idx);
}
for (src_idx, src_name) in file_names.iter().enumerate() {
let src_path = Path::new(src_name);
let Some(src_dir) = src_path.parent() else {
continue;
};
for (tgt_idx, tgt_name) in file_names.iter().enumerate() {
if src_idx == tgt_idx {
continue;
}
let tgt_path = Path::new(tgt_name);
// Compute the relative specifier from source directory to target file
if let Some(specifier) = relative_specifier(src_dir, tgt_path) {
// Register the specifier with ./ or ../ prefix
resolved_module_paths.insert((src_idx, specifier.clone()), tgt_idx);
resolved_modules.insert(specifier.clone());
// Also register without ./ prefix for bare relative specifiers
// (e.g., "types" in addition to "./types")
if let Some(bare) = specifier.strip_prefix("./") {
resolved_module_paths.insert((src_idx, bare.to_string()), tgt_idx);
resolved_modules.insert(bare.to_string());
}
}
// Index file resolution: if target is `dir/index.ts`, also register `./dir`
let tgt_stem = strip_ts_extension(tgt_name);
if let Some(dir_path) = tgt_stem.strip_suffix("/index") {
let dir_as_path = Path::new(dir_path);
if let Some(dir_specifier) = relative_specifier(src_dir, dir_as_path) {
resolved_module_paths.insert((src_idx, dir_specifier.clone()), tgt_idx);
resolved_modules.insert(dir_specifier.clone());
if let Some(bare) = dir_specifier.strip_prefix("./") {
resolved_module_paths.insert((src_idx, bare.to_string()), tgt_idx);
resolved_modules.insert(bare.to_string());
}
}
}
}
}
(resolved_module_paths, resolved_modules)
}
/// Build canonical lookup keys for a module specifier.
///
/// Invariant: every cross-module map lookup in checker code should go through
/// this function to avoid divergent quoting/slash normalization behavior.
pub fn module_specifier_candidates(specifier: &str) -> Vec<String> {
let mut candidates = Vec::with_capacity(5);
let mut push_unique = |value: String| {
if !candidates.contains(&value) {
candidates.push(value);
}
};
push_unique(specifier.to_string());
let trimmed = specifier.trim().trim_matches('"').trim_matches('\'');
if trimmed != specifier {
push_unique(trimmed.to_string());
}
if !trimmed.is_empty() {
push_unique(format!("\"{trimmed}\""));
push_unique(format!("'{trimmed}'"));
if trimmed.contains('\\') {
push_unique(trimmed.replace('\\', "/"));
}
}
candidates
}
#[cfg(test)]
#[path = "../tests/module_resolution.rs"]
mod tests;