projectatlas_symbols/
configured_modules.rs

1//! Validated repository-configured ECMAScript module scopes.
2
3use crate::resolution_keys::strip_known_source_extension;
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    /// Select the applicable compiler configuration once for a source graph.
166    pub(crate) fn for_source(&self, caller_path: &str) -> ConfiguredModuleSource<'_> {
167        ConfiguredModuleSource {
168            config: self.applicable_config(caller_path),
169        }
170    }
171
172    /// Select the nearest containing config, preferring the source-family kind
173    /// only when both kinds exist at that same scope.
174    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    /// Resolve configured candidate scopes for one non-relative import.
193    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/// Typed validation failure for configured module resolution.
244#[derive(Clone, Debug, Eq, PartialEq)]
245pub enum ConfiguredModuleError {
246    /// One identity was not a bounded normalized repository value.
247    InvalidIdentity {
248        /// Owning configuration field.
249        field: &'static str,
250        /// Rejected value.
251        value: String,
252    },
253    /// Too many configuration files were supplied.
254    ConfigCount {
255        /// Requested count.
256        requested: usize,
257    },
258    /// Too many aggregate path mappings were supplied.
259    MappingCount {
260        /// Requested count.
261        requested: usize,
262    },
263    /// One mapping supplied too many or no targets.
264    TargetCount {
265        /// Requested count.
266        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/// Match result that preserves whether wildcard substitution is required.
295#[derive(Clone, Copy, Debug, Eq, PartialEq)]
296enum ModulePatternMatch<'a> {
297    /// The complete module specifier matched a non-wildcard pattern.
298    Exact,
299    /// A wildcard matched this bounded portion of the module specifier.
300    Wildcard(
301        /// Source text captured by the pattern wildcard.
302        &'a str,
303    ),
304}
305
306/// Choose the compiler configuration family preferred by the caller extension.
307fn 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
315/// Return whether a configuration directory contains the caller path.
316fn 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
323/// Count normalized path components for nearest-config selection.
324fn directory_depth(directory: &str) -> usize {
325    directory
326        .split('/')
327        .filter(|component| !component.is_empty())
328        .count()
329}
330
331/// Rank a path pattern by the literal prefix before its optional wildcard.
332fn pattern_specificity(pattern: &str) -> usize {
333    pattern.find('*').unwrap_or(pattern.len())
334}
335
336/// Match one compiler path pattern against an imported module specifier.
337fn 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
347/// Validate one bounded compiler pattern with at most one wildcard.
348fn 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
359/// Validate one compiler pattern that must stay within repository scope.
360fn 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
368/// Validate one non-root repository-relative path.
369fn 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
380/// Validate one repository-relative path while admitting the root identity.
381fn 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
391/// Validate common size, separator, and control-character constraints.
392fn 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
406/// Reject absolute, drive-qualified, empty, current, and parent components.
407fn 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}