char/avril

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

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

Charlotte Somexperiment: automatic types for npm packages51266f9

main
39.3 KiB1099 linesraw
1use std::collections::{BTreeMap, BTreeSet};
2use std::sync::Arc;
3
4use super::{validate_npm_name, validate_npm_version};
5/// Failures reading npm package manifests.
6/// Resolution failures live in the resolver's own error type.
7#[derive(Clone, Debug, thiserror::Error)]
8pub enum PackageError {
9    #[error("npm package.json is missing its name")]
10    NpmPackageJsonMissingName,
11    #[error("npm package.json is missing its version")]
12    NpmPackageJsonMissingVersion,
13    #[error("npm package.json does not match its archive identity")]
14    NpmPackageJsonIdentityMismatch,
15    #[error("Unsupported package type: {other}")]
16    UnsupportedPackageType { other: String },
17    #[error("npm dependencies must be an object")]
18    NpmDependenciesNotObject,
19    #[error("npm dependency {key:?} must be a version range")]
20    NpmDependencyNotRange { key: String },
21    #[error("npm optional dependency {key:?} must be a version range")]
22    NpmOptionalDependencyNotRange { key: String },
23    #[error("npm dependency {key:?} has an invalid alias")]
24    NpmDependencyInvalidAlias { key: String },
25    #[error("npm dependency {key:?} has an invalid version range")]
26    NpmDependencyInvalidRange { key: String },
27    #[error("Invalid npm os field: {detail}")]
28    InvalidNpmOsField { detail: String },
29    #[error("Invalid npm cpu field: {detail}")]
30    InvalidNpmCpuField { detail: String },
31    #[error("npm optionalDependencies must be an object")]
32    NpmOptionalDependenciesNotObject,
33    #[error("npm peerDependencies must be an object")]
34    NpmPeerDependenciesNotObject,
35    #[error("npm peer dependency {key:?} must be a version range")]
36    NpmPeerDependencyNotRange { key: String },
37    #[error("npm {key:?} must be a path string")]
38    NpmMustBePathString { key: String },
39    #[error("npm dependency {key:?} has an empty alias")]
40    NpmDependencyEmptyAlias { key: String },
41    #[error("npm dependency {key:?} alias must not have a subpath")]
42    NpmDependencyAliasSubpath { key: String },
43
44    #[error("npm peer dependency {key:?} uses unsupported protocol {protocol:?}")]
45    NpmPeerDependencyProtocol { key: String, protocol: String },
46    #[error("npm exports mixes subpath and condition keys")]
47    NpmExportsMixesKeys,
48    #[error("Invalid npm exports field")]
49    InvalidNpmExportsField,
50    #[error("npm imports must be an object")]
51    NpmImportsNotObject,
52    #[error("npm imports keys must start with '#'")]
53    NpmImportsKeyPrefix,
54    #[error("npm platform entries must be strings")]
55    NpmPlatformEntriesNotStrings,
56    #[error("npm platform conditions must be a string or array")]
57    NpmPlatformConditionsType,
58    #[error("npm platform conditions must not be empty")]
59    NpmPlatformConditionsEmpty,
60    #[error("npm platform exclusion must name a platform")]
61    NpmPlatformExclusionUnnamed,
62    #[error("Invalid npm export subpath: {subpath}")]
63    InvalidNpmExportSubpath { subpath: String },
64    #[error("npm export \".\" is blocked")]
65    NpmExportRootBlocked,
66    #[error("Invalid npm export pattern: {key}")]
67    InvalidNpmExportPattern { key: String },
68    #[error("npm export {subpath:?} is blocked")]
69    NpmExportBlocked { subpath: String },
70    #[error("npm export target escapes its package: {target:?}")]
71    NpmExportTargetEscapes { target: String },
72    #[error("Invalid npm exports entry")]
73    InvalidNpmExportsEntry,
74    #[error("invalid npm condition key: {key}")]
75    InvalidNpmConditionKey { key: String },
76    #[error("Invalid npm imports pattern: {key}")]
77    InvalidNpmImportsPattern { key: String },
78    #[error("npm import {specifier:?} is blocked")]
79    NpmImportBlocked { specifier: String },
80    #[error("npm import is blocked")]
81    NpmImportBlockedBare,
82    #[error("npm imports target escapes its package: {target:?}")]
83    NpmImportsTargetEscapes { target: String },
84    #[error("npm imports mixes subpath and condition keys")]
85    NpmImportsMixesKeys,
86    #[error("Invalid npm imports entry")]
87    InvalidNpmImportsEntry,
88    #[error("Invalid npm imports target")]
89    InvalidNpmImportsTarget,
90    #[error("npm imports target escapes its package")]
91    NpmImportsTargetEscapesBare,
92    #[error("npm imports target {target:?} is not a declared dependency")]
93    NpmImportsTargetUndeclared { target: String },
94    #[error("Invalid package type in {path}")]
95    InvalidPackageTypeIn { path: String },
96    #[error("Unsupported package type in {path}: {text}")]
97    UnsupportedPackageTypeIn { path: String, text: String },
98
99    #[error("{detail}")]
100    NpmUnsupportedModule { detail: String },
101    #[error("Invalid nested npm package.json {path}: {source}")]
102    InvalidNestedNpmPackageJson {
103        path: String,
104        source: Arc<serde_json::Error>,
105    },
106    #[error(transparent)]
107    Npm(Box<super::NpmError>),
108}
109
110impl From<super::NpmError> for PackageError {
111    fn from(error: super::NpmError) -> Self {
112        Self::Npm(Box::new(error))
113    }
114}
115
116/// Parsed view of one published `package.json`. The ordered document is kept
117/// out of this struct: resolution reads `exports`/`imports` from the verified
118/// bytes through [`NpmManifest::exports_raw`]/[`imports_raw`] owners instead.
119#[derive(Clone, Debug, PartialEq)]
120pub struct NpmManifest {
121    pub name: String,
122    pub version: String,
123    pub package_type: NpmPackageType,
124    pub main: Option<String>,
125    pub module: Option<String>,
126    pub types: Option<serde_json::Value>,
127    pub typings: Option<serde_json::Value>,
128    pub types_versions: Option<serde_json::Value>,
129    pub exports_raw: Option<serde_json::Value>,
130    pub imports_raw: Option<serde_json::Value>,
131    pub dependencies: BTreeMap<String, NpmDep>,
132    pub peers: BTreeMap<String, NpmPeer>,
133    pub os: Option<PlatformRule>,
134    pub cpu: Option<PlatformRule>,
135    /// Bundler-only hints, kept raw so malformed values never break execution.
136    pub browser: Option<serde_json::Value>,
137    pub side_effects: Option<serde_json::Value>,
138}
139
140#[derive(Clone, Copy, Debug, PartialEq, Eq)]
141pub enum NpmPackageType {
142    Module,
143    CommonJs,
144}
145
146#[derive(Clone, Debug, PartialEq)]
147pub struct NpmDep {
148    pub kind: NpmDepKind,
149    pub optional: bool,
150}
151
152#[derive(Clone, Debug, PartialEq)]
153pub enum NpmDepKind {
154    Registry { request: String },
155    Alias { target: String },
156    Unsupported { protocol: String },
157}
158
159#[derive(Clone, Debug, PartialEq)]
160pub struct NpmPeer {
161    pub range: String,
162    pub optional: bool,
163}
164
165#[derive(Clone, Debug, PartialEq)]
166pub struct PlatformRule {
167    pub allow: Vec<String>,
168    pub deny: Vec<String>,
169}
170
171#[derive(Clone, Copy, Debug, PartialEq, Eq)]
172pub enum NpmModuleKind {
173    Esm,
174    TypeScript,
175    Declaration,
176    CommonJs,
177    Json,
178}
179
180#[derive(Clone, Debug)]
181pub enum NpmUnsupported {
182    Native { path: String },
183    Extension { path: String },
184}
185
186impl std::fmt::Display for NpmUnsupported {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        match self {
189            Self::Native { path } => write!(f, "Native addons are not supported: {path}"),
190            Self::Extension { path } => write!(f, "Unsupported module extension: {path}"),
191        }
192    }
193}
194
195impl std::error::Error for NpmUnsupported {}
196
197pub fn parse_npm_manifest(
198    name: &str,
199    version: &str,
200    value: &serde_json::Value,
201) -> Result<NpmManifest, PackageError> {
202    let published_name = value
203        .get("name")
204        .and_then(|value| value.as_str())
205        .ok_or(PackageError::NpmPackageJsonMissingName)?;
206    let published_version = value
207        .get("version")
208        .and_then(|value| value.as_str())
209        .ok_or(PackageError::NpmPackageJsonMissingVersion)?;
210    if published_name != name || published_version != version {
211        return Err(PackageError::NpmPackageJsonIdentityMismatch);
212    }
213    validate_npm_name(published_name)?;
214    validate_npm_version(published_version)?;
215    let package_type = match value.get("type").and_then(|value| value.as_str()) {
216        None => NpmPackageType::CommonJs,
217        Some("module") => NpmPackageType::Module,
218        Some("commonjs") => NpmPackageType::CommonJs,
219        Some(other) => {
220            return Err(PackageError::UnsupportedPackageType { other: other.to_owned() });
221        }
222    };
223    let main = read_entry_string(value, "main")?;
224    let module = read_entry_string(value, "module")?;
225    let types = value.get("types").cloned();
226    let typings = value.get("typings").cloned();
227    let types_versions = value.get("typesVersions").cloned();
228    let exports_raw = value.get("exports").cloned();
229    if let Some(exports) = &exports_raw {
230        validate_exports_shape(exports)?;
231    }
232    let imports_raw = value.get("imports").cloned();
233    if let Some(imports) = &imports_raw {
234        validate_imports_shape(imports)?;
235    }
236    let mut dependencies = BTreeMap::new();
237    if let Some(table) = value.get("dependencies") {
238        let table = table
239            .as_object()
240            .ok_or(PackageError::NpmDependenciesNotObject)?;
241        for (key, spec) in table {
242            let spec = spec
243                .as_str()
244                .ok_or_else(|| PackageError::NpmDependencyNotRange { key: key.to_owned() })?;
245            dependencies.insert(
246                key.clone(),
247                NpmDep {
248                    kind: parse_dep_spec(key, spec)?,
249                    optional: false,
250                },
251            );
252        }
253    }
254    if let Some(table) = value.get("optionalDependencies") {
255        let table = table
256            .as_object()
257            .ok_or(PackageError::NpmOptionalDependenciesNotObject)?;
258        for (key, spec) in table {
259            let spec =
260                spec.as_str()
261                    .ok_or_else(|| PackageError::NpmOptionalDependencyNotRange {
262                        key: key.to_owned(),
263                    })?;
264            dependencies.insert(
265                key.clone(),
266                NpmDep {
267                    kind: parse_dep_spec(key, spec)?,
268                    optional: true,
269                },
270            );
271        }
272    }
273    let mut peers = BTreeMap::new();
274    if let Some(table) = value.get("peerDependencies") {
275        let table = table
276            .as_object()
277            .ok_or(PackageError::NpmPeerDependenciesNotObject)?;
278        let meta = value
279            .get("peerDependenciesMeta")
280            .and_then(|value| value.as_object());
281        for (key, range) in table {
282            let range = range
283                .as_str()
284                .ok_or_else(|| PackageError::NpmPeerDependencyNotRange { key: key.to_owned() })?;
285            // Peer ranges stay textual until selection; only the protocol shape
286            // is rejected here so later chunks see the declared constraint.
287            peer_range_supported(key, range)?;
288            let optional = meta
289                .and_then(|meta| meta.get(key))
290                .and_then(|entry| entry.get("optional"))
291                .and_then(|value| value.as_bool())
292                .unwrap_or(false);
293            peers.insert(
294                key.clone(),
295                NpmPeer {
296                    range: range.to_owned(),
297                    optional,
298                },
299            );
300        }
301    }
302    let os = value
303        .get("os")
304        .map(parse_platform_rule)
305        .transpose()
306        .map_err(|error| PackageError::InvalidNpmOsField { detail: error.to_string() })?;
307    let cpu = value
308        .get("cpu")
309        .map(parse_platform_rule)
310        .transpose()
311        .map_err(|error| PackageError::InvalidNpmCpuField { detail: error.to_string() })?;
312    Ok(NpmManifest {
313        name: name.to_owned(),
314        version: version.to_owned(),
315        package_type,
316        main,
317        module,
318        types,
319        typings,
320        types_versions,
321        exports_raw,
322        imports_raw,
323        dependencies,
324        peers,
325        os,
326        cpu,
327        browser: value.get("browser").cloned(),
328        side_effects: value.get("sideEffects").cloned(),
329    })
330}
331
332/// Whether `sideEffects` declares side effects for a package-relative file,
333/// matched like Rolldown does. Unknown shapes and invalid patterns are `None`.
334pub fn declared_side_effects(field: &serde_json::Value, rel: &str) -> Option<bool> {
335    let patterns = match field {
336        serde_json::Value::Bool(value) => return Some(*value),
337        serde_json::Value::String(_) => std::slice::from_ref(field),
338        serde_json::Value::Array(patterns) => patterns.as_slice(),
339        _ => return None,
340    };
341    let mut matched = false;
342    for pattern in patterns {
343        let pattern = pattern.as_str()?;
344        let trimmed = pattern.trim_start_matches("./");
345        let mut glob = if trimmed.len() == pattern.len() && trimmed.contains('/') {
346            trimmed.to_owned()
347        } else {
348            format!("**/{trimmed}")
349        };
350        // The field is an allowlist: a leading `!` is a literal file name.
351        if glob.starts_with('!') {
352            glob.insert(0, '\\');
353        }
354        fast_glob::validate(&glob).ok()?;
355        matched |= fast_glob::glob_match(&glob, rel);
356    }
357    Some(matched)
358}
359
360fn read_entry_string(value: &serde_json::Value, key: &str) -> Result<Option<String>, PackageError> {
361    match value.get(key) {
362        None => Ok(None),
363        // Subpath-only packages use main: false alongside explicit exports.
364        Some(serde_json::Value::Bool(false)) if key == "main" => Ok(None),
365        Some(entry) => entry
366            .as_str()
367            .map(|entry| Some(entry.to_owned()))
368            .ok_or_else(|| PackageError::NpmMustBePathString { key: key.to_owned() }),
369    }
370}
371
372pub fn parse_dep_spec(key: &str, spec: &str) -> Result<NpmDepKind, PackageError> {
373    if let Some(target) = spec.strip_prefix("npm:") {
374        if target.is_empty() {
375            return Err(PackageError::NpmDependencyEmptyAlias { key: key.to_owned() });
376        }
377        let reference = super::parse_npm_request(&format!("npm:{target}"))
378            .map_err(|_| PackageError::NpmDependencyInvalidAlias { key: key.to_owned() })?;
379        if reference.subpath.is_some() {
380            return Err(PackageError::NpmDependencyAliasSubpath { key: key.to_owned() });
381        }
382        return Ok(NpmDepKind::Alias { target: reference.request_key() });
383    }
384    if let Some((protocol, _)) = spec.split_once(':')
385        && !protocol.contains('/')
386        && !protocol.contains('@')
387    {
388        return Ok(NpmDepKind::Unsupported { protocol: protocol.to_owned() });
389    }
390    let request = if spec.is_empty() || spec == "*" {
391        format!("npm:{key}")
392    } else {
393        format!("npm:{key}@{spec}")
394    };
395    super::parse_npm_request(&request)
396        .map_err(|_| PackageError::NpmDependencyInvalidRange { key: key.to_owned() })?;
397    Ok(NpmDepKind::Registry { request })
398}
399
400fn peer_range_supported(key: &str, range: &str) -> Result<(), PackageError> {
401    if range.is_empty() {
402        return Err(PackageError::NpmPeerDependencyNotRange { key: key.to_owned() });
403    }
404    if let Some((protocol, _)) = range.split_once(':')
405        && !protocol.contains('/')
406        && !protocol.contains('@')
407    {
408        return Err(PackageError::NpmPeerDependencyProtocol {
409            key: key.to_owned(),
410            protocol: protocol.to_owned(),
411        });
412    }
413    Ok(())
414}
415
416fn validate_exports_shape(exports: &serde_json::Value) -> Result<(), PackageError> {
417    match exports {
418        serde_json::Value::Null | serde_json::Value::String(_) => Ok(()),
419        serde_json::Value::Array(_) => Ok(()),
420        serde_json::Value::Object(map) => {
421            let subpaths = map.keys().filter(|key| key.starts_with('.')).count();
422            if subpaths > 0 && subpaths != map.len() {
423                return Err(PackageError::NpmExportsMixesKeys);
424            }
425            Ok(())
426        }
427        _ => Err(PackageError::InvalidNpmExportsField),
428    }
429}
430
431fn validate_imports_shape(imports: &serde_json::Value) -> Result<(), PackageError> {
432    let map = imports
433        .as_object()
434        .ok_or(PackageError::NpmImportsNotObject)?;
435    for key in map.keys() {
436        if !key.starts_with('#') {
437            return Err(PackageError::NpmImportsKeyPrefix);
438        }
439    }
440    Ok(())
441}
442
443fn parse_platform_rule(value: &serde_json::Value) -> Result<PlatformRule, PackageError> {
444    let entries: Vec<String> = match value {
445        serde_json::Value::String(entry) => vec![entry.clone()],
446        serde_json::Value::Array(entries) => entries
447            .iter()
448            .map(|entry| {
449                entry
450                    .as_str()
451                    .map(str::to_owned)
452                    .ok_or(PackageError::NpmPlatformEntriesNotStrings)
453            })
454            .collect::<Result<_, _>>()?,
455        _ => return Err(PackageError::NpmPlatformConditionsType),
456    };
457    if entries.is_empty() {
458        return Err(PackageError::NpmPlatformConditionsEmpty);
459    }
460    let mut allow = Vec::new();
461    let mut deny = Vec::new();
462    for entry in entries {
463        if let Some(excluded) = entry.strip_prefix('!') {
464            if excluded.is_empty() {
465                return Err(PackageError::NpmPlatformExclusionUnnamed);
466            }
467            deny.push(excluded.to_owned());
468        } else {
469            allow.push(entry);
470        }
471    }
472    Ok(PlatformRule { allow, deny })
473}
474
475pub fn platform_satisfied(rule: Option<&PlatformRule>, host: &str) -> bool {
476    let Some(rule) = rule else {
477        return true;
478    };
479    if rule.deny.iter().any(|entry| entry == host) {
480        return false;
481    }
482    if rule.allow.is_empty() {
483        return true;
484    }
485    rule.allow.iter().any(|entry| entry == host)
486}
487
488pub fn npm_host_os() -> &'static str {
489    match std::env::consts::OS {
490        "macos" => "darwin",
491        "windows" => "win32",
492        other => other,
493    }
494}
495
496pub fn npm_host_arch() -> &'static str {
497    match std::env::consts::ARCH {
498        "x86_64" => "x64",
499        "aarch64" => "arm64",
500        "x86" => "ia32",
501        other => other,
502    }
503}
504
505/// Ordered condition set for this runtime. `node`, `require`, and `browser`
506/// are deliberately absent: only ESM graphs resolve here.
507pub fn npm_conditions() -> [&'static str; 4] {
508    ["avril", "import", "module", "default"]
509}
510
511/// Ordered condition set for synchronous `require` graphs. `import`,
512/// `module`, and `node` are deliberately absent: `require` selects its own
513/// branch independently from `import`.
514pub fn npm_conditions_require() -> [&'static str; 3] {
515    ["avril", "require", "default"]
516}
517
518/// Resolve `exports` for one subpath (`"."` or `"./…"`). `Ok(None)` means no
519/// entry matched; an exports map that is present never falls back to private
520/// files, so callers must turn `None` into an error.
521pub fn resolve_exports_target(
522    exports: &serde_json::Value,
523    subpath: &str,
524    conditions: &[&str],
525) -> Result<Option<String>, PackageError> {
526    if !subpath.starts_with("./") && subpath != "." {
527        return Err(PackageError::InvalidNpmExportSubpath { subpath: subpath.to_owned() });
528    }
529    match exports {
530        serde_json::Value::Null => Ok(None),
531        serde_json::Value::String(target) => {
532            if subpath != "." {
533                return Ok(None);
534            }
535            Ok(Some(target.clone()))
536        }
537        serde_json::Value::Array(_) => {
538            if subpath != "." {
539                return Ok(None);
540            }
541            match resolve_match(exports, conditions, None, true)? {
542                Match::Hit(resolved) => Ok(Some(resolved)),
543                // Unreachable: array nulls skip inside resolve_match.
544                Match::Blocked => Err(PackageError::NpmExportRootBlocked),
545                Match::Miss => Ok(None),
546            }
547        }
548        serde_json::Value::Object(map) => {
549            let subpaths = map.keys().filter(|key| key.starts_with('.')).count();
550            if subpaths > 0 && subpaths != map.len() {
551                return Err(PackageError::NpmExportsMixesKeys);
552            }
553            if subpaths > 0 {
554                if let Some(target) = map.get(subpath) {
555                    return match resolve_match(target, conditions, None, true)? {
556                        Match::Hit(resolved) => Ok(Some(resolved)),
557                        Match::Blocked => {
558                            Err(PackageError::NpmExportBlocked { subpath: subpath.to_owned() })
559                        }
560                        Match::Miss => Ok(None),
561                    };
562                }
563                let mut best: Option<(usize, usize, &str)> = None;
564                for key in map.keys() {
565                    let Some((prefix, suffix)) = key.split_once('*') else {
566                        continue;
567                    };
568                    if key[prefix.len() + 1..].contains('*') {
569                        return Err(PackageError::InvalidNpmExportPattern { key: key.to_owned() });
570                    }
571                    if !subpath.starts_with(prefix) || !subpath.ends_with(suffix) {
572                        continue;
573                    }
574                    if prefix.len() + suffix.len() > subpath.len() {
575                        continue;
576                    }
577                    let candidate = (prefix.len(), suffix.len(), key.as_str());
578                    if best.is_none_or(|current| {
579                        candidate.0 > current.0
580                            || (candidate.0 == current.0 && candidate.1 > current.1)
581                    }) {
582                        best = Some(candidate);
583                    }
584                }
585                let Some((_, _, key)) = best else {
586                    return Ok(None);
587                };
588                let (prefix, suffix) = key.split_once('*').unwrap();
589                let captured = &subpath[prefix.len()..subpath.len() - suffix.len()];
590                match resolve_match(&map[key], conditions, Some(captured), true)? {
591                    Match::Hit(resolved) => Ok(Some(resolved)),
592                    Match::Blocked => {
593                        Err(PackageError::NpmExportBlocked { subpath: subpath.to_owned() })
594                    }
595                    Match::Miss => Ok(None),
596                }
597            } else if subpath != "." {
598                Ok(None)
599            } else {
600                match resolve_match(exports, conditions, None, true)? {
601                    Match::Hit(resolved) => Ok(Some(resolved)),
602                    Match::Blocked => Err(PackageError::NpmExportRootBlocked),
603                    Match::Miss => Ok(None),
604                }
605            }
606        }
607        _ => Err(PackageError::InvalidNpmExportsField),
608    }
609}
610
611enum Match {
612    Hit(String),
613    Blocked,
614    Miss,
615}
616
617/// Target shape validation during selection so arrays can skip bad entries
618/// instead of failing the whole list at the final caller.
619fn validate_export_target(target: &str) -> Result<(), PackageError> {
620    let rel = target
621        .strip_prefix("./")
622        .ok_or_else(|| PackageError::NpmExportTargetEscapes { target: target.to_owned() })?;
623    super::validate_npm_relpath(rel)?;
624    if rel.split('/').any(|part| part == "node_modules") {
625        return Err(PackageError::NpmExportTargetEscapes { target: target.to_owned() });
626    }
627    Ok(())
628}
629
630/// Evaluate one exports value. Conditions follow the package document's own
631/// key order against the active set; a directly selected `null` blocks
632/// distinctly instead of falling through, while arrays skip misses.
633fn resolve_match(
634    value: &serde_json::Value,
635    conditions: &[&str],
636    captured: Option<&str>,
637    strict: bool,
638) -> Result<Match, PackageError> {
639    let substitute = |target: &str| match captured {
640        Some(part) => target.replace('*', part),
641        None => target.to_owned(),
642    };
643    match value {
644        serde_json::Value::Null => Ok(Match::Blocked),
645        serde_json::Value::String(target) => {
646            let target = substitute(target);
647            match validate_export_target(&target) {
648                Ok(()) => Ok(Match::Hit(target)),
649                Err(_) if !strict => Ok(Match::Miss),
650                Err(error) => Err(error),
651            }
652        }
653        serde_json::Value::Array(targets) => {
654            for target in targets {
655                match resolve_match(target, conditions, captured, false)? {
656                    Match::Hit(resolved) => return Ok(Match::Hit(resolved)),
657                    Match::Blocked => continue,
658                    Match::Miss => continue,
659                }
660            }
661            Ok(Match::Miss)
662        }
663        serde_json::Value::Object(map) => {
664            for (key, target) in map {
665                if key.starts_with('.') {
666                    return Err(PackageError::NpmExportsMixesKeys);
667                }
668                if key.is_empty()
669                    || key.starts_with('#')
670                    || key.bytes().all(|byte| byte.is_ascii_digit())
671                {
672                    return Err(PackageError::InvalidNpmConditionKey { key: key.to_owned() });
673                }
674                if !conditions.contains(&key.as_str()) {
675                    continue;
676                }
677                match resolve_match(target, conditions, captured, strict)? {
678                    Match::Hit(resolved) => return Ok(Match::Hit(resolved)),
679                    Match::Blocked => return Ok(Match::Blocked),
680                    Match::Miss => continue,
681                }
682            }
683            Ok(Match::Miss)
684        }
685        _ => Err(PackageError::InvalidNpmExportsEntry),
686    }
687}
688
689pub enum NpmImportsTarget {
690    External { request: String },
691    File { path: String },
692}
693
694/// Resolve an `#…` specifier through the owning package's `imports` map.
695/// Bare-word targets must name a declared dependency of the same package.
696pub fn resolve_imports_target(
697    imports: &serde_json::Value,
698    specifier: &str,
699    conditions: &[&str],
700    declared: &BTreeSet<String>,
701) -> Result<Option<NpmImportsTarget>, PackageError> {
702    let map = imports
703        .as_object()
704        .ok_or(PackageError::NpmImportsNotObject)?;
705    if let Some(target) = map.get(specifier) {
706        return imports_value_target(target, conditions, declared);
707    }
708    let mut best: Option<(usize, usize, &str)> = None;
709    for key in map.keys() {
710        if !key.starts_with('#') {
711            return Err(PackageError::NpmImportsKeyPrefix);
712        }
713        let Some((prefix, suffix)) = key.split_once('*') else {
714            continue;
715        };
716        if key[prefix.len() + 1..].contains('*') {
717            return Err(PackageError::InvalidNpmImportsPattern { key: key.to_owned() });
718        }
719        if !specifier.starts_with(prefix) || !specifier.ends_with(suffix) {
720            continue;
721        }
722        if prefix.len() + suffix.len() > specifier.len() {
723            continue;
724        }
725        let candidate = (prefix.len(), suffix.len(), key.as_str());
726        if best.is_none_or(|current| {
727            candidate.0 > current.0 || (candidate.0 == current.0 && candidate.1 > current.1)
728        }) {
729            best = Some(candidate);
730        }
731    }
732    let Some((_, _, key)) = best else {
733        return Ok(None);
734    };
735    let (prefix, suffix) = key.split_once('*').unwrap();
736    let captured = &specifier[prefix.len()..specifier.len() - suffix.len()];
737    match imports_match(&map[key], conditions, Some(captured), declared, true)? {
738        ImportsMatch::Hit(target) => Ok(Some(target)),
739        ImportsMatch::Blocked => {
740            Err(PackageError::NpmImportBlocked { specifier: specifier.to_owned() })
741        }
742        ImportsMatch::Miss => Ok(None),
743    }
744}
745
746enum ImportsMatch {
747    Hit(NpmImportsTarget),
748    Blocked,
749    Miss,
750}
751
752fn imports_value_target(
753    value: &serde_json::Value,
754    conditions: &[&str],
755    declared: &BTreeSet<String>,
756) -> Result<Option<NpmImportsTarget>, PackageError> {
757    match imports_match(value, conditions, None, declared, true)? {
758        ImportsMatch::Hit(target) => Ok(Some(target)),
759        ImportsMatch::Blocked => Err(PackageError::NpmImportBlockedBare),
760        ImportsMatch::Miss => Ok(None),
761    }
762}
763
764fn validate_imports_file(target: &str) -> Result<(), PackageError> {
765    let rel = target
766        .strip_prefix("./")
767        .ok_or_else(|| PackageError::NpmImportsTargetEscapes { target: target.to_owned() })?;
768    super::validate_npm_relpath(rel)?;
769    if rel.split('/').any(|part| part == "node_modules") {
770        return Err(PackageError::NpmImportsTargetEscapes { target: target.to_owned() });
771    }
772    Ok(())
773}
774
775fn imports_match(
776    value: &serde_json::Value,
777    conditions: &[&str],
778    captured: Option<&str>,
779    declared: &BTreeSet<String>,
780    strict: bool,
781) -> Result<ImportsMatch, PackageError> {
782    let substitute = |target: &str| match captured {
783        Some(part) => target.replace('*', part),
784        None => target.to_owned(),
785    };
786    match value {
787        serde_json::Value::Null => Ok(ImportsMatch::Blocked),
788        serde_json::Value::String(target) => {
789            let target = substitute(target);
790            if target.starts_with('.') {
791                match validate_imports_file(&target) {
792                    Ok(()) => {}
793                    Err(_) if !strict => return Ok(ImportsMatch::Miss),
794                    Err(error) => return Err(error),
795                }
796            }
797            imports_string_target(&target, declared)
798                .map(|hit| hit.map_or(ImportsMatch::Miss, ImportsMatch::Hit))
799        }
800        serde_json::Value::Array(targets) => {
801            for target in targets {
802                match imports_match(target, conditions, captured, declared, false)? {
803                    ImportsMatch::Hit(resolved) => return Ok(ImportsMatch::Hit(resolved)),
804                    ImportsMatch::Blocked => continue,
805                    ImportsMatch::Miss => continue,
806                }
807            }
808            Ok(ImportsMatch::Miss)
809        }
810        serde_json::Value::Object(map) => {
811            for (key, target) in map {
812                if key.starts_with('.') || key.starts_with('#') {
813                    return Err(PackageError::NpmImportsMixesKeys);
814                }
815                if key.is_empty() || key.bytes().all(|byte| byte.is_ascii_digit()) {
816                    return Err(PackageError::InvalidNpmConditionKey { key: key.to_owned() });
817                }
818                if !conditions.contains(&key.as_str()) {
819                    continue;
820                }
821                match imports_match(target, conditions, captured, declared, strict)? {
822                    ImportsMatch::Hit(resolved) => return Ok(ImportsMatch::Hit(resolved)),
823                    ImportsMatch::Blocked => return Ok(ImportsMatch::Blocked),
824                    ImportsMatch::Miss => continue,
825                }
826            }
827            Ok(ImportsMatch::Miss)
828        }
829        _ => Err(PackageError::InvalidNpmImportsEntry),
830    }
831}
832
833fn imports_string_target(
834    target: &str,
835    declared: &BTreeSet<String>,
836) -> Result<Option<NpmImportsTarget>, PackageError> {
837    if let Some(path) = target.strip_prefix("./") {
838        if path.is_empty() {
839            return Err(PackageError::InvalidNpmImportsTarget);
840        }
841        return Ok(Some(NpmImportsTarget::File { path: path.to_owned() }));
842    }
843    if target.starts_with("../") || target == ".." {
844        return Err(PackageError::NpmImportsTargetEscapesBare);
845    }
846    let (name, _) = split_bare(target);
847    if !declared.contains(name) {
848        return Err(PackageError::NpmImportsTargetUndeclared { target: target.to_owned() });
849    }
850    Ok(Some(NpmImportsTarget::External {
851        request: format!("npm:{target}"),
852    }))
853}
854
855pub fn types_package_name(name: &str) -> String {
856    match name.strip_prefix('@') {
857        Some(scoped) => format!("@types/{}", scoped.replace('/', "__")),
858        None => format!("@types/{name}"),
859    }
860}
861
862/// Split a bare specifier into its package name and trailing subpath.
863pub fn split_bare(specifier: &str) -> (&str, Option<&str>) {
864    if let Some(rest) = specifier.strip_prefix('@') {
865        match rest.split_once('/') {
866            None => (specifier, None),
867            Some((scope, tail)) => match tail.split_once('/') {
868                None => (specifier, None),
869                Some((leaf, sub)) => {
870                    let name = &specifier[..scope.len() + leaf.len() + 2];
871                    (name, Some(sub))
872                }
873            },
874        }
875    } else {
876        match specifier.split_once('/') {
877            None => (specifier, None),
878            Some((name, sub)) => (name, Some(sub)),
879        }
880    }
881}
882
883/// Classify one manifest-relative path for execution. ESM and
884/// project-pipeline TypeScript stay executable via `import`; CommonJS and
885/// JSON are executable via `require` (and CJS additionally via ESM wrappers).
886/// Declarations stay resolvable through the editor path instead. Explicit
887/// extensions never consult package metadata.
888pub fn classify_npm_module<E: From<NpmUnsupported>>(
889    path: &str,
890    package_type: impl FnOnce() -> Result<NpmPackageType, E>,
891) -> Result<NpmModuleKind, E> {
892    if path.ends_with(".d.ts") || path.ends_with(".d.mts") || path.ends_with(".d.cts") {
893        return Ok(NpmModuleKind::Declaration);
894    }
895    if let Some(name) = path.rsplit('/').next()
896        && let Some(extension) = name.rsplit('.').next()
897        && name.contains('.')
898    {
899        match extension {
900            "mjs" | "mts" => return Ok(NpmModuleKind::Esm),
901            "cjs" => return Ok(NpmModuleKind::CommonJs),
902            // Explicit `.cts` transpiles TypeScript while preserving CJS.
903            "cts" => return Ok(NpmModuleKind::CommonJs),
904            "ts" | "tsx" | "jsx" => return Ok(NpmModuleKind::TypeScript),
905            "js" => {}
906            "json" => return Ok(NpmModuleKind::Json),
907            "node" => {
908                return Err(NpmUnsupported::Native { path: path.to_owned() }.into());
909            }
910            _ => {
911                return Err(NpmUnsupported::Extension { path: path.to_owned() }.into());
912            }
913        }
914    }
915    match package_type()? {
916        NpmPackageType::Module => Ok(NpmModuleKind::Esm),
917        // `.js` follows the nearest `type` boundary; explicit `.cjs`/`.cts`
918        // above already returned CommonJS.
919        NpmPackageType::CommonJs => Ok(NpmModuleKind::CommonJs),
920    }
921}
922
923#[derive(Debug)]
924pub(crate) struct NpmPackageScope {
925    pub package_type: Option<NpmPackageType>,
926    pub main: Option<String>,
927}
928
929pub(crate) fn parse_npm_scope(bytes: &[u8], path: &str) -> Result<NpmPackageScope, PackageError> {
930    let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|source| {
931        PackageError::InvalidNestedNpmPackageJson {
932            path: path.to_owned(),
933            source: Arc::new(source),
934        }
935    })?;
936    let package_type = match value.get("type") {
937        None => None,
938        Some(entry) => {
939            let text = entry
940                .as_str()
941                .ok_or_else(|| PackageError::InvalidPackageTypeIn { path: path.to_owned() })?;
942            Some(match text {
943                "module" => NpmPackageType::Module,
944                "commonjs" => NpmPackageType::CommonJs,
945                _ => {
946                    return Err(PackageError::UnsupportedPackageTypeIn {
947                        path: path.to_owned(),
948                        text: text.to_owned(),
949                    });
950                }
951            })
952        }
953    };
954    Ok(NpmPackageScope {
955        package_type,
956        main: value
957            .get("main")
958            .and_then(|entry| entry.as_str())
959            .map(str::to_owned),
960    })
961}
962
963#[cfg(test)]
964mod tests {
965    use super::*;
966
967    #[test]
968    fn subpath_only_packages_may_disable_main() {
969        let manifest = parse_npm_manifest(
970            "subpaths",
971            "1.0.0",
972            &serde_json::json!({
973                "name": "subpaths", "version": "1.0.0", "main": false,
974                "exports": { "./value": "./value.js" }
975            }),
976        )
977        .unwrap();
978        assert_eq!(
979            resolve_exports_target(
980                manifest.exports_raw.as_ref().unwrap(),
981                "./value",
982                &conditions()
983            )
984            .unwrap(),
985            Some("./value.js".into())
986        );
987        assert_eq!(
988            resolve_exports_target(manifest.exports_raw.as_ref().unwrap(), ".", &conditions())
989                .unwrap(),
990            None
991        );
992    }
993
994    #[test]
995    fn side_effects_match_like_rolldown_and_fail_open() {
996        let field = |text: &str| serde_json::from_str::<serde_json::Value>(text).unwrap();
997        for (value, rel, expected) in [
998            ("false", "index.js", Some(false)),
999            ("\"./init.js\"", "init.js", Some(true)),
1000            ("[\"./{init,other}.js\"]", "init.js", Some(true)),
1001            ("[\"./{init,other}.js\"]", "lib/third.js", Some(false)),
1002            ("[\"./lib/[a-c].js\"]", "lib/b.js", Some(true)),
1003            ("[\"*.css\"]", "deep/style.css", Some(true)),
1004            ("[\"src/**/*.js\"]", "src/a/b.js", Some(true)),
1005            ("[\"!literal.js\"]", "!literal.js", Some(true)),
1006            ("[\"./unclosed{\"]", "unclosed", None),
1007            ("[\"./unclosed[\"]", "unclosed", None),
1008            ("[42]", "index.js", None),
1009            ("{}", "index.js", None),
1010        ] {
1011            assert_eq!(
1012                declared_side_effects(&field(value), rel),
1013                expected,
1014                "{value} {rel}"
1015            );
1016        }
1017    }
1018
1019    fn conditions() -> [&'static str; 4] {
1020        npm_conditions()
1021    }
1022
1023    #[test]
1024    fn conditions_follow_package_document_order() {
1025        let exports: serde_json::Value =
1026            serde_json::from_str("{\"default\": \"./x.js\", \"import\": \"./y.mjs\"}").unwrap();
1027        assert_eq!(
1028            resolve_exports_target(&exports, ".", &conditions()).unwrap(),
1029            Some("./x.js".into())
1030        );
1031        let exports: serde_json::Value =
1032            serde_json::from_str("{\"avril\": \"./a.js\", \"import\": \"./y.mjs\"}").unwrap();
1033        assert_eq!(
1034            resolve_exports_target(&exports, ".", &conditions()).unwrap(),
1035            Some("./a.js".into())
1036        );
1037    }
1038
1039    #[test]
1040    fn arrays_validate_each_target_and_fall_back() {
1041        let exports: serde_json::Value =
1042            serde_json::from_str("{\".\": [\"./../bad.js\", \"./good.js\"]}").unwrap();
1043        assert_eq!(
1044            resolve_exports_target(&exports, ".", &conditions()).unwrap(),
1045            Some("./good.js".into())
1046        );
1047        let exports: serde_json::Value =
1048            serde_json::from_str("{\".\": {\"0\": \"./x.js\", \"default\": \"./d.js\"}}").unwrap();
1049        assert!(
1050            resolve_exports_target(&exports, ".", &conditions())
1051                .unwrap_err()
1052                .to_string()
1053                .contains("condition")
1054        );
1055    }
1056
1057    #[test]
1058    fn unknown_conditions_fall_through_to_default() {
1059        let exports: serde_json::Value = serde_json::from_str(
1060            "{\".\": {\"future-condition\": \"./u.js\", \"default\": \"./d.js\"}}",
1061        )
1062        .unwrap();
1063        assert_eq!(
1064            resolve_exports_target(&exports, ".", &conditions()).unwrap(),
1065            Some("./d.js".into())
1066        );
1067    }
1068
1069    #[test]
1070    fn longest_pattern_prefix_wins_ties_by_suffix() {
1071        let exports: serde_json::Value = serde_json::from_str(
1072            "{\"./features/*.js\": \"./a/*.js\", \"./features/x/*.js\": \"./b/*.js\"}",
1073        )
1074        .unwrap();
1075        assert_eq!(
1076            resolve_exports_target(&exports, "./features/x/y.js", &conditions()).unwrap(),
1077            Some("./b/y.js".into())
1078        );
1079    }
1080
1081    #[test]
1082    fn mixed_exports_are_rejected() {
1083        let exports: serde_json::Value =
1084            serde_json::from_str("{\".\": \"./x.js\", \"import\": \"./y.mjs\"}").unwrap();
1085        assert!(
1086            resolve_exports_target(&exports, ".", &conditions())
1087                .unwrap_err()
1088                .to_string()
1089                .contains("mixes")
1090        );
1091    }
1092
1093    #[test]
1094    fn platform_negations_behave() {
1095        let rule = parse_platform_rule(&serde_json::Value::String("!arm64".into())).unwrap();
1096        assert!(platform_satisfied(Some(&rule), "x64"));
1097        assert!(!platform_satisfied(Some(&rule), "arm64"));
1098    }
1099}