char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somdeslop: factor module prep/transpilatione/etc into loader.rsab5df08

main
38.5 KiB1040 linesraw
1use std::cell::RefCell;
2use std::collections::{BTreeMap, BTreeSet, HashMap};
3use std::path::{Path, PathBuf};
4use std::rc::Rc;
5use std::sync::Arc;
6
7use avril_core::{Error, ModuleFormat, ModuleLoader};
8use oxc::span::SourceType;
9
10use crate::authority::SourceAuthority;
11use crate::caps::PathTarget;
12use crate::data_modules::DataModule;
13use crate::local_packages::ResolveMode;
14pub use crate::registry::npm::resolution::ResolutionMode;
15use crate::registry::npm::resolution::{
16    NpmResolver, NpmRootRequest, PreparedNpmRoots, ResolutionError, ResolvedNpmModule,
17};
18
19pub use crate::loader::BuiltinWarning;
20pub(crate) use crate::loader::{compile, transpile};
21pub use crate::type_graph::TypeGraph;
22
23#[derive(Debug, thiserror::Error)]
24pub(crate) enum ResolutionFailure {
25    #[error("Unsupported module specifier: {0}")]
26    UnboundSpecifier(String),
27    #[error(transparent)]
28    Npm(#[from] ResolutionError),
29    #[error(transparent)]
30    Other(#[from] Error),
31}
32
33impl ResolutionFailure {
34    pub(crate) fn can_be_ambient(&self) -> bool {
35        matches!(
36            self,
37            Self::UnboundSpecifier(_) | Self::Npm(ResolutionError::NpmPackageNotDeclared { .. })
38        )
39    }
40}
41
42impl From<ResolutionFailure> for Error {
43    fn from(failure: ResolutionFailure) -> Self {
44        match failure {
45            ResolutionFailure::UnboundSpecifier(specifier) => {
46                Error::Module(format!("Unsupported module specifier: {specifier}"))
47            }
48            ResolutionFailure::Npm(error) => Error::Module(error.to_string()),
49            ResolutionFailure::Other(error) => error,
50        }
51    }
52}
53
54pub(crate) enum ResolutionTarget {
55    Module(String),
56    Jsr(String),
57    Npm(String),
58    Remote(String),
59}
60
61pub(crate) struct ResolvedRequest {
62    pub(crate) name: String,
63    pub(crate) tracked: Option<String>,
64    // The npm export still eligible for type overrides and automatic discovery.
65    pub(crate) type_subpath: Option<String>,
66}
67
68impl ResolvedRequest {
69    fn module(name: String, tracked: Option<String>) -> Self {
70        Self {
71            name,
72            tracked,
73            type_subpath: None,
74        }
75    }
76
77    fn npm(
78        module: ResolvedNpmModule,
79        specifier: &str,
80        tracked: Option<String>,
81    ) -> Result<Self, Error> {
82        let subpath = if specifier.starts_with("npm:") {
83            crate::registry::npm::parse_npm_request(specifier)
84                .map_err(|error| Error::Module(error.to_string()))?
85                .subpath
86        } else if !specifier.starts_with(['.', '/', '#']) && !specifier.contains(':') {
87            crate::registry::npm::package::split_bare(specifier)
88                .1
89                .map(str::to_owned)
90        } else {
91            return Ok(Self::module(
92                module.path.to_string_lossy().into_owned(),
93                tracked,
94            ));
95        };
96        Ok(Self {
97            name: module.path.to_string_lossy().into_owned(),
98            tracked,
99            type_subpath: Some(subpath.unwrap_or_default()),
100        })
101    }
102}
103
104/// Normalized name, local path, and remote/npm flags for one referrer.
105pub(crate) struct Referrer {
106    pub(crate) name: String,
107    pub(crate) path: Option<PathBuf>,
108    pub(crate) remote: bool,
109    pub(crate) inline: bool,
110    pub(crate) npm: bool,
111}
112
113#[derive(Clone)]
114pub struct ModuleGraph {
115    pub(crate) authority: SourceAuthority,
116    pub(crate) packages: Rc<crate::local_packages::LocalPackages>,
117    pub(crate) store: Arc<crate::registry::PackageStore>,
118    pub(crate) remote: Rc<crate::remote_packages::RemotePackages>,
119    pub(crate) npm: Rc<NpmResolver>,
120    pub(crate) npm_handles: Rc<RefCell<HashMap<PathBuf, PreparedNpmRoots>>>,
121    pub(crate) types: Rc<crate::type_graph::TypeState>,
122    pub(crate) loader: Rc<RefCell<crate::loader::LoaderState>>,
123}
124
125impl ModuleGraph {
126    /// A graph with internally owned package loaders sharing one authority.
127    /// The store is built with [`crate::registry::PackageStore::new_with_authority`].
128    pub fn open(
129        runtime: tokio::runtime::Handle,
130        authority: SourceAuthority,
131        options: crate::registry::Options,
132    ) -> Result<Self, Error> {
133        let store = Arc::new(
134            crate::registry::PackageStore::new_with_authority(runtime, options, authority.clone())
135                .map_err(|error| Error::Module(error.to_string()))?,
136        );
137        Ok(Self::assemble(authority, store, false))
138    }
139
140    /// Like [`ModuleGraph::open`], resolving npm packages for browser bundles.
141    pub fn open_browser(
142        runtime: tokio::runtime::Handle,
143        authority: SourceAuthority,
144        options: crate::registry::Options,
145    ) -> Result<Self, Error> {
146        let store = Arc::new(
147            crate::registry::PackageStore::new_with_authority(runtime, options, authority.clone())
148                .map_err(|error| Error::Module(error.to_string()))?,
149        );
150        Ok(Self::assemble(authority, store, true))
151    }
152
153    /// Reuse an execution store, as workers do with their parent's store.
154    /// Graph checks retain the caller's authority; acquisition uses the store's.
155    /// Read graphs should use `open` or `open_browser` so acquisition shares
156    /// their read/network policy.
157    pub fn open_shared(
158        authority: SourceAuthority,
159        store: Arc<crate::registry::PackageStore>,
160    ) -> Self {
161        Self::assemble(authority, store, false)
162    }
163
164    /// The store this graph acquires through.
165    pub fn store(&self) -> &Arc<crate::registry::PackageStore> {
166        &self.store
167    }
168
169    pub fn set_typescript_version(&self, version: &str) -> Result<(), Error> {
170        self.npm
171            .set_typescript_version(version)
172            .map_err(|error| Error::Module(error.to_string()))
173    }
174
175    pub fn audit_npm(&self) -> Result<(), Error> {
176        self.npm
177            .audit()
178            .map_err(|error| Error::Module(error.to_string()))
179    }
180
181    /// The project root owning `anchor`, for store and lock bootstrap.
182    pub fn project_root(authority: &SourceAuthority, anchor: &str) -> Result<PathBuf, Error> {
183        crate::local_packages::LocalPackages::new(authority.clone()).root(anchor)
184    }
185
186    /// The trusted config a REPL session belongs to, for storage bootstrap.
187    pub fn project_root_for_repl(
188        authority: &SourceAuthority,
189        anchor: &str,
190    ) -> Result<PathBuf, Error> {
191        crate::local_packages::LocalPackages::new(authority.clone()).root_for_repl(anchor)
192    }
193
194    /// The owning config path of a REPL session, if it has one.
195    pub fn project_config_for_repl(
196        authority: &SourceAuthority,
197        anchor: &str,
198    ) -> Result<Option<PathBuf>, Error> {
199        crate::local_packages::LocalPackages::new(authority.clone()).config_for_repl(anchor)
200    }
201
202    /// Locate the project root for `add`, rejecting untrusted configs.
203    pub fn project_root_for_add(
204        authority: &SourceAuthority,
205        anchor: &str,
206    ) -> Result<PathBuf, Error> {
207        crate::local_packages::LocalPackages::new(authority.clone()).root_for_add(anchor)
208    }
209
210    fn assemble(
211        authority: SourceAuthority,
212        store: Arc<crate::registry::PackageStore>,
213        browser: bool,
214    ) -> Self {
215        let packages = Rc::new(crate::local_packages::LocalPackages::new(authority.clone()));
216        let remote = Rc::new(crate::remote_packages::RemotePackages::new(
217            authority.clone(),
218            store.clone(),
219        ));
220        let npm = Rc::new(if browser {
221            NpmResolver::for_browser(store.clone())
222        } else {
223            NpmResolver::new(store.clone())
224        });
225        Self {
226            packages,
227            store,
228            remote,
229            npm,
230            npm_handles: Rc::default(),
231            types: Rc::default(),
232            authority,
233            loader: Rc::default(),
234        }
235    }
236
237    pub(crate) fn side_effects(&self, name: &str) -> Option<bool> {
238        self.npm.side_effects(Path::new(name))
239    }
240
241    pub async fn initialize_package(&self, name: &str) -> Result<(), Error> {
242        if data_url(name).is_some() {
243            return Ok(());
244        }
245        if http_url(name).is_some() {
246            self.remote.initialize(name).await
247        } else if self.npm.is_reserved_npm_path(Path::new(name)) {
248            self.npm
249                .check_access(Path::new(name))
250                .map(|_| ())
251                .map_err(|error| Error::Module(error.to_string()))
252        } else {
253            self.packages.initialize(name, &self.remote).await
254        }
255    }
256
257    pub async fn original_source(&self, name: &str) -> Result<String, Error> {
258        self.original_source_for(name, ResolutionMode::Import).await
259    }
260
261    pub(crate) async fn original_source_for(
262        &self,
263        name: &str,
264        mode: ResolutionMode,
265    ) -> Result<String, Error> {
266        self.check_source_access(name, mode)?;
267        let source = if data_url(name).is_some() {
268            DataModule::parse(name)?.source()?
269        } else if http_url(name).is_some() {
270            self.store
271                .remote_source(name)
272                .await
273                .map_err(|error| Error::Module(error.to_string()))?
274        } else if self.npm.owning_instance(Path::new(name)).is_some() {
275            self.npm
276                .npm_source(Path::new(name))
277                .map_err(|error| Error::Module(error.to_string()))?
278        } else {
279            crate::sources::read_text(&self.target(name)?, &self.authority)
280                .map_err(|error| self.path_error(name, error))?
281        };
282        self.check_source_access(name, mode)?;
283        Ok(source)
284    }
285
286    pub(crate) fn check_source_access(
287        &self,
288        name: &str,
289        mode: ResolutionMode,
290    ) -> Result<(), Error> {
291        if mode.is_type() && self.npm.is_reserved_npm_path(Path::new(name)) {
292            self.npm
293                .check_access(Path::new(name))
294                .map(|_| ())
295                .map_err(|error| Error::Module(error.to_string()))
296        } else {
297            self.check_access(name)
298        }
299    }
300
301    pub(crate) fn package_metadata(&self, name: &str) -> Result<Option<serde_json::Value>, Error> {
302        if data_url(name).is_some() {
303            return Ok(None);
304        }
305        if Path::new(name).is_absolute() {
306            if let Some(instance) = self.npm.owning_instance(Path::new(name)) {
307                return self.npm_package_metadata(&instance, name);
308            }
309            self.target(name)?;
310            self.packages.metadata(name)
311        } else {
312            self.remote.metadata(name)
313        }
314    }
315
316    fn npm_package_metadata(
317        &self,
318        instance: &str,
319        name: &str,
320    ) -> Result<Option<serde_json::Value>, Error> {
321        let identity = self
322            .npm
323            .describe_instance(instance)
324            .map_err(|error| Error::Module(error.to_string()))?;
325        Ok(Some(serde_json::json!({
326            "registry": self.store.options().npm_registry.as_str(),
327            "name": identity.name,
328            "version": identity.version,
329            "instance": instance,
330            "file": name,
331        })))
332    }
333
334    /// Workspace ownership, not guest write permission. Package-store bytes are
335    /// immutable inputs even when their materialized identity is an absolute path.
336    pub fn is_workspace_source(&self, name: &str) -> bool {
337        let path = Path::new(name);
338        let options = self.store.options();
339        path.is_absolute()
340            && !self.npm.is_reserved_npm_path(path)
341            && !path.starts_with(&options.cache)
342            && options
343                .vendor
344                .as_ref()
345                .is_none_or(|vendor| !path.starts_with(vendor))
346    }
347
348    pub fn config_inputs(&self) -> BTreeSet<std::path::PathBuf> {
349        self.packages.config_inputs.borrow().clone()
350    }
351
352    pub(crate) fn jsx_config(&self, name: &str) -> Result<crate::project::JsxConfig, Error> {
353        if http_url(name).is_some()
354            || data_url(name).is_some()
355            || self.npm.is_reserved_npm_path(Path::new(name))
356        {
357            Ok(crate::project::JsxConfig::default())
358        } else {
359            self.packages.jsx_config(name)
360        }
361    }
362
363    pub(crate) fn source_type(&self, name: &str) -> Result<SourceType, Error> {
364        if data_url(name).is_some() {
365            return Ok(DataModule::parse(name)?.source_type);
366        }
367        if self.npm.owning_instance(Path::new(name)).is_some() {
368            // Descriptive only; execution policy lives in check_access and
369            // the resolver, so type tooling keeps working on declarations.
370            return Ok(SourceType::from_path(name).unwrap_or_else(|_| SourceType::mjs()));
371        }
372        let base = if http_url(name).is_some() {
373            self.store
374                .remote_base(name)
375                .map_err(|error| Error::Module(error.to_string()))?
376        } else {
377            name.to_owned()
378        };
379        let url = http_url(&base);
380        Ok(
381            SourceType::from_path(url.as_ref().map_or(base.as_str(), url::Url::path))
382                .unwrap_or_else(|_| SourceType::mjs()),
383        )
384    }
385
386    pub(crate) fn target(&self, name: &str) -> Result<PathTarget, Error> {
387        let target = PathTarget::resolve(name).map_err(|error| self.path_error(name, error))?;
388        if target.as_path() != Path::new(name) {
389            return Err(self.path_error(name, "Module path changed after resolution"));
390        }
391        self.authority
392            .check_local(&target)
393            .map_err(|error| self.path_error(name, error))?;
394        Ok(target)
395    }
396
397    fn resolve_local_target(
398        &self,
399        path: &Path,
400        specifier: &str,
401        allow_missing: bool,
402    ) -> Result<String, Error> {
403        // A cache symlink must not turn a registry-owned URL into an unchecked local import.
404        let logical = lexical_absolute(path)?;
405        if self.npm.is_reserved_npm_path(&logical) {
406            self.npm
407                .check_access(&logical)
408                .map_err(|error| Error::Module(error.to_string()))?;
409            return logical
410                .into_os_string()
411                .into_string()
412                .map_err(|_| Error::Module("Module paths must be valid UTF-8".into()));
413        }
414        let target = if allow_missing {
415            PathTarget::for_creation(path)
416        } else {
417            PathTarget::resolve(path)
418        }
419        .map_err(|error| self.path_error(specifier, error))?;
420        self.authority
421            .check_local(&target)
422            .map_err(|error| self.path_error(specifier, error))?;
423        target
424            .into_path()
425            .into_os_string()
426            .into_string()
427            .map_err(|_| Error::Module("Module paths must be valid UTF-8".into()))
428    }
429
430    pub(crate) fn path_error(&self, name: &str, error: impl std::fmt::Display) -> Error {
431        if self.authority.unrestricted_local() {
432            Error::Module(format!("{name}: {error}"))
433        } else if self.authority.is_read() {
434            Error::Module(format!("PermissionDenied: read {name}"))
435        } else {
436            Error::Module(format!("PermissionDenied: js-exec {name}"))
437        }
438    }
439}
440
441impl ModuleGraph {
442    pub(crate) async fn resolve_tracked(
443        &self,
444        specifier: &str,
445        referrer: &str,
446    ) -> Result<(String, Option<String>), Error> {
447        let resolved = self
448            .resolve_tracked_for(specifier, referrer, ResolutionMode::Import, false)
449            .await
450            .map_err(Error::from)?;
451        Ok((resolved.name, resolved.tracked))
452    }
453
454    pub(crate) async fn resolve_tracked_for(
455        &self,
456        specifier: &str,
457        referrer: &str,
458        mode: ResolutionMode,
459        overrides: bool,
460    ) -> Result<ResolvedRequest, ResolutionFailure> {
461        let referrer = self.classify_referrer(referrer)?;
462        if mode.is_type() && specifier.starts_with("node:") {
463            return Ok(ResolvedRequest::module(specifier.to_owned(), None));
464        }
465        if let Some(name) = Self::early_scheme(specifier)? {
466            return Ok(ResolvedRequest::module(name, None));
467        }
468        if overrides && let Some(target) = self.type_override(specifier, &referrer.name)? {
469            let anchor = self.type_config_referrer(&referrer.name);
470            let mut resolved = Box::pin(self.resolve_tracked_for(&target, &anchor, mode, false))
471                .await
472                .map_err(|failure| ResolutionFailure::Other(failure.into()))?;
473            resolved.type_subpath = None;
474            return Ok(resolved);
475        }
476        if let Some(path) = referrer.path.as_deref()
477            && referrer.npm
478        {
479            if let Some(url) = data_url(specifier) {
480                return Ok(ResolvedRequest::module(url.to_string(), None));
481            }
482            if self.npm.owning_instance(path).is_none() {
483                return Err(Error::Module(format!(
484                    "npm cache path is not owned by a prepared instance: {}",
485                    path.display()
486                ))
487                .into());
488            }
489            return self.resolve_npm_from_file(specifier, path, mode).await;
490        }
491        let mapped = if referrer.inline {
492            None
493        } else if referrer.remote {
494            self.remote.resolve(specifier, &referrer.name).await?
495        } else {
496            self.packages
497                .resolve(specifier, &referrer.name, &self.remote)
498                .await?
499        };
500        let effective = match &mapped {
501            Some(crate::local_packages::Resolution::Specifier(target)) => target.as_str(),
502            _ => specifier,
503        };
504        if mapped.is_none() && !effective.contains(':') && !effective.starts_with(['.', '/', '#']) {
505            return Err(ResolutionFailure::UnboundSpecifier(effective.to_owned()));
506        }
507        if overrides
508            && effective != specifier
509            && let Some(target) = self.type_override(effective, &referrer.name)?
510        {
511            let mut resolved =
512                Box::pin(self.resolve_tracked_for(&target, &referrer.name, mode, false))
513                    .await
514                    .map_err(|failure| ResolutionFailure::Other(failure.into()))?;
515            resolved.type_subpath = None;
516            return Ok(resolved);
517        }
518        if effective.starts_with("npm:") {
519            return self
520                .resolve_npm_specifier(effective, &referrer, mode, !overrides)
521                .await;
522        }
523        match self.resolve_target(specifier, &referrer, mapped, ResolveMode::Acquire)? {
524            ResolutionTarget::Module(name) => Ok(ResolvedRequest::module(name, None)),
525            ResolutionTarget::Npm(specifier) => {
526                self.resolve_npm_specifier(&specifier, &referrer, mode, !overrides)
527                    .await
528            }
529            ResolutionTarget::Jsr(specifier) => {
530                let request = crate::registry::parse_jsr_request(&specifier)
531                    .map_err(|error| Error::Module(error.to_string()))?;
532                let key = request.request_key();
533                let name = self
534                    .store
535                    .resolve_request(&request)
536                    .await
537                    .map_err(|error| Error::Module(error.to_string()))?;
538                Ok(ResolvedRequest::module(name, Some(key)))
539            }
540            ResolutionTarget::Remote(name) => {
541                let declarations = if referrer.remote || referrer.inline {
542                    Vec::new()
543                } else {
544                    self.packages.remote_dependencies(&referrer.name)?
545                };
546                self.remote
547                    .import(&name, &referrer.name, &declarations)
548                    .await?;
549                self.check_access(&name)?;
550                Ok(ResolvedRequest::module(name, None))
551            }
552        }
553    }
554
555    /// Classify the referrer once per resolution. The local path reuses
556    /// `require_referrer_path`; npm/remote are checked here, ownership at
557    /// use sites. Invalid file URLs and non-UTF8 paths error.
558    pub(crate) fn classify_referrer(&self, referrer: &str) -> Result<Referrer, Error> {
559        let path = Self::require_referrer_path(referrer);
560        if path.is_none() && url::Url::parse(referrer).is_ok_and(|url| url.scheme() == "file") {
561            return Err(Error::Module(format!("Invalid file referrer: {referrer}")));
562        }
563        let npm = path
564            .as_deref()
565            .is_some_and(|path| self.npm.is_reserved_npm_path(path));
566        let name = match &path {
567            Some(path) => path
568                .to_str()
569                .ok_or_else(|| Error::Module("Module paths must be valid UTF-8".into()))?
570                .to_owned(),
571            None => referrer.to_owned(),
572        };
573        Ok(Referrer {
574            remote: http_url(referrer).is_some(),
575            inline: data_url(referrer).is_some(),
576            name,
577            path,
578            npm,
579        })
580    }
581
582    /// `ext:` rejects, `avril:` passes through untouched, `node:` rejects.
583    /// Returns `Some` when the specifier decides the resolution before any
584    /// mapping; `None` continues the tree.
585    pub(crate) fn early_scheme(specifier: &str) -> Result<Option<String>, Error> {
586        if specifier.starts_with("ext:") {
587            return Err(Error::Module("Extension modules are private".into()));
588        }
589        if specifier.starts_with("avril:") {
590            return Ok(Some(specifier.to_owned()));
591        }
592        if specifier.starts_with("node:") {
593            return Err(Error::Module(format!(
594                "node: builtin modules are not supported: {specifier}"
595            )));
596        }
597        Ok(None)
598    }
599
600    /// Scheme dispatch over the (possibly mapped) specifier.
601    /// `mode.allow_missing()` controls target existence probing at every call
602    /// site: `Acquire` requires the target, `PreparedOnly` only forms it.
603    /// Every other branch is mode-independent.
604    pub(crate) fn resolve_target(
605        &self,
606        specifier: &str,
607        referrer: &Referrer,
608        mapped: Option<crate::local_packages::Resolution>,
609        mode: ResolveMode,
610    ) -> Result<ResolutionTarget, Error> {
611        let allow_missing = mode.allow_missing();
612        let mapped = match mapped {
613            Some(crate::local_packages::Resolution::Module(name)) => {
614                let logical = lexical_absolute(Path::new(&name))?;
615                if self.npm.is_reserved_npm_path(&logical) {
616                    self.npm
617                        .check_access(&logical)
618                        .map_err(|error| Error::Module(error.to_string()))?;
619                    let name = logical
620                        .into_os_string()
621                        .into_string()
622                        .map_err(|_| Error::Module("Module paths must be valid UTF-8".into()))?;
623                    return Ok(ResolutionTarget::Module(name));
624                }
625                if !allow_missing {
626                    self.target(&name)?;
627                }
628                return Ok(ResolutionTarget::Module(name));
629            }
630            Some(crate::local_packages::Resolution::Remote(name)) => {
631                self.check_access(&name)?;
632                return Ok(ResolutionTarget::Module(name));
633            }
634            Some(crate::local_packages::Resolution::Specifier(target)) => Some(target),
635            None => None,
636        };
637        let specifier = mapped.as_deref().unwrap_or(specifier);
638        if let Some(name) = Self::early_scheme(specifier)? {
639            return Ok(ResolutionTarget::Module(name));
640        }
641        if let Some(url) = data_url(specifier) {
642            return Ok(ResolutionTarget::Module(url.to_string()));
643        }
644        if referrer.inline && url::Url::parse(specifier).is_err() {
645            return Err(Error::Module(format!(
646                "Data modules require absolute import URLs: {specifier}"
647            )));
648        }
649        if specifier.starts_with("jsr:") {
650            return Ok(ResolutionTarget::Jsr(specifier.to_owned()));
651        }
652        if specifier.starts_with("npm:") {
653            return Ok(ResolutionTarget::Npm(specifier.to_owned()));
654        }
655        if referrer
656            .name
657            .starts_with(self.store.options().jsr_registry.as_str())
658        {
659            if specifier.starts_with("./")
660                || specifier.starts_with("../")
661                || specifier.starts_with('/')
662                || specifier.starts_with(self.store.options().jsr_registry.as_str())
663            {
664                let name = self
665                    .store
666                    .resolve_relative(specifier, &referrer.name)
667                    .map_err(|error| Error::Module(error.to_string()))?;
668                self.check_access(&name)?;
669                return Ok(ResolutionTarget::Module(name));
670            }
671            return Err(Error::Module(format!(
672                "Unsupported module specifier: {specifier}"
673            )));
674        }
675        let remote = if let Some(url) = http_url(specifier) {
676            Some(url)
677        } else if referrer.remote
678            && (specifier.starts_with("./")
679                || specifier.starts_with("../")
680                || specifier.starts_with('/')
681                || specifier.starts_with('?')
682                || specifier.starts_with('#'))
683        {
684            let base = self.store.remote_base(&referrer.name);
685            let base = base.map_err(|error| Error::Module(error.to_string()))?;
686            Some(
687                url::Url::parse(&base)
688                    .and_then(|url| url.join(specifier))
689                    .map_err(|error| Error::Module(error.to_string()))?,
690            )
691        } else {
692            None
693        };
694        if let Some(url) = remote {
695            let name = url.to_string();
696            self.check_access(&name)?;
697            return Ok(ResolutionTarget::Remote(name));
698        }
699        if referrer.remote {
700            return Err(Error::Module(format!(
701                "Unsupported module specifier: {specifier}"
702            )));
703        }
704        let path = Path::new(specifier);
705        let path = if specifier
706            .get(..5)
707            .is_some_and(|scheme| scheme.eq_ignore_ascii_case("file:"))
708        {
709            let url = url::Url::parse(specifier)
710                .map_err(|_| Error::Module(format!("Invalid file URL: {specifier}")))?;
711            if url.query().is_some() || url.fragment().is_some() {
712                return Err(Error::Module(
713                    "File URL queries and fragments are not supported yet".into(),
714                ));
715            }
716            url.to_file_path()
717                .map_err(|_| Error::Module(format!("Invalid file URL: {specifier}")))?
718        } else if path.is_absolute() {
719            path.to_owned()
720        } else if specifier.starts_with("./") || specifier.starts_with("../") {
721            Path::new(&referrer.name).parent().unwrap().join(path)
722        } else {
723            return Err(Error::Module(format!(
724                "Unsupported module specifier: {specifier}"
725            )));
726        };
727        self.resolve_local_target(&path, specifier, allow_missing)
728            .map(ResolutionTarget::Module)
729    }
730
731    /// Prepare every declared npm dependency of the owning config together
732    /// with on-demand requests, keeping one resolution handle per config
733    /// root so different peer environments never share bindings.
734    pub(crate) async fn prepare_npm_for_config(
735        &self,
736        referrer: &str,
737        extra: &[String],
738    ) -> Result<PreparedNpmRoots, Error> {
739        // Reserved npm paths are never owned by a local config: resolve
740        // them through the instance tree instead of config discovery.
741        let canonical = Path::new(referrer)
742            .canonicalize()
743            .unwrap_or_else(|_| Path::new(referrer).to_owned());
744        if self.npm.is_reserved_npm_path(&canonical) {
745            return Err(Error::Module(format!(
746                "npm cache paths resolve through their owning instance, not a config: {referrer}"
747            )));
748        }
749        let root = self
750            .packages
751            .root(referrer)
752            .map_err(|error| Error::Module(error.to_string()))?;
753        if let Some(handle) = self.npm_handles.borrow().get(&root)
754            && extra.iter().all(|request| {
755                self.npm
756                    .resolve_in(handle, request, ResolutionMode::Import)
757                    .is_ok()
758            })
759        {
760            return Ok(handle.clone());
761        }
762        let mut requests = self
763            .packages
764            .npm_dependencies(referrer)
765            .map_err(|error| Error::Module(error.to_string()))?;
766        requests.extend(extra.iter().cloned());
767        requests.sort();
768        requests.dedup();
769        let handle = self
770            .npm
771            .prepare(
772                &requests
773                    .into_iter()
774                    .map(|request| NpmRootRequest {
775                        request,
776                        context: BTreeMap::new(),
777                    })
778                    .collect::<Vec<_>>(),
779            )
780            .await
781            .map_err(|error| Error::Module(error.to_string()))?;
782        let mut handles = self.npm_handles.borrow_mut();
783        let entry = handles.entry(root).or_default();
784        for (key, instance) in &handle.roots {
785            let request = key.split('\0').next().unwrap();
786            if entry.keys_for_request(request).is_empty() {
787                entry.roots.insert(key.clone(), instance.clone());
788            }
789        }
790        Ok(entry.clone())
791    }
792
793    /// Prepares the npm roots a local module can reach before its edges
794    /// resolve, returning lock keys to record for CommonJS `require`.
795    pub(crate) async fn prepare_npm_roots(
796        &self,
797        name: &str,
798        format: ModuleFormat,
799        specifiers: &[String],
800    ) -> Result<Vec<String>, Error> {
801        // Remote and npm-owned modules resolve on demand instead.
802        if self.npm.is_reserved_npm_path(Path::new(name))
803            || http_url(name).is_some()
804            || data_url(name).is_some()
805        {
806            return Ok(Vec::new());
807        }
808        if format == ModuleFormat::CommonJs {
809            let handle = self.prepare_npm_for_config(name, &[]).await?;
810            return Ok(handle.roots.into_keys().collect());
811        }
812        // Literal npm: imports of one local module resolve together
813        // so their peer environments preselect jointly. Mapped-away
814        // literals never become roots: only what the import map
815        // still selects as npm: is prepared.
816        let mut literal = Vec::new();
817        for specifier in specifiers
818            .iter()
819            .filter(|specifier| specifier.starts_with("npm:"))
820        {
821            match self.packages.resolve(specifier, name, &self.remote).await? {
822                Some(crate::local_packages::Resolution::Specifier(target))
823                    if target.starts_with("npm:") =>
824                {
825                    literal.push(target);
826                }
827                Some(_) => {}
828                None => literal.push(specifier.clone()),
829            }
830        }
831        if !literal.is_empty() {
832            self.prepare_npm_for_config(name, &literal).await?;
833        }
834        Ok(Vec::new())
835    }
836
837    async fn resolve_npm_from_file(
838        &self,
839        specifier: &str,
840        path: &Path,
841        mode: ResolutionMode,
842    ) -> Result<ResolvedRequest, ResolutionFailure> {
843        if specifier.starts_with("node:") {
844            return Err(Error::Module(format!(
845                "node: builtin modules are not supported: {specifier}"
846            ))
847            .into());
848        }
849        if specifier.starts_with("npm:") {
850            let request = crate::registry::npm::parse_npm_request(specifier)
851                .map_err(|error| Error::Module(error.to_string()))?;
852            let handle = self
853                .npm
854                .prepare(&[NpmRootRequest {
855                    request: request.request_key(),
856                    context: BTreeMap::new(),
857                }])
858                .await
859                .map_err(|error| Error::Module(error.to_string()))?;
860            let resolved = self.npm.resolve_in(&handle, specifier, mode)?;
861            let key = handle
862                .roots
863                .iter()
864                .find(|(_, instance)| *instance == &resolved.instance)
865                .map(|(key, _)| key.clone())
866                .ok_or_else(|| {
867                    Error::Module(format!("npm package is not prepared: {specifier}"))
868                })?;
869            return ResolvedRequest::npm(resolved, specifier, Some(key))
870                .map_err(ResolutionFailure::Other);
871        }
872        let resolved = self.npm.resolve_from_file(specifier, path, mode)?;
873        ResolvedRequest::npm(resolved, specifier, None).map_err(ResolutionFailure::Other)
874    }
875
876    async fn resolve_npm_specifier(
877        &self,
878        specifier: &str,
879        referrer: &Referrer,
880        mode: ResolutionMode,
881        type_root: bool,
882    ) -> Result<ResolvedRequest, ResolutionFailure> {
883        if referrer.path.is_some() && !referrer.remote && !(type_root && mode.is_type()) {
884            let handle = self
885                .prepare_npm_for_config(&referrer.name, std::slice::from_ref(&specifier.to_owned()))
886                .await?;
887            let resolved = self.npm.resolve_in(&handle, specifier, mode)?;
888            let key = self.npm_root_key(&handle, specifier, &resolved);
889            return ResolvedRequest::npm(resolved, specifier, Some(key))
890                .map_err(ResolutionFailure::Other);
891        }
892        let request = crate::registry::npm::parse_npm_request(specifier)
893            .map_err(|error| Error::Module(error.to_string()))?;
894        if !mode.is_type() {
895            let resolved = self
896                .npm
897                .resolve_request_parsed(&request, mode)
898                .await
899                .map_err(|error| Error::Module(error.to_string()))?;
900            let tracked = self
901                .npm
902                .tracking_key(&request.request_key(), &resolved.instance)
903                .unwrap_or_else(|| request.request_key());
904            return ResolvedRequest::npm(resolved, specifier, Some(tracked))
905                .map_err(ResolutionFailure::Other);
906        }
907        let context = self.types.context.borrow().clone();
908        let handle = self
909            .npm
910            .prepare(&[NpmRootRequest {
911                request: request.request_key(),
912                context,
913            }])
914            .await
915            .map_err(|error| Error::Module(error.to_string()))?;
916        let resolved = self.npm.resolve_in(&handle, specifier, mode)?;
917        let tracked = self.npm_root_key(&handle, specifier, &resolved);
918        ResolvedRequest::npm(resolved, specifier, Some(tracked)).map_err(ResolutionFailure::Other)
919    }
920
921    /// The exact recorded root key behind a handle resolution, so lock
922    /// tracking keeps successful contexts only.
923    fn npm_root_key(
924        &self,
925        handle: &PreparedNpmRoots,
926        specifier: &str,
927        resolved: &ResolvedNpmModule,
928    ) -> String {
929        let request =
930            crate::registry::npm::parse_npm_request(specifier).map(|request| request.request_key());
931        let request = request.unwrap_or_else(|_| specifier.to_owned());
932        handle
933            .keys_for_request(&request)
934            .into_iter()
935            .find(|key| {
936                handle
937                    .roots
938                    .get(key)
939                    .is_some_and(|instance| instance == &resolved.instance)
940            })
941            .unwrap_or(request)
942    }
943
944    /// Synchronous metadata-only npm resolution: prepared handles first,
945    /// then globally prepared roots. Never performs I/O.
946    pub(crate) fn resolve_import_meta_npm(
947        &self,
948        specifier: &str,
949        name: &str,
950        remote: bool,
951    ) -> Result<String, Error> {
952        let request = crate::registry::npm::parse_npm_request(specifier)
953            .map_err(|error| Error::Module(error.to_string()))?;
954        if !remote
955            && data_url(name).is_none()
956            && let Ok(root) = self.packages.root(name)
957            && let Some(handle) = self.npm_handles.borrow().get(&root)
958            && let Ok(resolved) = self
959                .npm
960                .resolve_in(handle, specifier, ResolutionMode::Import)
961        {
962            let path = resolved
963                .path
964                .to_str()
965                .ok_or_else(|| Error::Module("Invalid module path".into()))?;
966            return self
967                .module_url(path)
968                .ok_or_else(|| Error::Module("Invalid module path".into()));
969        }
970        let path = self
971            .npm
972            .resolve_import_meta_parsed(&request)
973            .map_err(|error| Error::Module(error.to_string()))?;
974        self.module_url(&path)
975            .ok_or_else(|| Error::Module("Invalid module path".into()))
976    }
977}
978
979pub(crate) fn lexical_absolute(path: &Path) -> Result<PathBuf, Error> {
980    if !path.is_absolute() {
981        return Err(Error::Module("Expected an absolute module path".into()));
982    }
983    let mut normalized = PathBuf::new();
984    for component in path.components() {
985        match component {
986            std::path::Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
987            std::path::Component::RootDir => normalized.push(component.as_os_str()),
988            std::path::Component::CurDir => {}
989            std::path::Component::ParentDir => {
990                normalized.pop();
991            }
992            std::path::Component::Normal(part) => normalized.push(part),
993        }
994    }
995    Ok(normalized)
996}
997
998pub fn data_url(name: &str) -> Option<url::Url> {
999    url::Url::parse(name)
1000        .ok()
1001        .filter(|url| url.scheme() == "data")
1002}
1003
1004pub fn http_url(name: &str) -> Option<url::Url> {
1005    url::Url::parse(name)
1006        .ok()
1007        .filter(|url| matches!(url.scheme(), "http" | "https"))
1008}
1009
1010/// An offline graph over a scratch project directory, for tests that never
1011/// touch the network: registries point at `.invalid`, the lock is never
1012/// written, and the runtime is returned so it outlives the graph.
1013#[cfg(test)]
1014pub(crate) fn offline_graph(dir: &Path) -> (tokio::runtime::Runtime, ModuleGraph) {
1015    let caps = crate::caps::Capabilities::new(crate::caps::Initial::new(false), dir.to_owned());
1016    let authority = crate::authority::SourceAuthority::execute(caps);
1017    let runtime = tokio::runtime::Builder::new_multi_thread()
1018        .worker_threads(2)
1019        .enable_all()
1020        .build()
1021        .unwrap();
1022    let graph = ModuleGraph::open(
1023        runtime.handle().clone(),
1024        authority,
1025        crate::registry::Options {
1026            jsr_registry: url::Url::parse("https://example.invalid/").unwrap(),
1027            npm_registry: url::Url::parse("https://example.invalid/").unwrap(),
1028            cache: dir.join("cache"),
1029            lock: dir.join("avril.lock"),
1030            project_root: dir.to_owned(),
1031            offline: true,
1032            frozen: false,
1033            update: false,
1034            ephemeral_lock: false,
1035            vendor: None,
1036        },
1037    )
1038    .unwrap();
1039    (runtime, graph)
1040}