Skip to main content

projectatlas_symbols/
configured_modules.rs

1//! Validated repository-configured ECMAScript module scopes.
2
3use crate::resolution_keys::ecmascript_module_scope;
4use std::error::Error;
5use std::fmt;
6
7/// Maximum compiler configuration files admitted to one resolution snapshot.
8pub const MAX_CONFIGURED_MODULE_CONFIGS: usize = 1_024;
9/// Maximum path mappings admitted across one resolution snapshot.
10pub const MAX_CONFIGURED_MODULE_MAPPINGS: usize = 4_096;
11/// Maximum target substitutions admitted for one path mapping.
12pub const MAX_CONFIGURED_MODULE_TARGETS: usize = 64;
13/// Maximum bytes admitted for one normalized pattern or repository path.
14pub const MAX_CONFIGURED_MODULE_IDENTITY_BYTES: usize = 1_024;
15
16/// ECMAScript compiler configuration family.
17#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
18pub enum EcmaScriptConfigKind {
19    /// `tsconfig.json`.
20    TypeScript,
21    /// `jsconfig.json`.
22    JavaScript,
23}
24
25/// One validated compiler `paths` pattern and its repository-normalized targets.
26#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
27pub struct EcmaScriptPathMapping {
28    /// Module specifier pattern from the compiler configuration.
29    pattern: String,
30    /// Repository-normalized target substitutions.
31    targets: Vec<String>,
32}
33
34impl EcmaScriptPathMapping {
35    /// Construct one bounded mapping.
36    ///
37    /// # Errors
38    ///
39    /// Returns an error when the pattern or normalized targets are unsafe or
40    /// exceed the per-mapping target bound.
41    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/// One repository-contained `tsconfig.json` or `jsconfig.json` scope.
63#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
64pub struct EcmaScriptModuleConfig {
65    /// Repository-relative compiler configuration path.
66    config_path: String,
67    /// Repository-relative directory that owns the configuration scope.
68    directory: String,
69    /// Compiler configuration family used for same-scope precedence.
70    kind: EcmaScriptConfigKind,
71    /// Repository-normalized `baseUrl`, when configured.
72    base_url: Option<String>,
73    /// Validated and deterministically ordered path mappings.
74    mappings: Vec<EcmaScriptPathMapping>,
75}
76
77impl EcmaScriptModuleConfig {
78    /// Construct one validated direct compiler configuration.
79    ///
80    /// `base_url` and every mapping target must already be normalized relative
81    /// to the repository root by the filesystem-owning caller.
82    ///
83    /// # Errors
84    ///
85    /// Returns an error when any path is absolute, traversing, malformed, or
86    /// exceeds the configured identity bounds.
87    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/// Deterministic compiler configuration snapshot shared by graph projections.
124#[derive(Clone, Debug, Default, Eq, PartialEq)]
125pub struct ConfiguredModuleResolution {
126    /// Validated configurations ordered for deterministic selection.
127    configs: Vec<EcmaScriptModuleConfig>,
128}
129
130/// One configuration selected once for a source graph.
131#[derive(Clone, Copy, Debug)]
132pub(crate) struct ConfiguredModuleSource<'a> {
133    /// Nearest applicable compiler configuration, when one exists.
134    config: Option<&'a EcmaScriptModuleConfig>,
135}
136
137impl ConfiguredModuleResolution {
138    /// Construct one sorted, bounded configuration snapshot.
139    ///
140    /// # Errors
141    ///
142    /// Returns an error when configuration or aggregate mapping counts exceed
143    /// their owning bounds.
144    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    /// Count the owned configuration vector, mappings, targets and text capacities.
166    #[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    /// Select the applicable compiler configuration once for a source graph.
187    pub(crate) fn for_source(&self, caller_path: &str) -> ConfiguredModuleSource<'_> {
188        ConfiguredModuleSource {
189            config: self.applicable_config(caller_path),
190        }
191    }
192
193    /// Select the nearest containing config, preferring the source-family kind
194    /// only when both kinds exist at that same scope.
195    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    /// Resolve configured candidate scopes for one non-relative import.
214    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/// Typed validation failure for configured module resolution.
265#[derive(Clone, Debug, Eq, PartialEq)]
266pub enum ConfiguredModuleError {
267    /// One identity was not a bounded normalized repository value.
268    InvalidIdentity {
269        /// Owning configuration field.
270        field: &'static str,
271        /// Rejected value.
272        value: String,
273    },
274    /// Too many configuration files were supplied.
275    ConfigCount {
276        /// Requested count.
277        requested: usize,
278    },
279    /// Too many aggregate path mappings were supplied.
280    MappingCount {
281        /// Requested count.
282        requested: usize,
283    },
284    /// One mapping supplied too many or no targets.
285    TargetCount {
286        /// Requested count.
287        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/// Match result that preserves whether wildcard substitution is required.
316#[derive(Clone, Copy, Debug, Eq, PartialEq)]
317enum ModulePatternMatch<'a> {
318    /// The complete module specifier matched a non-wildcard pattern.
319    Exact,
320    /// A wildcard matched this bounded portion of the module specifier.
321    Wildcard(
322        /// Source text captured by the pattern wildcard.
323        &'a str,
324    ),
325}
326
327/// Choose the compiler configuration family preferred by the caller extension.
328fn 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
336/// Return whether a configuration directory contains the caller path.
337fn 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
344/// Count normalized path components for nearest-config selection.
345fn directory_depth(directory: &str) -> usize {
346    directory
347        .split('/')
348        .filter(|component| !component.is_empty())
349        .count()
350}
351
352/// Rank a path pattern by the literal prefix before its optional wildcard.
353fn pattern_specificity(pattern: &str) -> usize {
354    pattern.find('*').unwrap_or(pattern.len())
355}
356
357/// Match one compiler path pattern against an imported module specifier.
358fn 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
368/// Validate one bounded compiler pattern with at most one wildcard.
369fn 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
380/// Validate one compiler pattern that must stay within repository scope.
381fn 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
389/// Validate one non-root repository-relative path.
390fn 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
401/// Validate one repository-relative path while admitting the root identity.
402fn 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
412/// Validate common size, separator, and control-character constraints.
413fn 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
427/// Reject absolute, drive-qualified, empty, current, and parent components.
428fn 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}