1use crate::resolution_keys::strip_known_source_extension;
4use std::error::Error;
5use std::fmt;
6
7pub const MAX_CONFIGURED_MODULE_CONFIGS: usize = 1_024;
9pub const MAX_CONFIGURED_MODULE_MAPPINGS: usize = 4_096;
11pub const MAX_CONFIGURED_MODULE_TARGETS: usize = 64;
13pub const MAX_CONFIGURED_MODULE_IDENTITY_BYTES: usize = 1_024;
15
16#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
18pub enum EcmaScriptConfigKind {
19 TypeScript,
21 JavaScript,
23}
24
25#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
27pub struct EcmaScriptPathMapping {
28 pattern: String,
30 targets: Vec<String>,
32}
33
34impl EcmaScriptPathMapping {
35 pub fn new(
42 pattern: impl Into<String>,
43 targets: Vec<String>,
44 ) -> Result<Self, ConfiguredModuleError> {
45 let pattern = pattern.into();
46 validate_pattern("module pattern", &pattern)?;
47 if targets.is_empty() || targets.len() > MAX_CONFIGURED_MODULE_TARGETS {
48 return Err(ConfiguredModuleError::TargetCount {
49 requested: targets.len(),
50 });
51 }
52 let mut targets = targets;
53 for target in &targets {
54 validate_repository_pattern("module target", target)?;
55 }
56 targets.sort();
57 targets.dedup();
58 Ok(Self { pattern, targets })
59 }
60}
61
62#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
64pub struct EcmaScriptModuleConfig {
65 config_path: String,
67 directory: String,
69 kind: EcmaScriptConfigKind,
71 base_url: Option<String>,
73 mappings: Vec<EcmaScriptPathMapping>,
75}
76
77impl EcmaScriptModuleConfig {
78 pub fn new(
88 config_path: impl Into<String>,
89 kind: EcmaScriptConfigKind,
90 base_url: Option<String>,
91 mut mappings: Vec<EcmaScriptPathMapping>,
92 ) -> Result<Self, ConfiguredModuleError> {
93 let config_path = config_path.into();
94 validate_repository_path("configuration path", &config_path)?;
95 let expected_name = match kind {
96 EcmaScriptConfigKind::TypeScript => "tsconfig.json",
97 EcmaScriptConfigKind::JavaScript => "jsconfig.json",
98 };
99 if config_path.rsplit('/').next() != Some(expected_name) {
100 return Err(ConfiguredModuleError::InvalidIdentity {
101 field: "configuration path",
102 value: config_path,
103 });
104 }
105 if let Some(base_url) = base_url.as_deref() {
106 validate_repository_path_allow_root("baseUrl", base_url)?;
107 }
108 mappings.sort();
109 mappings.dedup();
110 let directory = config_path
111 .rsplit_once('/')
112 .map_or_else(String::new, |(directory, _)| directory.to_string());
113 Ok(Self {
114 config_path,
115 directory,
116 kind,
117 base_url,
118 mappings,
119 })
120 }
121}
122
123#[derive(Clone, Debug, Default, Eq, PartialEq)]
125pub struct ConfiguredModuleResolution {
126 configs: Vec<EcmaScriptModuleConfig>,
128}
129
130#[derive(Clone, Copy, Debug)]
132pub(crate) struct ConfiguredModuleSource<'a> {
133 config: Option<&'a EcmaScriptModuleConfig>,
135}
136
137impl ConfiguredModuleResolution {
138 pub fn new(mut configs: Vec<EcmaScriptModuleConfig>) -> Result<Self, ConfiguredModuleError> {
145 if configs.len() > MAX_CONFIGURED_MODULE_CONFIGS {
146 return Err(ConfiguredModuleError::ConfigCount {
147 requested: configs.len(),
148 });
149 }
150 let mapping_count = configs
151 .iter()
152 .map(|config| config.mappings.len())
153 .try_fold(0_usize, usize::checked_add)
154 .unwrap_or(usize::MAX);
155 if mapping_count > MAX_CONFIGURED_MODULE_MAPPINGS {
156 return Err(ConfiguredModuleError::MappingCount {
157 requested: mapping_count,
158 });
159 }
160 configs.sort();
161 configs.dedup();
162 Ok(Self { configs })
163 }
164
165 pub(crate) fn for_source(&self, caller_path: &str) -> ConfiguredModuleSource<'_> {
167 ConfiguredModuleSource {
168 config: self.applicable_config(caller_path),
169 }
170 }
171
172 fn applicable_config(&self, caller_path: &str) -> Option<&EcmaScriptModuleConfig> {
175 let preferred = preferred_config_kind(caller_path);
176 self.configs
177 .iter()
178 .filter(|config| directory_contains(&config.directory, caller_path))
179 .max_by(|left, right| {
180 directory_depth(&left.directory)
181 .cmp(&directory_depth(&right.directory))
182 .then_with(|| {
183 (left.kind == preferred)
184 .cmp(&(right.kind == preferred))
185 .then_with(|| right.config_path.cmp(&left.config_path))
186 })
187 })
188 }
189}
190
191impl ConfiguredModuleSource<'_> {
192 pub(crate) fn scopes_for_import(self, module_spec: &str) -> Vec<String> {
194 if module_spec.starts_with("./") || module_spec.starts_with("../") || module_spec.is_empty()
195 {
196 return Vec::new();
197 }
198 let Some(config) = self.config else {
199 return Vec::new();
200 };
201 let mut matched = config
202 .mappings
203 .iter()
204 .filter_map(|mapping| {
205 match_module_pattern(&mapping.pattern, module_spec)
206 .map(|capture| (mapping, capture))
207 })
208 .collect::<Vec<_>>();
209 if !matched.is_empty() {
210 let best_specificity = matched
211 .iter()
212 .map(|(mapping, _)| pattern_specificity(&mapping.pattern))
213 .max()
214 .unwrap_or_default();
215 matched
216 .retain(|(mapping, _)| pattern_specificity(&mapping.pattern) == best_specificity);
217 let mut scopes = matched
218 .into_iter()
219 .flat_map(|(mapping, capture)| {
220 mapping.targets.iter().map(move |target| match capture {
221 ModulePatternMatch::Exact => target.clone(),
222 ModulePatternMatch::Wildcard(capture) => target.replacen('*', capture, 1),
223 })
224 })
225 .map(|scope| strip_known_source_extension(&scope))
226 .collect::<Vec<_>>();
227 scopes.sort();
228 scopes.dedup();
229 return scopes;
230 }
231 let Some(base_url) = config.base_url.as_deref() else {
232 return Vec::new();
233 };
234 let scope = if base_url.is_empty() {
235 module_spec.to_string()
236 } else {
237 format!("{base_url}/{module_spec}")
238 };
239 vec![strip_known_source_extension(&scope)]
240 }
241}
242
243#[derive(Clone, Debug, Eq, PartialEq)]
245pub enum ConfiguredModuleError {
246 InvalidIdentity {
248 field: &'static str,
250 value: String,
252 },
253 ConfigCount {
255 requested: usize,
257 },
258 MappingCount {
260 requested: usize,
262 },
263 TargetCount {
265 requested: usize,
267 },
268}
269
270impl fmt::Display for ConfiguredModuleError {
271 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
272 match self {
273 Self::InvalidIdentity { field, value } => {
274 write!(formatter, "invalid configured module {field}: {value:?}")
275 }
276 Self::ConfigCount { requested } => write!(
277 formatter,
278 "configured module snapshot contains {requested} configs; maximum is {MAX_CONFIGURED_MODULE_CONFIGS}"
279 ),
280 Self::MappingCount { requested } => write!(
281 formatter,
282 "configured module snapshot contains {requested} mappings; maximum is {MAX_CONFIGURED_MODULE_MAPPINGS}"
283 ),
284 Self::TargetCount { requested } => write!(
285 formatter,
286 "configured module mapping contains {requested} targets; admitted range is 1..={MAX_CONFIGURED_MODULE_TARGETS}"
287 ),
288 }
289 }
290}
291
292impl Error for ConfiguredModuleError {}
293
294#[derive(Clone, Copy, Debug, Eq, PartialEq)]
296enum ModulePatternMatch<'a> {
297 Exact,
299 Wildcard(
301 &'a str,
303 ),
304}
305
306fn preferred_config_kind(path: &str) -> EcmaScriptConfigKind {
308 if matches!(path.rsplit('.').next(), Some("js" | "jsx" | "mjs" | "cjs")) {
309 EcmaScriptConfigKind::JavaScript
310 } else {
311 EcmaScriptConfigKind::TypeScript
312 }
313}
314
315fn directory_contains(directory: &str, path: &str) -> bool {
317 directory.is_empty()
318 || path
319 .strip_prefix(directory)
320 .is_some_and(|rest| rest.starts_with('/'))
321}
322
323fn directory_depth(directory: &str) -> usize {
325 directory
326 .split('/')
327 .filter(|component| !component.is_empty())
328 .count()
329}
330
331fn pattern_specificity(pattern: &str) -> usize {
333 pattern.find('*').unwrap_or(pattern.len())
334}
335
336fn match_module_pattern<'a>(pattern: &str, module_spec: &'a str) -> Option<ModulePatternMatch<'a>> {
338 let Some((prefix, suffix)) = pattern.split_once('*') else {
339 return (pattern == module_spec).then_some(ModulePatternMatch::Exact);
340 };
341 module_spec
342 .strip_prefix(prefix)?
343 .strip_suffix(suffix)
344 .map(ModulePatternMatch::Wildcard)
345}
346
347fn validate_pattern(field: &'static str, value: &str) -> Result<(), ConfiguredModuleError> {
349 validate_compact(field, value)?;
350 if value.matches('*').count() > 1 {
351 return Err(ConfiguredModuleError::InvalidIdentity {
352 field,
353 value: value.to_string(),
354 });
355 }
356 Ok(())
357}
358
359fn validate_repository_pattern(
361 field: &'static str,
362 value: &str,
363) -> Result<(), ConfiguredModuleError> {
364 validate_pattern(field, value)?;
365 validate_repository_components(field, value)
366}
367
368fn validate_repository_path(field: &'static str, value: &str) -> Result<(), ConfiguredModuleError> {
370 validate_compact(field, value)?;
371 if value.contains('*') {
372 return Err(ConfiguredModuleError::InvalidIdentity {
373 field,
374 value: value.to_string(),
375 });
376 }
377 validate_repository_components(field, value)
378}
379
380fn validate_repository_path_allow_root(
382 field: &'static str,
383 value: &str,
384) -> Result<(), ConfiguredModuleError> {
385 if value.is_empty() {
386 return Ok(());
387 }
388 validate_repository_path(field, value)
389}
390
391fn validate_compact(field: &'static str, value: &str) -> Result<(), ConfiguredModuleError> {
393 if value.is_empty()
394 || value.len() > MAX_CONFIGURED_MODULE_IDENTITY_BYTES
395 || value.contains('\\')
396 || value.chars().any(char::is_control)
397 {
398 return Err(ConfiguredModuleError::InvalidIdentity {
399 field,
400 value: value.to_string(),
401 });
402 }
403 Ok(())
404}
405
406fn validate_repository_components(
408 field: &'static str,
409 value: &str,
410) -> Result<(), ConfiguredModuleError> {
411 if value.starts_with('/')
412 || value.contains(':')
413 || value
414 .split('/')
415 .any(|component| component.is_empty() || component == "." || component == "..")
416 {
417 return Err(ConfiguredModuleError::InvalidIdentity {
418 field,
419 value: value.to_string(),
420 });
421 }
422 Ok(())
423}
424
425#[cfg(test)]
426mod tests {
427 use super::{
428 ConfiguredModuleResolution, EcmaScriptConfigKind, EcmaScriptModuleConfig,
429 EcmaScriptPathMapping,
430 };
431 use std::error::Error;
432
433 #[test]
434 fn nearest_config_and_most_specific_pattern_win() -> Result<(), Box<dyn Error>> {
435 let root = EcmaScriptModuleConfig::new(
436 "tsconfig.json",
437 EcmaScriptConfigKind::TypeScript,
438 Some("src".to_string()),
439 vec![EcmaScriptPathMapping::new(
440 "@/*",
441 vec!["src/*".to_string()],
442 )?],
443 )?;
444 let nested = EcmaScriptModuleConfig::new(
445 "packages/app/tsconfig.json",
446 EcmaScriptConfigKind::TypeScript,
447 Some("packages/app/src".to_string()),
448 vec![
449 EcmaScriptPathMapping::new("@/*", vec!["packages/app/src/*".to_string()])?,
450 EcmaScriptPathMapping::new(
451 "@/models/*",
452 vec!["packages/shared/models/*".to_string()],
453 )?,
454 ],
455 )?;
456 let configured = ConfiguredModuleResolution::new(vec![root, nested])?;
457 if configured
458 .for_source("packages/app/src/page.ts")
459 .scopes_for_import("@/models/user")
460 != vec!["packages/shared/models/user"]
461 {
462 return Err(std::io::Error::other("nearest config or specific pattern lost").into());
463 }
464 if configured
465 .for_source("src/page.ts")
466 .scopes_for_import("@/controller")
467 != vec!["src/controller"]
468 {
469 return Err(std::io::Error::other("root alias did not resolve").into());
470 }
471 Ok(())
472 }
473
474 #[test]
475 fn source_family_preference_and_index_targets_are_deterministic() -> Result<(), Box<dyn Error>>
476 {
477 let ts = EcmaScriptModuleConfig::new(
478 "tsconfig.json",
479 EcmaScriptConfigKind::TypeScript,
480 None,
481 vec![EcmaScriptPathMapping::new(
482 "@/*",
483 vec!["src/typed/*/index.ts".to_string()],
484 )?],
485 )?;
486 let js = EcmaScriptModuleConfig::new(
487 "jsconfig.json",
488 EcmaScriptConfigKind::JavaScript,
489 None,
490 vec![EcmaScriptPathMapping::new(
491 "@/*",
492 vec!["src/script/*/index.js".to_string()],
493 )?],
494 )?;
495 let configured = ConfiguredModuleResolution::new(vec![ts, js])?;
496 if configured
497 .for_source("src/page.tsx")
498 .scopes_for_import("@/tools")
499 != vec!["src/typed/tools/index"]
500 {
501 return Err(std::io::Error::other("TypeScript config preference lost").into());
502 }
503 if configured
504 .for_source("src/page.jsx")
505 .scopes_for_import("@/tools")
506 != vec!["src/script/tools/index"]
507 {
508 return Err(std::io::Error::other("JavaScript config preference lost").into());
509 }
510 Ok(())
511 }
512
513 #[test]
514 fn unsafe_or_excessive_targets_are_rejected() {
515 assert!(EcmaScriptPathMapping::new("@/*", vec!["../outside/*".to_string()]).is_err());
516 assert!(
517 EcmaScriptPathMapping::new("@/*", (0..65).map(|i| format!("src/{i}/*")).collect())
518 .is_err()
519 );
520 }
521}