1use crate::resolution_keys::ecmascript_module_scope;
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 #[must_use]
167 pub fn retained_bytes(&self) -> u64 {
168 self.configs.iter().fold(
169 (self.configs.capacity() as u64).saturating_mul(std::mem::size_of::<EcmaScriptModuleConfig>() as u64),
170 |bytes, config| {
171 config.mappings.iter().fold(
172 bytes.saturating_add(config.config_path.capacity() as u64)
173 .saturating_add(config.directory.capacity() as u64)
174 .saturating_add(config.base_url.as_ref().map_or(0, String::capacity) as u64)
175 .saturating_add((config.mappings.capacity() as u64).saturating_mul(std::mem::size_of::<EcmaScriptPathMapping>() as u64)),
176 |bytes, mapping| mapping.targets.iter().fold(
177 bytes.saturating_add(mapping.pattern.capacity() as u64)
178 .saturating_add((mapping.targets.capacity() as u64).saturating_mul(std::mem::size_of::<String>() as u64)),
179 |bytes, target| bytes.saturating_add(target.capacity() as u64),
180 ),
181 )
182 },
183 )
184 }
185
186 pub(crate) fn for_source(&self, caller_path: &str) -> ConfiguredModuleSource<'_> {
188 ConfiguredModuleSource {
189 config: self.applicable_config(caller_path),
190 }
191 }
192
193 fn applicable_config(&self, caller_path: &str) -> Option<&EcmaScriptModuleConfig> {
196 let preferred = preferred_config_kind(caller_path);
197 self.configs
198 .iter()
199 .filter(|config| directory_contains(&config.directory, caller_path))
200 .max_by(|left, right| {
201 directory_depth(&left.directory)
202 .cmp(&directory_depth(&right.directory))
203 .then_with(|| {
204 (left.kind == preferred)
205 .cmp(&(right.kind == preferred))
206 .then_with(|| right.config_path.cmp(&left.config_path))
207 })
208 })
209 }
210}
211
212impl ConfiguredModuleSource<'_> {
213 pub(crate) fn scopes_for_import(self, module_spec: &str) -> Vec<String> {
215 if module_spec.starts_with("./") || module_spec.starts_with("../") || module_spec.is_empty()
216 {
217 return Vec::new();
218 }
219 let Some(config) = self.config else {
220 return Vec::new();
221 };
222 let mut matched = config
223 .mappings
224 .iter()
225 .filter_map(|mapping| {
226 match_module_pattern(&mapping.pattern, module_spec)
227 .map(|capture| (mapping, capture))
228 })
229 .collect::<Vec<_>>();
230 if !matched.is_empty() {
231 let best_specificity = matched
232 .iter()
233 .map(|(mapping, _)| pattern_specificity(&mapping.pattern))
234 .max()
235 .unwrap_or_default();
236 matched
237 .retain(|(mapping, _)| pattern_specificity(&mapping.pattern) == best_specificity);
238 let mut scopes = matched
239 .into_iter()
240 .flat_map(|(mapping, capture)| {
241 mapping.targets.iter().map(move |target| match capture {
242 ModulePatternMatch::Exact => target.clone(),
243 ModulePatternMatch::Wildcard(capture) => target.replacen('*', capture, 1),
244 })
245 })
246 .map(|scope| ecmascript_module_scope(&scope))
247 .collect::<Vec<_>>();
248 scopes.sort();
249 scopes.dedup();
250 return scopes;
251 }
252 let Some(base_url) = config.base_url.as_deref() else {
253 return Vec::new();
254 };
255 let scope = if base_url.is_empty() {
256 module_spec.to_string()
257 } else {
258 format!("{base_url}/{module_spec}")
259 };
260 vec![ecmascript_module_scope(&scope)]
261 }
262}
263
264#[derive(Clone, Debug, Eq, PartialEq)]
266pub enum ConfiguredModuleError {
267 InvalidIdentity {
269 field: &'static str,
271 value: String,
273 },
274 ConfigCount {
276 requested: usize,
278 },
279 MappingCount {
281 requested: usize,
283 },
284 TargetCount {
286 requested: usize,
288 },
289}
290
291impl fmt::Display for ConfiguredModuleError {
292 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
293 match self {
294 Self::InvalidIdentity { field, value } => {
295 write!(formatter, "invalid configured module {field}: {value:?}")
296 }
297 Self::ConfigCount { requested } => write!(
298 formatter,
299 "configured module snapshot contains {requested} configs; maximum is {MAX_CONFIGURED_MODULE_CONFIGS}"
300 ),
301 Self::MappingCount { requested } => write!(
302 formatter,
303 "configured module snapshot contains {requested} mappings; maximum is {MAX_CONFIGURED_MODULE_MAPPINGS}"
304 ),
305 Self::TargetCount { requested } => write!(
306 formatter,
307 "configured module mapping contains {requested} targets; admitted range is 1..={MAX_CONFIGURED_MODULE_TARGETS}"
308 ),
309 }
310 }
311}
312
313impl Error for ConfiguredModuleError {}
314
315#[derive(Clone, Copy, Debug, Eq, PartialEq)]
317enum ModulePatternMatch<'a> {
318 Exact,
320 Wildcard(
322 &'a str,
324 ),
325}
326
327fn preferred_config_kind(path: &str) -> EcmaScriptConfigKind {
329 if matches!(path.rsplit('.').next(), Some("js" | "jsx" | "mjs" | "cjs")) {
330 EcmaScriptConfigKind::JavaScript
331 } else {
332 EcmaScriptConfigKind::TypeScript
333 }
334}
335
336fn directory_contains(directory: &str, path: &str) -> bool {
338 directory.is_empty()
339 || path
340 .strip_prefix(directory)
341 .is_some_and(|rest| rest.starts_with('/'))
342}
343
344fn directory_depth(directory: &str) -> usize {
346 directory
347 .split('/')
348 .filter(|component| !component.is_empty())
349 .count()
350}
351
352fn pattern_specificity(pattern: &str) -> usize {
354 pattern.find('*').unwrap_or(pattern.len())
355}
356
357fn match_module_pattern<'a>(pattern: &str, module_spec: &'a str) -> Option<ModulePatternMatch<'a>> {
359 let Some((prefix, suffix)) = pattern.split_once('*') else {
360 return (pattern == module_spec).then_some(ModulePatternMatch::Exact);
361 };
362 module_spec
363 .strip_prefix(prefix)?
364 .strip_suffix(suffix)
365 .map(ModulePatternMatch::Wildcard)
366}
367
368fn validate_pattern(field: &'static str, value: &str) -> Result<(), ConfiguredModuleError> {
370 validate_compact(field, value)?;
371 if value.matches('*').count() > 1 {
372 return Err(ConfiguredModuleError::InvalidIdentity {
373 field,
374 value: value.to_string(),
375 });
376 }
377 Ok(())
378}
379
380fn validate_repository_pattern(
382 field: &'static str,
383 value: &str,
384) -> Result<(), ConfiguredModuleError> {
385 validate_pattern(field, value)?;
386 validate_repository_components(field, value)
387}
388
389fn validate_repository_path(field: &'static str, value: &str) -> Result<(), ConfiguredModuleError> {
391 validate_compact(field, value)?;
392 if value.contains('*') {
393 return Err(ConfiguredModuleError::InvalidIdentity {
394 field,
395 value: value.to_string(),
396 });
397 }
398 validate_repository_components(field, value)
399}
400
401fn validate_repository_path_allow_root(
403 field: &'static str,
404 value: &str,
405) -> Result<(), ConfiguredModuleError> {
406 if value.is_empty() {
407 return Ok(());
408 }
409 validate_repository_path(field, value)
410}
411
412fn validate_compact(field: &'static str, value: &str) -> Result<(), ConfiguredModuleError> {
414 if value.is_empty()
415 || value.len() > MAX_CONFIGURED_MODULE_IDENTITY_BYTES
416 || value.contains('\\')
417 || value.chars().any(char::is_control)
418 {
419 return Err(ConfiguredModuleError::InvalidIdentity {
420 field,
421 value: value.to_string(),
422 });
423 }
424 Ok(())
425}
426
427fn validate_repository_components(
429 field: &'static str,
430 value: &str,
431) -> Result<(), ConfiguredModuleError> {
432 if value.starts_with('/')
433 || value.contains(':')
434 || value
435 .split('/')
436 .any(|component| component.is_empty() || component == "." || component == "..")
437 {
438 return Err(ConfiguredModuleError::InvalidIdentity {
439 field,
440 value: value.to_string(),
441 });
442 }
443 Ok(())
444}
445
446#[cfg(test)]
447mod tests {
448 use super::{
449 ConfiguredModuleResolution, EcmaScriptConfigKind, EcmaScriptModuleConfig,
450 EcmaScriptPathMapping,
451 };
452 use std::error::Error;
453
454 #[test]
455 fn nearest_config_and_most_specific_pattern_win() -> Result<(), Box<dyn Error>> {
456 let root = EcmaScriptModuleConfig::new(
457 "tsconfig.json",
458 EcmaScriptConfigKind::TypeScript,
459 Some("src".to_string()),
460 vec![EcmaScriptPathMapping::new(
461 "@/*",
462 vec!["src/*".to_string()],
463 )?],
464 )?;
465 let nested = EcmaScriptModuleConfig::new(
466 "packages/app/tsconfig.json",
467 EcmaScriptConfigKind::TypeScript,
468 Some("packages/app/src".to_string()),
469 vec![
470 EcmaScriptPathMapping::new("@/*", vec!["packages/app/src/*".to_string()])?,
471 EcmaScriptPathMapping::new(
472 "@/models/*",
473 vec!["packages/shared/models/*".to_string()],
474 )?,
475 ],
476 )?;
477 let configured = ConfiguredModuleResolution::new(vec![root, nested])?;
478 if configured
479 .for_source("packages/app/src/page.ts")
480 .scopes_for_import("@/models/user")
481 != vec!["module:packages/shared/models/user"]
482 {
483 return Err(std::io::Error::other("nearest config or specific pattern lost").into());
484 }
485 if configured
486 .for_source("src/page.ts")
487 .scopes_for_import("@/controller")
488 != vec!["module:src/controller"]
489 {
490 return Err(std::io::Error::other("root alias did not resolve").into());
491 }
492 Ok(())
493 }
494
495 #[test]
496 fn source_family_preference_and_index_targets_are_deterministic() -> Result<(), Box<dyn Error>>
497 {
498 let ts = EcmaScriptModuleConfig::new(
499 "tsconfig.json",
500 EcmaScriptConfigKind::TypeScript,
501 None,
502 vec![EcmaScriptPathMapping::new(
503 "@/*",
504 vec!["src/typed/*/index.ts".to_string()],
505 )?],
506 )?;
507 let js = EcmaScriptModuleConfig::new(
508 "jsconfig.json",
509 EcmaScriptConfigKind::JavaScript,
510 None,
511 vec![EcmaScriptPathMapping::new(
512 "@/*",
513 vec!["src/script/*/index.js".to_string()],
514 )?],
515 )?;
516 let configured = ConfiguredModuleResolution::new(vec![ts, js])?;
517 if configured
518 .for_source("src/page.tsx")
519 .scopes_for_import("@/tools")
520 != vec!["file:src/typed/tools/index.ts"]
521 {
522 return Err(std::io::Error::other("TypeScript config preference lost").into());
523 }
524 if configured
525 .for_source("src/page.jsx")
526 .scopes_for_import("@/tools")
527 != vec!["file:src/script/tools/index.js"]
528 {
529 return Err(std::io::Error::other("JavaScript config preference lost").into());
530 }
531 Ok(())
532 }
533
534 #[test]
535 fn unsafe_or_excessive_targets_are_rejected() {
536 assert!(EcmaScriptPathMapping::new("@/*", vec!["../outside/*".to_string()]).is_err());
537 assert!(
538 EcmaScriptPathMapping::new("@/*", (0..65).map(|i| format!("src/{i}/*")).collect())
539 .is_err()
540 );
541 }
542}