Skip to main content

code_system_graph_core/
packages.rs

1//! Conservative extraction of package manifests and declared dependencies.
2//!
3//! The parsers in this module intentionally support static declarations only. They preserve
4//! repository-relative paths and exact source lines, reject malformed structured input, and skip
5//! declarations that require executing build logic or resolving variables.
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::error::Error;
9use std::fmt;
10
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14use crate::{ExtractionBudgets, ExtractionLimitExceeded, ExtractionTracker};
15
16const EXACT_CONFIDENCE: f32 = 1.0;
17const STATIC_TEXT_CONFIDENCE: f32 = 0.95;
18const PRESENCE_CONFIDENCE: f32 = 0.9;
19
20/// Package manager or language ecosystem associated with an extracted declaration.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23#[non_exhaustive]
24pub enum PackageEcosystem {
25    /// JavaScript packages distributed through npm-compatible registries.
26    Npm,
27    /// Python distributions.
28    Python,
29    /// Rust crates.
30    Cargo,
31    /// Go modules.
32    Go,
33    /// Java Virtual Machine artifacts addressed by Maven coordinates.
34    Maven,
35    /// Dependencies declared by Gradle build scripts.
36    Gradle,
37    /// .NET packages distributed through `NuGet`.
38    NuGet,
39}
40
41/// Semantic role of a dependency in its declaring package.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44#[non_exhaustive]
45pub enum DependencyScope {
46    /// Required by the deployed or executed package.
47    Runtime,
48    /// Used during local development.
49    Dev,
50    /// Used only while running tests.
51    Test,
52    /// Used to compile, generate, or package the project.
53    Build,
54    /// Supplied by a consuming package or host.
55    Peer,
56    /// Not required for the package's default operation.
57    Optional,
58}
59
60/// An exact line retained as evidence for one extracted fact.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct PackageEvidenceLine {
63    /// One-based line number in [`Self::text`]'s source file.
64    pub line: u32,
65    /// Transient source line when the parser supplies it without rescanning the document.
66    #[serde(skip)]
67    pub text: String,
68}
69
70/// A package identity observed in a manifest.
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72pub struct PackageCoordinate {
73    /// Ecosystem in which the package name is interpreted.
74    pub ecosystem: PackageEcosystem,
75    /// Package name, module path, or group-and-artifact coordinate.
76    pub name: String,
77    /// Statically declared package version, when present.
78    pub version: Option<String>,
79    /// Repository-relative manifest path.
80    pub source_path: String,
81    /// Exact declaration line.
82    pub evidence: PackageEvidenceLine,
83    /// Normalized confidence in the inclusive range from zero to one.
84    pub confidence: f32,
85}
86
87/// A statically declared dependency.
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89pub struct PackageDependency {
90    /// Ecosystem in which the dependency name is interpreted.
91    pub ecosystem: PackageEcosystem,
92    /// Package name, module path, or group-and-artifact coordinate.
93    pub name: String,
94    /// Exact version or version range as written, when statically available.
95    pub version_or_range: Option<String>,
96    /// Semantic role of the dependency.
97    pub scope: DependencyScope,
98    /// Whether the declaration explicitly makes the dependency optional.
99    pub optional: bool,
100    /// Feature, target, marker, profile, framework, or configuration condition.
101    pub condition: Option<String>,
102    /// Repository-relative manifest path.
103    pub source_path: String,
104    /// Exact declaration line.
105    pub evidence: PackageEvidenceLine,
106    /// Normalized confidence in the inclusive range from zero to one.
107    pub confidence: f32,
108}
109
110/// A workspace member, package export, or named feature declaration.
111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
112pub struct PackageManifestValue {
113    /// Static value exactly represented by the manifest.
114    pub value: String,
115    /// Repository-relative manifest path.
116    pub source_path: String,
117    /// Exact declaration line.
118    pub evidence: PackageEvidenceLine,
119    /// Normalized confidence in the inclusive range from zero to one.
120    pub confidence: f32,
121}
122
123/// Conservative metadata proving that a package-manager lockfile was observed.
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125pub struct LockfileMetadata {
126    /// Ecosystem controlled by the lockfile.
127    pub ecosystem: PackageEcosystem,
128    /// Package manager that owns the lockfile.
129    pub package_manager: String,
130    /// Statically declared lockfile format version, when present.
131    pub format_version: Option<String>,
132    /// Repository-relative lockfile path.
133    pub source_path: String,
134    /// Exact line proving the lockfile kind or format.
135    pub evidence: PackageEvidenceLine,
136    /// Normalized confidence in the inclusive range from zero to one.
137    pub confidence: f32,
138}
139
140/// Deterministic facts extracted from one package manifest or lockfile.
141#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
142pub struct PackageManifest {
143    /// Package identities declared by the source.
144    pub packages: Vec<PackageCoordinate>,
145    /// Direct dependency declarations. Lockfiles do not contribute transitive dependencies.
146    pub dependencies: Vec<PackageDependency>,
147    /// Workspace members or included module directories.
148    pub workspace_members: Vec<PackageManifestValue>,
149    /// Public package export keys.
150    pub exports: Vec<PackageManifestValue>,
151    /// Named Cargo features.
152    pub features: Vec<PackageManifestValue>,
153    /// Lockfile-presence observations.
154    pub lockfiles: Vec<LockfileMetadata>,
155}
156
157/// Failure to recognize or safely parse a package manifest.
158#[derive(Debug, Clone, PartialEq, Eq)]
159#[non_exhaustive]
160pub enum PackageManifestError {
161    /// The supplied path is not a supported repository-relative package manifest path.
162    UnsupportedPath(String),
163    /// A supported structured file is malformed or contains an invalid static declaration.
164    Malformed {
165        /// Repository-relative source path.
166        path: String,
167        /// Bounded parser explanation.
168        message: String,
169    },
170    /// Extraction exceeded one configured invocation resource.
171    LimitExceeded(ExtractionLimitExceeded),
172}
173
174impl fmt::Display for PackageManifestError {
175    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
176        match self {
177            Self::UnsupportedPath(path) => {
178                write!(formatter, "unsupported package manifest path `{path}`")
179            }
180            Self::Malformed { path, message } => {
181                write!(formatter, "malformed package manifest `{path}`: {message}")
182            }
183            Self::LimitExceeded(error) => error.fmt(formatter),
184        }
185    }
186}
187
188impl Error for PackageManifestError {
189    fn source(&self) -> Option<&(dyn Error + 'static)> {
190        match self {
191            Self::LimitExceeded(error) => Some(error),
192            Self::UnsupportedPath(_) | Self::Malformed { .. } => None,
193        }
194    }
195}
196
197impl From<ExtractionLimitExceeded> for PackageManifestError {
198    fn from(error: ExtractionLimitExceeded) -> Self {
199        Self::LimitExceeded(error)
200    }
201}
202
203/// Extracts static package facts by dispatching on a repository-relative file name.
204///
205/// Supported inputs are `package.json`, npm/pnpm/Yarn lockfiles, `pyproject.toml`,
206/// `requirements*.txt`, `Cargo.toml`, `Cargo.lock`, `go.mod`, `go.work`, `pom.xml`, Gradle build files,
207/// `packages.config`, and SDK-style `.csproj` files.
208///
209/// Results are sorted independently of declaration order. Dynamic Gradle expressions, unresolved
210/// Maven properties, VCS requirements, and other declarations requiring evaluation are skipped.
211///
212/// # Errors
213///
214/// Returns [`PackageManifestError::UnsupportedPath`] for absolute, parent-traversing, or
215/// unsupported paths. Returns [`PackageManifestError::Malformed`] when a recognized structured
216/// input cannot be parsed without guessing.
217pub fn extract_package_manifest(
218    relative_path: &str,
219    content: &str,
220) -> Result<PackageManifest, PackageManifestError> {
221    let mut tracker = ExtractionTracker::new(
222        relative_path,
223        "code-system-graph.packages",
224        &ExtractionBudgets::default(),
225    );
226    extract_package_manifest_with_tracker(relative_path, content, &mut tracker)
227}
228
229/// Extracts package facts using an existing per-invocation tracker.
230///
231/// # Errors
232///
233/// Returns an error for malformed input or an exhausted extraction budget.
234pub fn extract_package_manifest_with_tracker(
235    relative_path: &str,
236    content: &str,
237    tracker: &mut ExtractionTracker,
238) -> Result<PackageManifest, PackageManifestError> {
239    tracker.check_input_bytes(u64::try_from(content.len()).unwrap_or(u64::MAX))?;
240    validate_relative_path(relative_path)?;
241    tracker.charge_portable_path(&relative_path.replace('\\', "/"))?;
242    let file_name = relative_path.rsplit('/').next().unwrap_or(relative_path);
243    let lower_name = file_name.to_ascii_lowercase();
244    if std::path::Path::new(&lower_name)
245        .extension()
246        .is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
247    {
248        crate::graphql_contracts::precheck_json_structure(content, tracker)?;
249    } else {
250        precheck_package_text(content, tracker)?;
251    }
252
253    let result = match lower_name.as_str() {
254        "package.json" => parse_package_json(relative_path, content, tracker),
255        "package-lock.json" | "npm-shrinkwrap.json" => {
256            parse_npm_lockfile(relative_path, content, tracker)
257        }
258        "pnpm-lock.yaml" => parse_pnpm_lockfile(relative_path, content, tracker),
259        "yarn.lock" => parse_yarn_lockfile(relative_path, content, tracker),
260        "pyproject.toml" => parse_pyproject(relative_path, content, tracker),
261        "cargo.toml" => parse_cargo_manifest(relative_path, content, tracker),
262        "cargo.lock" => parse_cargo_lockfile(relative_path, content, tracker),
263        "go.mod" => parse_go_mod(relative_path, content, tracker),
264        "go.work" => parse_go_work(relative_path, content, tracker),
265        "pom.xml" => parse_maven(relative_path, content, tracker),
266        "build.gradle" | "build.gradle.kts" => parse_gradle(relative_path, content, tracker),
267        "packages.config" => parse_packages_config(relative_path, content, tracker),
268        _ if lower_name.starts_with("requirements")
269            && std::path::Path::new(&lower_name)
270                .extension()
271                .is_some_and(|extension| extension.eq_ignore_ascii_case("txt")) =>
272        {
273            parse_requirements(relative_path, content, tracker)
274        }
275        _ if lower_name.ends_with(".csproj") => parse_csproj(relative_path, content, tracker),
276        _ => Err(PackageManifestError::UnsupportedPath(
277            relative_path.to_owned(),
278        )),
279    };
280    if matches!(result, Err(PackageManifestError::LimitExceeded(_))) {
281        return result;
282    }
283    tracker.check_structured_time()?;
284    let result = result?;
285
286    let result = finalize(result);
287    tracker.check_structured_time()?;
288    Ok(result)
289}
290
291fn precheck_package_text(
292    content: &str,
293    tracker: &mut ExtractionTracker,
294) -> Result<(), ExtractionLimitExceeded> {
295    let bytes = content.as_bytes();
296    let mut cursor = 0_usize;
297    let mut accumulated = 0_u64;
298    while cursor < bytes.len() {
299        if cursor.is_multiple_of(1_024) {
300            tracker.check_structured_time()?;
301        }
302        let byte = bytes[cursor];
303        if matches!(byte, b'"' | b'\'') {
304            tracker.charge_work(1)?;
305            let delimiter = byte;
306            cursor = cursor.saturating_add(1);
307            let start = cursor;
308            let mut escaped = false;
309            while cursor < bytes.len() {
310                let byte = bytes[cursor];
311                if escaped {
312                    escaped = false;
313                } else if byte == b'\\' {
314                    escaped = true;
315                } else if byte == delimiter {
316                    break;
317                }
318                cursor = cursor.saturating_add(1);
319                if cursor.is_multiple_of(1_024) {
320                    tracker.check_structured_time()?;
321                }
322            }
323            let observed = u64::try_from(cursor.saturating_sub(start)).unwrap_or(u64::MAX);
324            tracker.check_string_bytes(observed)?;
325            accumulated = accumulated.saturating_add(observed);
326            tracker.check_accumulated_string_bytes(accumulated)?;
327            cursor = cursor.saturating_add(1);
328            continue;
329        }
330        if byte == b'_' || byte.is_ascii_alphanumeric() || matches!(byte, b'@' | b'.' | b'-') {
331            let start = cursor;
332            cursor = cursor.saturating_add(1);
333            while cursor < bytes.len()
334                && (bytes[cursor].is_ascii_alphanumeric()
335                    || matches!(bytes[cursor], b'_' | b'@' | b'.' | b'-' | b'/' | b':'))
336            {
337                cursor = cursor.saturating_add(1);
338            }
339            let observed = u64::try_from(cursor.saturating_sub(start)).unwrap_or(u64::MAX);
340            tracker.charge_work(1)?;
341            tracker.check_identifier_bytes(observed)?;
342            accumulated = accumulated.saturating_add(observed);
343            tracker.check_accumulated_string_bytes(accumulated)?;
344            continue;
345        }
346        if matches!(
347            byte,
348            b'{' | b'}' | b'[' | b']' | b'(' | b')' | b':' | b'=' | b'<'
349        ) {
350            tracker.charge_work(1)?;
351        }
352        cursor = cursor.saturating_add(1);
353    }
354    Ok(())
355}
356
357fn push_package(
358    output: &mut Vec<PackageCoordinate>,
359    package: PackageCoordinate,
360    tracker: &mut ExtractionTracker,
361) -> Result<(), ExtractionLimitExceeded> {
362    tracker.charge_observation(1)?;
363    tracker.charge_identifier(&package.name)?;
364    if let Some(version) = &package.version {
365        tracker.charge_string(version)?;
366    }
367    tracker.charge_portable_path(&package.source_path)?;
368    output.push(package);
369    Ok(())
370}
371
372fn push_dependency(
373    output: &mut Vec<PackageDependency>,
374    dependency: PackageDependency,
375    tracker: &mut ExtractionTracker,
376) -> Result<(), ExtractionLimitExceeded> {
377    tracker.charge_observation(1)?;
378    tracker.charge_identifier(&dependency.name)?;
379    if let Some(version) = &dependency.version_or_range {
380        tracker.charge_string(version)?;
381    }
382    if let Some(condition) = &dependency.condition {
383        tracker.charge_string(condition)?;
384    }
385    tracker.charge_portable_path(&dependency.source_path)?;
386    output.push(dependency);
387    Ok(())
388}
389
390fn push_value(
391    output: &mut Vec<PackageManifestValue>,
392    value: PackageManifestValue,
393    tracker: &mut ExtractionTracker,
394) -> Result<(), ExtractionLimitExceeded> {
395    tracker.charge_observation(1)?;
396    tracker.charge_string(&value.value)?;
397    tracker.charge_portable_path(&value.source_path)?;
398    output.push(value);
399    Ok(())
400}
401
402fn push_lockfile(
403    output: &mut Vec<LockfileMetadata>,
404    lockfile: LockfileMetadata,
405    tracker: &mut ExtractionTracker,
406) -> Result<(), ExtractionLimitExceeded> {
407    tracker.charge_observation(1)?;
408    tracker.charge_identifier(&lockfile.package_manager)?;
409    if let Some(version) = &lockfile.format_version {
410        tracker.charge_string(version)?;
411    }
412    tracker.charge_portable_path(&lockfile.source_path)?;
413    output.push(lockfile);
414    Ok(())
415}
416
417fn validate_relative_path(path: &str) -> Result<(), PackageManifestError> {
418    let normalized = path.replace('\\', "/");
419    let has_parent = normalized.split('/').any(|part| part == "..");
420    let windows_absolute = normalized
421        .as_bytes()
422        .get(1)
423        .is_some_and(|byte| *byte == b':');
424    if path.is_empty() || normalized.starts_with('/') || windows_absolute || has_parent {
425        return Err(PackageManifestError::UnsupportedPath(path.to_owned()));
426    }
427    Ok(())
428}
429
430fn malformed(path: &str, message: impl Into<String>) -> PackageManifestError {
431    PackageManifestError::Malformed {
432        path: path.to_owned(),
433        message: message.into(),
434    }
435}
436
437fn finalize(mut manifest: PackageManifest) -> PackageManifest {
438    manifest.packages.sort_by(|left, right| {
439        (
440            left.ecosystem,
441            left.name.as_str(),
442            left.version.as_deref(),
443            left.evidence.line,
444        )
445            .cmp(&(
446                right.ecosystem,
447                right.name.as_str(),
448                right.version.as_deref(),
449                right.evidence.line,
450            ))
451    });
452    manifest.dependencies.sort_by(|left, right| {
453        (
454            left.ecosystem,
455            left.name.as_str(),
456            left.scope,
457            left.condition.as_deref(),
458            left.version_or_range.as_deref(),
459            left.evidence.line,
460        )
461            .cmp(&(
462                right.ecosystem,
463                right.name.as_str(),
464                right.scope,
465                right.condition.as_deref(),
466                right.version_or_range.as_deref(),
467                right.evidence.line,
468            ))
469    });
470    sort_values(&mut manifest.workspace_members);
471    sort_values(&mut manifest.exports);
472    sort_values(&mut manifest.features);
473    manifest.lockfiles.sort_by(|left, right| {
474        (
475            left.ecosystem,
476            left.package_manager.as_str(),
477            left.source_path.as_str(),
478        )
479            .cmp(&(
480                right.ecosystem,
481                right.package_manager.as_str(),
482                right.source_path.as_str(),
483            ))
484    });
485    manifest
486}
487
488fn sort_values(values: &mut [PackageManifestValue]) {
489    values.sort_by(|left, right| {
490        (left.value.as_str(), left.evidence.line).cmp(&(right.value.as_str(), right.evidence.line))
491    });
492}
493
494fn evidence_at(_content: &str, line: usize) -> PackageEvidenceLine {
495    PackageEvidenceLine {
496        line: u32::try_from(line.max(1)).unwrap_or(u32::MAX),
497        text: String::new(),
498    }
499}
500
501fn evidence_for(_content: &str, start_line: usize, _token: &str) -> PackageEvidenceLine {
502    // Parser-owned declaration positions avoid repeatedly rescanning an attacker-controlled
503    // prefix for every emitted fact. Multi-line declarations intentionally use their first line.
504    evidence_at("", start_line)
505}
506
507fn value_fact(
508    path: &str,
509    content: &str,
510    value: String,
511    start_line: usize,
512    token: &str,
513) -> PackageManifestValue {
514    PackageManifestValue {
515        value,
516        source_path: path.to_owned(),
517        evidence: evidence_for(content, start_line, token),
518        confidence: EXACT_CONFIDENCE,
519    }
520}
521
522fn value_fact_at(path: &str, value: String, line: usize) -> PackageManifestValue {
523    PackageManifestValue {
524        value,
525        source_path: path.to_owned(),
526        evidence: evidence_at("", line),
527        confidence: EXACT_CONFIDENCE,
528    }
529}
530
531fn package(
532    ecosystem: PackageEcosystem,
533    name: String,
534    version: Option<String>,
535    path: &str,
536    evidence: PackageEvidenceLine,
537) -> PackageCoordinate {
538    PackageCoordinate {
539        ecosystem,
540        name,
541        version,
542        source_path: path.to_owned(),
543        evidence,
544        confidence: EXACT_CONFIDENCE,
545    }
546}
547
548struct DependencyInput<'a> {
549    ecosystem: PackageEcosystem,
550    name: String,
551    version: Option<String>,
552    scope: DependencyScope,
553    optional: bool,
554    condition: Option<String>,
555    path: &'a str,
556    evidence: PackageEvidenceLine,
557    confidence: f32,
558}
559
560fn dependency(input: DependencyInput<'_>) -> PackageDependency {
561    PackageDependency {
562        ecosystem: input.ecosystem,
563        name: input.name,
564        version_or_range: input.version,
565        scope: input.scope,
566        optional: input.optional,
567        condition: input.condition,
568        source_path: input.path.to_owned(),
569        evidence: input.evidence,
570        confidence: input.confidence,
571    }
572}
573
574fn json_string_lines(content: &str) -> BTreeMap<String, Vec<usize>> {
575    let bytes = content.as_bytes();
576    let mut lines = BTreeMap::<String, Vec<usize>>::new();
577    let mut cursor = 0_usize;
578    let mut line = 1_usize;
579    while cursor < bytes.len() {
580        if bytes[cursor] == b'\n' {
581            line = line.saturating_add(1);
582            cursor = cursor.saturating_add(1);
583            continue;
584        }
585        if bytes[cursor] != b'"' {
586            cursor = cursor.saturating_add(1);
587            continue;
588        }
589        let start = cursor;
590        let source_line = line;
591        cursor = cursor.saturating_add(1);
592        let mut escaped = false;
593        while cursor < bytes.len() {
594            let byte = bytes[cursor];
595            if escaped {
596                escaped = false;
597            } else if byte == b'\\' {
598                escaped = true;
599            } else if byte == b'"' {
600                break;
601            }
602            cursor = cursor.saturating_add(1);
603        }
604        if cursor < bytes.len()
605            && let Some(raw) = content.get(start..=cursor)
606            && let Ok(value) = serde_json::from_str::<String>(raw)
607        {
608            lines.entry(value).or_default().push(source_line);
609        }
610        cursor = cursor.saturating_add(1);
611    }
612    lines
613}
614
615fn json_string_line(lines: &BTreeMap<String, Vec<usize>>, value: &str, minimum: usize) -> usize {
616    lines
617        .get(value)
618        .and_then(|values| values.iter().copied().find(|line| *line >= minimum))
619        .unwrap_or(minimum)
620}
621
622fn parse_package_json(
623    path: &str,
624    content: &str,
625    tracker: &mut ExtractionTracker,
626) -> Result<PackageManifest, PackageManifestError> {
627    let root: Value =
628        serde_json::from_str(content).map_err(|error| malformed(path, error.to_string()))?;
629    let string_lines = json_string_lines(content);
630    let object = root
631        .as_object()
632        .ok_or_else(|| malformed(path, "top-level JSON value must be an object"))?;
633    let mut result = PackageManifest::default();
634
635    if let Some(name_value) = object.get("name") {
636        let name = required_json_string(path, "name", name_value)?;
637        let version = object
638            .get("version")
639            .map(|value| required_json_string(path, "version", value))
640            .transpose()?;
641        push_package(
642            &mut result.packages,
643            package(
644                PackageEcosystem::Npm,
645                name.to_owned(),
646                version.map(str::to_owned),
647                path,
648                evidence_at(content, json_string_line(&string_lines, "name", 1)),
649            ),
650            tracker,
651        )?;
652    } else if object.contains_key("version") {
653        return Err(malformed(path, "`version` requires a static `name`"));
654    }
655
656    let optional_peers = optional_npm_peers(path, object)?;
657    for (section, scope, section_optional) in [
658        ("dependencies", DependencyScope::Runtime, false),
659        ("devDependencies", DependencyScope::Dev, false),
660        ("peerDependencies", DependencyScope::Peer, false),
661        ("optionalDependencies", DependencyScope::Optional, true),
662    ] {
663        let Some(value) = object.get(section) else {
664            continue;
665        };
666        let entries = value
667            .as_object()
668            .ok_or_else(|| malformed(path, format!("`{section}` must be an object")))?;
669        let section_line = json_string_line(&string_lines, section, 1);
670        for (name, version) in entries {
671            let version = required_json_string(path, section, version)?;
672            let optional = section_optional
673                || (section == "peerDependencies" && optional_peers.contains(name));
674            push_dependency(
675                &mut result.dependencies,
676                dependency(DependencyInput {
677                    ecosystem: PackageEcosystem::Npm,
678                    name: name.clone(),
679                    version: Some(version.to_owned()),
680                    scope,
681                    optional,
682                    condition: None,
683                    path,
684                    evidence: evidence_at(
685                        content,
686                        json_string_line(&string_lines, name, section_line),
687                    ),
688                    confidence: EXACT_CONFIDENCE,
689                }),
690                tracker,
691            )?;
692        }
693    }
694
695    if let Some(workspaces) = object.get("workspaces") {
696        let values = if let Some(array) = workspaces.as_array() {
697            array
698        } else {
699            workspaces
700                .as_object()
701                .and_then(|map| map.get("packages"))
702                .and_then(Value::as_array)
703                .ok_or_else(|| {
704                    malformed(
705                        path,
706                        "`workspaces` must be an array or an object with a `packages` array",
707                    )
708                })?
709        };
710        for value in values {
711            let member = required_json_string(path, "workspaces", value)?;
712            push_value(
713                &mut result.workspace_members,
714                value_fact_at(
715                    path,
716                    member.to_owned(),
717                    json_string_line(&string_lines, member, 1),
718                ),
719                tracker,
720            )?;
721        }
722    }
723
724    if let Some(exports) = object.get("exports") {
725        collect_json_export_keys(path, exports, &string_lines, &mut result.exports, tracker)?;
726    }
727    Ok(result)
728}
729
730fn optional_npm_peers(
731    path: &str,
732    object: &serde_json::Map<String, Value>,
733) -> Result<BTreeSet<String>, PackageManifestError> {
734    let Some(value) = object.get("peerDependenciesMeta") else {
735        return Ok(BTreeSet::new());
736    };
737    let entries = value
738        .as_object()
739        .ok_or_else(|| malformed(path, "`peerDependenciesMeta` must be an object"))?;
740    let mut optional = BTreeSet::new();
741    for (name, metadata) in entries {
742        let metadata = metadata.as_object().ok_or_else(|| {
743            malformed(
744                path,
745                format!("peer metadata for `{name}` must be an object"),
746            )
747        })?;
748        if let Some(flag) = metadata.get("optional") {
749            let flag = flag.as_bool().ok_or_else(|| {
750                malformed(
751                    path,
752                    format!("peer metadata `optional` for `{name}` must be a boolean"),
753                )
754            })?;
755            if flag {
756                optional.insert(name.clone());
757            }
758        }
759    }
760    Ok(optional)
761}
762
763fn required_json_string<'a>(
764    path: &str,
765    field: &str,
766    value: &'a Value,
767) -> Result<&'a str, PackageManifestError> {
768    value
769        .as_str()
770        .filter(|text| !text.trim().is_empty())
771        .ok_or_else(|| malformed(path, format!("`{field}` must contain non-empty strings")))
772}
773
774fn collect_json_export_keys(
775    path: &str,
776    exports: &Value,
777    string_lines: &BTreeMap<String, Vec<usize>>,
778    output: &mut Vec<PackageManifestValue>,
779    tracker: &mut ExtractionTracker,
780) -> Result<(), PackageManifestError> {
781    match exports {
782        Value::String(target) if !target.is_empty() => {
783            push_value(
784                output,
785                value_fact_at(
786                    path,
787                    ".".to_owned(),
788                    json_string_line(string_lines, "exports", 1),
789                ),
790                tracker,
791            )?;
792        }
793        Value::Object(map) => {
794            for (key, value) in map {
795                if key.starts_with('.') {
796                    push_value(
797                        output,
798                        value_fact_at(path, key.clone(), json_string_line(string_lines, key, 1)),
799                        tracker,
800                    )?;
801                }
802                validate_json_export_target(path, value)?;
803            }
804        }
805        _ => return Err(malformed(path, "`exports` must be a string or object")),
806    }
807    Ok(())
808}
809
810fn validate_json_export_target(path: &str, value: &Value) -> Result<(), PackageManifestError> {
811    match value {
812        Value::String(_) | Value::Null => Ok(()),
813        Value::Array(values) => {
814            for item in values {
815                validate_json_export_target(path, item)?;
816            }
817            Ok(())
818        }
819        Value::Object(map) => {
820            for item in map.values() {
821                validate_json_export_target(path, item)?;
822            }
823            Ok(())
824        }
825        _ => Err(malformed(
826            path,
827            "`exports` targets must be strings, null, arrays, or condition objects",
828        )),
829    }
830}
831
832fn parse_npm_lockfile(
833    path: &str,
834    content: &str,
835    tracker: &mut ExtractionTracker,
836) -> Result<PackageManifest, PackageManifestError> {
837    let root: Value =
838        serde_json::from_str(content).map_err(|error| malformed(path, error.to_string()))?;
839    let object = root
840        .as_object()
841        .ok_or_else(|| malformed(path, "top-level JSON value must be an object"))?;
842    let version = object
843        .get("lockfileVersion")
844        .map(|value| match value {
845            Value::Number(number) => Ok(number.to_string()),
846            Value::String(text) if !text.is_empty() => Ok(text.clone()),
847            _ => Err(malformed(
848                path,
849                "`lockfileVersion` must be a string or number",
850            )),
851        })
852        .transpose()?;
853    if object.is_empty() {
854        return Err(malformed(path, "lockfile object must not be empty"));
855    }
856    let token = if version.is_some() {
857        "\"lockfileVersion\""
858    } else {
859        "{"
860    };
861    lockfile_result(path, content, "npm", version, token, tracker)
862}
863
864fn parse_pnpm_lockfile(
865    path: &str,
866    content: &str,
867    tracker: &mut ExtractionTracker,
868) -> Result<PackageManifest, PackageManifestError> {
869    reject_nul(path, content)?;
870    let declaration = content
871        .lines()
872        .enumerate()
873        .find_map(|(index, line)| {
874            let trimmed = line.trim();
875            trimmed
876                .strip_prefix("lockfileVersion:")
877                .map(|value| (index + 1, unquote_scalar(value.trim())))
878        })
879        .ok_or_else(|| malformed(path, "missing static `lockfileVersion`"))?;
880    if declaration.1.is_empty() {
881        return Err(malformed(path, "`lockfileVersion` must not be empty"));
882    }
883    lockfile_result_at(
884        path,
885        content,
886        "pnpm",
887        Some(declaration.1),
888        declaration.0,
889        tracker,
890    )
891}
892
893fn parse_yarn_lockfile(
894    path: &str,
895    content: &str,
896    tracker: &mut ExtractionTracker,
897) -> Result<PackageManifest, PackageManifestError> {
898    reject_nul(path, content)?;
899    let first = content
900        .lines()
901        .enumerate()
902        .find(|(_, line)| !line.trim().is_empty())
903        .ok_or_else(|| malformed(path, "lockfile must not be empty"))?;
904    let is_v1 = first.1.contains("yarn lockfile v1");
905    let metadata_line = content
906        .lines()
907        .enumerate()
908        .find(|(_, line)| line.trim() == "__metadata:")
909        .map(|(index, _)| index + 1);
910    if !is_v1 && metadata_line.is_none() {
911        return Err(malformed(
912            path,
913            "missing Yarn v1 header or Berry `__metadata` section",
914        ));
915    }
916    let version = if is_v1 {
917        Some("1".to_owned())
918    } else {
919        metadata_line.and_then(|start| {
920            content
921                .lines()
922                .skip(start)
923                .find_map(|line| line.trim().strip_prefix("version:").map(str::trim))
924                .map(unquote_scalar)
925        })
926    };
927    lockfile_result_at(
928        path,
929        content,
930        "yarn",
931        version,
932        metadata_line.unwrap_or(first.0 + 1),
933        tracker,
934    )
935}
936
937fn lockfile_result(
938    path: &str,
939    content: &str,
940    manager: &str,
941    version: Option<String>,
942    token: &str,
943    tracker: &mut ExtractionTracker,
944) -> Result<PackageManifest, PackageManifestError> {
945    let evidence = evidence_for(content, 1, token);
946    lockfile_result_with_evidence(path, manager, version, evidence, tracker)
947}
948
949fn lockfile_result_at(
950    path: &str,
951    content: &str,
952    manager: &str,
953    version: Option<String>,
954    line: usize,
955    tracker: &mut ExtractionTracker,
956) -> Result<PackageManifest, PackageManifestError> {
957    lockfile_result_with_evidence(path, manager, version, evidence_at(content, line), tracker)
958}
959
960fn lockfile_result_with_evidence(
961    path: &str,
962    manager: &str,
963    version: Option<String>,
964    evidence: PackageEvidenceLine,
965    tracker: &mut ExtractionTracker,
966) -> Result<PackageManifest, PackageManifestError> {
967    ecosystem_lockfile_result(
968        path,
969        PackageEcosystem::Npm,
970        manager,
971        version,
972        evidence,
973        tracker,
974    )
975}
976
977fn ecosystem_lockfile_result(
978    path: &str,
979    ecosystem: PackageEcosystem,
980    manager: &str,
981    version: Option<String>,
982    evidence: PackageEvidenceLine,
983    tracker: &mut ExtractionTracker,
984) -> Result<PackageManifest, PackageManifestError> {
985    let mut result = PackageManifest::default();
986    push_lockfile(
987        &mut result.lockfiles,
988        LockfileMetadata {
989            ecosystem,
990            package_manager: manager.to_owned(),
991            format_version: version,
992            source_path: path.to_owned(),
993            evidence,
994            confidence: PRESENCE_CONFIDENCE,
995        },
996        tracker,
997    )?;
998    Ok(result)
999}
1000
1001fn parse_cargo_lockfile(
1002    path: &str,
1003    content: &str,
1004    tracker: &mut ExtractionTracker,
1005) -> Result<PackageManifest, PackageManifestError> {
1006    reject_nul(path, content)?;
1007    let first_content_line = content
1008        .lines()
1009        .enumerate()
1010        .find(|(_, line)| {
1011            let line = line.trim();
1012            !line.is_empty() && !line.starts_with('#')
1013        })
1014        .ok_or_else(|| malformed(path, "lockfile must not be empty"))?;
1015    let version = content
1016        .lines()
1017        .enumerate()
1018        .take_while(|(_, line)| line.trim() != "[[package]]")
1019        .find_map(|(index, line)| {
1020            line.trim()
1021                .strip_prefix("version =")
1022                .map(|value| (index + 1, unquote_scalar(value.trim())))
1023        });
1024    if version
1025        .as_ref()
1026        .is_some_and(|(_, version)| version.is_empty())
1027    {
1028        return Err(malformed(path, "`version` must not be empty"));
1029    }
1030    let evidence = version.as_ref().map_or_else(
1031        || evidence_at(content, first_content_line.0 + 1),
1032        |(line, _)| evidence_at(content, *line),
1033    );
1034    ecosystem_lockfile_result(
1035        path,
1036        PackageEcosystem::Cargo,
1037        "cargo",
1038        version.map(|(_, value)| value),
1039        evidence,
1040        tracker,
1041    )
1042}
1043
1044#[derive(Debug)]
1045struct TomlEntry {
1046    section: String,
1047    key: String,
1048    value: String,
1049    line: usize,
1050}
1051
1052fn parse_toml(
1053    path: &str,
1054    content: &str,
1055    tracker: &ExtractionTracker,
1056) -> Result<Vec<TomlEntry>, PackageManifestError> {
1057    reject_nul(path, content)?;
1058    let lines = content.lines().collect::<Vec<_>>();
1059    let mut entries = Vec::new();
1060    let mut section = String::new();
1061    let mut index = 0;
1062    while index < lines.len() {
1063        if index.is_multiple_of(1_024) {
1064            tracker.check_structured_time()?;
1065        }
1066        let first_line = index + 1;
1067        let stripped = strip_line_comment(lines[index], '#')?;
1068        let trimmed = stripped.trim();
1069        if trimmed.is_empty() {
1070            index += 1;
1071            continue;
1072        }
1073        if trimmed.starts_with('[') {
1074            let (open, close) = if trimmed.starts_with("[[") {
1075                ("[[", "]]")
1076            } else {
1077                ("[", "]")
1078            };
1079            if !trimmed.ends_with(close) {
1080                return Err(malformed(
1081                    path,
1082                    format!("unterminated table at line {first_line}"),
1083                ));
1084            }
1085            let name = &trimmed[open.len()..trimmed.len() - close.len()];
1086            if name.trim().is_empty() {
1087                return Err(malformed(path, format!("empty table at line {first_line}")));
1088            }
1089            name.trim().clone_into(&mut section);
1090            index += 1;
1091            continue;
1092        }
1093        let Some((key, initial_value)) = split_top_level_once(trimmed, '=') else {
1094            return Err(malformed(
1095                path,
1096                format!("expected a key/value declaration at line {first_line}"),
1097            ));
1098        };
1099        let key = unquote_scalar(key.trim());
1100        if key.is_empty() {
1101            return Err(malformed(path, format!("empty key at line {first_line}")));
1102        }
1103        let mut value = initial_value.trim().to_owned();
1104        while !structured_value_complete(&value)? {
1105            index += 1;
1106            let Some(next) = lines.get(index) else {
1107                return Err(malformed(
1108                    path,
1109                    format!("unterminated value beginning at line {first_line}"),
1110                ));
1111            };
1112            let next = strip_line_comment(next, '#')?;
1113            value.push('\n');
1114            value.push_str(next.trim());
1115        }
1116        if value.is_empty() {
1117            return Err(malformed(path, format!("empty value at line {first_line}")));
1118        }
1119        entries.push(TomlEntry {
1120            section: section.clone(),
1121            key,
1122            value,
1123            line: first_line,
1124        });
1125        index += 1;
1126    }
1127    Ok(entries)
1128}
1129
1130fn strip_line_comment(line: &str, marker: char) -> Result<&str, PackageManifestError> {
1131    let mut quote = None;
1132    let mut escaped = false;
1133    for (index, character) in line.char_indices() {
1134        if escaped {
1135            escaped = false;
1136            continue;
1137        }
1138        if character == '\\' && quote == Some('"') {
1139            escaped = true;
1140            continue;
1141        }
1142        if character == '\'' || character == '"' {
1143            if quote == Some(character) {
1144                quote = None;
1145            } else if quote.is_none() {
1146                quote = Some(character);
1147            }
1148        } else if character == marker && quote.is_none() {
1149            return Ok(&line[..index]);
1150        }
1151    }
1152    if quote.is_some() {
1153        return Err(PackageManifestError::Malformed {
1154            path: String::new(),
1155            message: "unterminated quoted string".to_owned(),
1156        });
1157    }
1158    Ok(line)
1159}
1160
1161fn structured_value_complete(value: &str) -> Result<bool, PackageManifestError> {
1162    let mut square = 0_i32;
1163    let mut curly = 0_i32;
1164    let mut quote = None;
1165    let mut escaped = false;
1166    for character in value.chars() {
1167        if escaped {
1168            escaped = false;
1169            continue;
1170        }
1171        if character == '\\' && quote == Some('"') {
1172            escaped = true;
1173            continue;
1174        }
1175        if character == '\'' || character == '"' {
1176            if quote == Some(character) {
1177                quote = None;
1178            } else if quote.is_none() {
1179                quote = Some(character);
1180            }
1181            continue;
1182        }
1183        if quote.is_some() {
1184            continue;
1185        }
1186        match character {
1187            '[' => square += 1,
1188            ']' => square -= 1,
1189            '{' => curly += 1,
1190            '}' => curly -= 1,
1191            _ => {}
1192        }
1193        if square < 0 || curly < 0 {
1194            return Err(PackageManifestError::Malformed {
1195                path: String::new(),
1196                message: "unbalanced structured value".to_owned(),
1197            });
1198        }
1199    }
1200    Ok(square == 0 && curly == 0 && quote.is_none())
1201}
1202
1203fn split_top_level_once(input: &str, separator: char) -> Option<(&str, &str)> {
1204    let mut square = 0_i32;
1205    let mut curly = 0_i32;
1206    let mut round = 0_i32;
1207    let mut quote = None;
1208    let mut escaped = false;
1209    for (index, character) in input.char_indices() {
1210        if escaped {
1211            escaped = false;
1212            continue;
1213        }
1214        if character == '\\' && quote == Some('"') {
1215            escaped = true;
1216            continue;
1217        }
1218        if character == '\'' || character == '"' {
1219            if quote == Some(character) {
1220                quote = None;
1221            } else if quote.is_none() {
1222                quote = Some(character);
1223            }
1224            continue;
1225        }
1226        if quote.is_some() {
1227            continue;
1228        }
1229        match character {
1230            '[' => square += 1,
1231            ']' => square -= 1,
1232            '{' => curly += 1,
1233            '}' => curly -= 1,
1234            '(' => round += 1,
1235            ')' => round -= 1,
1236            _ => {}
1237        }
1238        if character == separator && square == 0 && curly == 0 && round == 0 {
1239            return Some((&input[..index], &input[index + character.len_utf8()..]));
1240        }
1241    }
1242    None
1243}
1244
1245fn split_top_level(input: &str, separator: char) -> Vec<&str> {
1246    let mut result = Vec::new();
1247    let mut rest = input;
1248    while let Some((left, right)) = split_top_level_once(rest, separator) {
1249        result.push(left);
1250        rest = right;
1251    }
1252    result.push(rest);
1253    result
1254}
1255
1256fn unquote_scalar(value: &str) -> String {
1257    let trimmed = value.trim();
1258    if trimmed.len() >= 2
1259        && ((trimmed.starts_with('"') && trimmed.ends_with('"'))
1260            || (trimmed.starts_with('\'') && trimmed.ends_with('\'')))
1261    {
1262        trimmed[1..trimmed.len() - 1].to_owned()
1263    } else {
1264        trimmed.to_owned()
1265    }
1266}
1267
1268fn toml_string(path: &str, entry: &TomlEntry) -> Result<String, PackageManifestError> {
1269    let trimmed = entry.value.trim();
1270    if trimmed.starts_with('"') && trimmed.ends_with('"') {
1271        return serde_json::from_str::<String>(trimmed)
1272            .map_err(|error| malformed(path, format!("line {}: {error}", entry.line)));
1273    }
1274    if trimmed.starts_with('\'') && trimmed.ends_with('\'') && trimmed.len() >= 2 {
1275        return Ok(trimmed[1..trimmed.len() - 1].to_owned());
1276    }
1277    Err(malformed(
1278        path,
1279        format!("`{}` at line {} must be a string", entry.key, entry.line),
1280    ))
1281}
1282
1283fn toml_array(path: &str, entry: &TomlEntry) -> Result<Vec<String>, PackageManifestError> {
1284    let trimmed = entry.value.trim();
1285    if !trimmed.starts_with('[') || !trimmed.ends_with(']') {
1286        return Err(malformed(
1287            path,
1288            format!("`{}` at line {} must be an array", entry.key, entry.line),
1289        ));
1290    }
1291    let inner = &trimmed[1..trimmed.len() - 1];
1292    let mut values = Vec::new();
1293    for item in split_top_level(inner, ',') {
1294        let item = item.trim();
1295        if item.is_empty() {
1296            continue;
1297        }
1298        let fake = TomlEntry {
1299            section: entry.section.clone(),
1300            key: entry.key.clone(),
1301            value: item.to_owned(),
1302            line: entry.line,
1303        };
1304        values.push(toml_string(path, &fake)?);
1305    }
1306    Ok(values)
1307}
1308
1309fn inline_table(
1310    path: &str,
1311    entry: &TomlEntry,
1312) -> Result<BTreeMap<String, String>, PackageManifestError> {
1313    let trimmed = entry.value.trim();
1314    if !trimmed.starts_with('{') || !trimmed.ends_with('}') {
1315        return Err(malformed(
1316            path,
1317            format!(
1318                "`{}` at line {} must be an inline table",
1319                entry.key, entry.line
1320            ),
1321        ));
1322    }
1323    let mut values = BTreeMap::new();
1324    for item in split_top_level(&trimmed[1..trimmed.len() - 1], ',') {
1325        let item = item.trim();
1326        if item.is_empty() {
1327            continue;
1328        }
1329        let (key, value) = split_top_level_once(item, '=').ok_or_else(|| {
1330            malformed(path, format!("invalid inline table at line {}", entry.line))
1331        })?;
1332        values.insert(unquote_scalar(key), value.trim().to_owned());
1333    }
1334    Ok(values)
1335}
1336
1337fn entry<'a>(entries: &'a [TomlEntry], section: &str, key: &str) -> Option<&'a TomlEntry> {
1338    entries
1339        .iter()
1340        .find(|item| item.section == section && item.key == key)
1341}
1342
1343fn parse_pyproject(
1344    path: &str,
1345    content: &str,
1346    tracker: &mut ExtractionTracker,
1347) -> Result<PackageManifest, PackageManifestError> {
1348    let entries = parse_toml(path, content, tracker).map_err(|error| rehome_error(path, error))?;
1349    let mut result = PackageManifest::default();
1350    extract_python_package(path, content, &entries, &mut result, tracker)?;
1351    extract_pep621_dependencies(path, content, &entries, &mut result, tracker)?;
1352    extract_poetry_dependencies(path, content, &entries, &mut result, tracker)?;
1353    Ok(result)
1354}
1355
1356fn extract_python_package(
1357    path: &str,
1358    content: &str,
1359    entries: &[TomlEntry],
1360    result: &mut PackageManifest,
1361    tracker: &mut ExtractionTracker,
1362) -> Result<(), PackageManifestError> {
1363    let project_name = entry(entries, "project", "name")
1364        .map(|item| toml_string(path, item))
1365        .transpose()?;
1366    let poetry_name = entry(entries, "tool.poetry", "name")
1367        .map(|item| toml_string(path, item))
1368        .transpose()?;
1369    if let Some(name) = project_name.as_ref().or(poetry_name.as_ref()) {
1370        let section = if project_name.is_some() {
1371            "project"
1372        } else {
1373            "tool.poetry"
1374        };
1375        let name_entry = entry(entries, section, "name")
1376            .ok_or_else(|| malformed(path, "package name declaration disappeared"))?;
1377        let version = entry(entries, section, "version")
1378            .map(|item| toml_string(path, item))
1379            .transpose()?;
1380        push_package(
1381            &mut result.packages,
1382            package(
1383                PackageEcosystem::Python,
1384                name.clone(),
1385                version,
1386                path,
1387                evidence_at(content, name_entry.line),
1388            ),
1389            tracker,
1390        )?;
1391    }
1392    Ok(())
1393}
1394
1395fn extract_pep621_dependencies(
1396    path: &str,
1397    content: &str,
1398    entries: &[TomlEntry],
1399    result: &mut PackageManifest,
1400    tracker: &mut ExtractionTracker,
1401) -> Result<(), PackageManifestError> {
1402    if let Some(dependencies) = entry(entries, "project", "dependencies") {
1403        for requirement in toml_array(path, dependencies)? {
1404            if let Some(parsed) = parse_python_requirement(&requirement) {
1405                push_dependency(
1406                    &mut result.dependencies,
1407                    python_dependency(
1408                        path,
1409                        content,
1410                        parsed,
1411                        DependencyScope::Runtime,
1412                        false,
1413                        dependencies.line,
1414                        &requirement,
1415                    ),
1416                    tracker,
1417                )?;
1418            }
1419        }
1420    }
1421    for item in entries {
1422        if let Some(extra) = item.section.strip_prefix("project.optional-dependencies") {
1423            let extra = extra.trim_start_matches('.');
1424            if extra.is_empty() {
1425                for requirement in toml_array(path, item)? {
1426                    if let Some(parsed) = parse_python_requirement(&requirement) {
1427                        push_dependency(
1428                            &mut result.dependencies,
1429                            python_dependency(
1430                                path,
1431                                content,
1432                                parsed.with_condition(format!("extra = \"{}\"", item.key)),
1433                                DependencyScope::Optional,
1434                                true,
1435                                item.line,
1436                                &requirement,
1437                            ),
1438                            tracker,
1439                        )?;
1440                    }
1441                }
1442            }
1443        }
1444    }
1445    Ok(())
1446}
1447
1448fn extract_poetry_dependencies(
1449    path: &str,
1450    content: &str,
1451    entries: &[TomlEntry],
1452    result: &mut PackageManifest,
1453    tracker: &mut ExtractionTracker,
1454) -> Result<(), PackageManifestError> {
1455    for item in entries {
1456        let (scope, group_condition) = if item.section == "tool.poetry.dependencies" {
1457            (DependencyScope::Runtime, None)
1458        } else if item.section == "tool.poetry.dev-dependencies" {
1459            (DependencyScope::Dev, None)
1460        } else if let Some(group) = item
1461            .section
1462            .strip_prefix("tool.poetry.group.")
1463            .and_then(|tail| tail.strip_suffix(".dependencies"))
1464        {
1465            (
1466                if group.eq_ignore_ascii_case("test") {
1467                    DependencyScope::Test
1468                } else {
1469                    DependencyScope::Dev
1470                },
1471                Some(format!("poetry group = \"{group}\"")),
1472            )
1473        } else {
1474            continue;
1475        };
1476        if item.key == "python" || contains_dynamic(&item.key) {
1477            continue;
1478        }
1479        let (version, optional, table_condition) = parse_poetry_value(path, item)?;
1480        let condition = join_conditions(group_condition, table_condition);
1481        push_dependency(
1482            &mut result.dependencies,
1483            dependency(DependencyInput {
1484                ecosystem: PackageEcosystem::Python,
1485                name: item.key.clone(),
1486                version,
1487                scope: if optional {
1488                    DependencyScope::Optional
1489                } else {
1490                    scope
1491                },
1492                optional,
1493                condition,
1494                path,
1495                evidence: evidence_at(content, item.line),
1496                confidence: EXACT_CONFIDENCE,
1497            }),
1498            tracker,
1499        )?;
1500    }
1501    Ok(())
1502}
1503
1504fn rehome_error(path: &str, error: PackageManifestError) -> PackageManifestError {
1505    match error {
1506        PackageManifestError::Malformed { message, .. } => malformed(path, message),
1507        other => other,
1508    }
1509}
1510
1511fn parse_poetry_value(
1512    path: &str,
1513    item: &TomlEntry,
1514) -> Result<(Option<String>, bool, Option<String>), PackageManifestError> {
1515    if item.value.trim().starts_with(['"', '\'']) {
1516        return Ok((Some(toml_string(path, item)?), false, None));
1517    }
1518    let values = inline_table(path, item)?;
1519    let version = values.get("version").map(|value| unquote_scalar(value));
1520    let optional = values
1521        .get("optional")
1522        .is_some_and(|value| value.trim() == "true");
1523    let mut conditions = Vec::new();
1524    for key in ["markers", "python", "platform"] {
1525        if let Some(value) = values.get(key) {
1526            conditions.push(format!("{key} = {}", value.trim()));
1527        }
1528    }
1529    Ok((
1530        version,
1531        optional,
1532        (!conditions.is_empty()).then(|| conditions.join(" and ")),
1533    ))
1534}
1535
1536#[derive(Debug)]
1537struct PythonRequirement {
1538    name: String,
1539    version: Option<String>,
1540    condition: Option<String>,
1541}
1542
1543impl PythonRequirement {
1544    fn with_condition(mut self, condition: String) -> Self {
1545        self.condition = join_conditions(Some(condition), self.condition);
1546        self
1547    }
1548}
1549
1550fn parse_python_requirement(input: &str) -> Option<PythonRequirement> {
1551    let trimmed = input.trim();
1552    if trimmed.is_empty()
1553        || trimmed.starts_with('-')
1554        || trimmed.contains("${")
1555        || trimmed.contains(" @ ")
1556        || trimmed.starts_with("git+")
1557    {
1558        return None;
1559    }
1560    let (declaration, marker) = trimmed
1561        .split_once(';')
1562        .map_or((trimmed, None), |(left, right)| {
1563            (left.trim(), Some(right.trim().to_owned()))
1564        });
1565    let name_end = declaration
1566        .char_indices()
1567        .find_map(|(index, character)| {
1568            (character.is_whitespace() || matches!(character, '<' | '>' | '=' | '!' | '~' | '@'))
1569                .then_some(index)
1570        })
1571        .unwrap_or(declaration.len());
1572    let name = declaration[..name_end].trim();
1573    if name.is_empty()
1574        || !name
1575            .chars()
1576            .all(|character| character.is_ascii_alphanumeric() || "._-[]".contains(character))
1577    {
1578        return None;
1579    }
1580    let version = declaration[name_end..].trim();
1581    Some(PythonRequirement {
1582        name: name.to_owned(),
1583        version: (!version.is_empty()).then(|| version.to_owned()),
1584        condition: marker.filter(|value| !value.is_empty()),
1585    })
1586}
1587
1588fn python_dependency(
1589    path: &str,
1590    content: &str,
1591    parsed: PythonRequirement,
1592    scope: DependencyScope,
1593    optional: bool,
1594    start_line: usize,
1595    token: &str,
1596) -> PackageDependency {
1597    dependency(DependencyInput {
1598        ecosystem: PackageEcosystem::Python,
1599        name: parsed.name,
1600        version: parsed.version,
1601        scope,
1602        optional,
1603        condition: parsed.condition,
1604        path,
1605        evidence: evidence_for(content, start_line, token),
1606        confidence: EXACT_CONFIDENCE,
1607    })
1608}
1609
1610fn parse_requirements(
1611    path: &str,
1612    content: &str,
1613    tracker: &mut ExtractionTracker,
1614) -> Result<PackageManifest, PackageManifestError> {
1615    reject_nul(path, content)?;
1616    let mut result = PackageManifest::default();
1617    for (index, line) in content.lines().enumerate() {
1618        if index.is_multiple_of(1_024) {
1619            tracker.check_structured_time()?;
1620        }
1621        let declaration = strip_requirement_comment(line).trim();
1622        if declaration.is_empty() || declaration.starts_with('-') {
1623            continue;
1624        }
1625        if declaration.ends_with('\\') {
1626            return Err(malformed(
1627                path,
1628                format!("line continuations are not supported at line {}", index + 1),
1629            ));
1630        }
1631        if let Some(parsed) = parse_python_requirement(declaration) {
1632            push_dependency(
1633                &mut result.dependencies,
1634                python_dependency(
1635                    path,
1636                    content,
1637                    parsed,
1638                    DependencyScope::Runtime,
1639                    false,
1640                    index + 1,
1641                    declaration,
1642                ),
1643                tracker,
1644            )?;
1645        }
1646    }
1647    Ok(result)
1648}
1649
1650fn strip_requirement_comment(line: &str) -> &str {
1651    line.find(" #")
1652        .map_or(line, |index| &line[..index])
1653        .trim_end()
1654}
1655
1656fn parse_cargo_manifest(
1657    path: &str,
1658    content: &str,
1659    tracker: &mut ExtractionTracker,
1660) -> Result<PackageManifest, PackageManifestError> {
1661    let entries = parse_toml(path, content, tracker).map_err(|error| rehome_error(path, error))?;
1662    let mut result = PackageManifest::default();
1663    if let Some(name_entry) = entry(&entries, "package", "name") {
1664        let name = toml_string(path, name_entry)?;
1665        let version = entry(&entries, "package", "version")
1666            .map(|item| toml_string(path, item))
1667            .transpose()?;
1668        push_package(
1669            &mut result.packages,
1670            package(
1671                PackageEcosystem::Cargo,
1672                name,
1673                version,
1674                path,
1675                evidence_at(content, name_entry.line),
1676            ),
1677            tracker,
1678        )?;
1679    }
1680    if let Some(members) = entry(&entries, "workspace", "members") {
1681        for member in toml_array(path, members)? {
1682            push_value(
1683                &mut result.workspace_members,
1684                value_fact(path, content, member.clone(), members.line, &member),
1685                tracker,
1686            )?;
1687        }
1688    }
1689
1690    let mut feature_dependencies: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
1691    for feature in entries.iter().filter(|item| item.section == "features") {
1692        push_value(
1693            &mut result.features,
1694            value_fact(
1695                path,
1696                content,
1697                feature.key.clone(),
1698                feature.line,
1699                &feature.key,
1700            ),
1701            tracker,
1702        )?;
1703        for member in toml_array(path, feature)? {
1704            let dependency_name = member
1705                .strip_prefix("dep:")
1706                .or_else(|| (!member.contains('/')).then_some(member.as_str()));
1707            if let Some(name) = dependency_name {
1708                feature_dependencies
1709                    .entry(name.to_owned())
1710                    .or_default()
1711                    .insert(feature.key.clone());
1712            }
1713        }
1714    }
1715
1716    for item in &entries {
1717        let Some((scope, target)) = cargo_dependency_section(&item.section) else {
1718            continue;
1719        };
1720        let (name, version, optional, declared_features) = parse_cargo_dependency(path, item)?;
1721        let features = feature_dependencies.get(&item.key);
1722        let feature_condition = features.map(|names| {
1723            let joined = names.iter().cloned().collect::<Vec<_>>().join("|");
1724            format!("feature = \"{joined}\"")
1725        });
1726        let implicit_feature =
1727            (optional && features.is_none()).then(|| format!("feature = \"{}\"", item.key));
1728        let dependency_features = (!declared_features.is_empty())
1729            .then(|| format!("dependency features = \"{}\"", declared_features.join("|")));
1730        push_dependency(
1731            &mut result.dependencies,
1732            dependency(DependencyInput {
1733                ecosystem: PackageEcosystem::Cargo,
1734                name,
1735                version,
1736                scope,
1737                optional,
1738                condition: join_conditions(
1739                    join_conditions(target, feature_condition.or(implicit_feature)),
1740                    dependency_features,
1741                ),
1742                path,
1743                evidence: evidence_at(content, item.line),
1744                confidence: EXACT_CONFIDENCE,
1745            }),
1746            tracker,
1747        )?;
1748    }
1749    Ok(result)
1750}
1751
1752fn cargo_dependency_section(section: &str) -> Option<(DependencyScope, Option<String>)> {
1753    let (prefix, scope) = if let Some(prefix) = section.strip_suffix(".dev-dependencies") {
1754        (prefix, DependencyScope::Dev)
1755    } else if let Some(prefix) = section.strip_suffix(".build-dependencies") {
1756        (prefix, DependencyScope::Build)
1757    } else if let Some(prefix) = section.strip_suffix(".dependencies") {
1758        (prefix, DependencyScope::Runtime)
1759    } else {
1760        return match section {
1761            "dependencies" => Some((DependencyScope::Runtime, None)),
1762            "dev-dependencies" => Some((DependencyScope::Dev, None)),
1763            "build-dependencies" => Some((DependencyScope::Build, None)),
1764            _ => None,
1765        };
1766    };
1767    let target = prefix
1768        .strip_prefix("target.")
1769        .map(|value| format!("target = {}", value.trim_matches(['\'', '"'])));
1770    target.map(|condition| (scope, Some(condition)))
1771}
1772
1773fn parse_cargo_dependency(
1774    path: &str,
1775    item: &TomlEntry,
1776) -> Result<(String, Option<String>, bool, Vec<String>), PackageManifestError> {
1777    if item.value.trim().starts_with(['"', '\'']) {
1778        return Ok((
1779            item.key.clone(),
1780            Some(toml_string(path, item)?),
1781            false,
1782            Vec::new(),
1783        ));
1784    }
1785    let values = inline_table(path, item)?;
1786    let name = values
1787        .get("package")
1788        .map_or_else(|| item.key.clone(), |value| unquote_scalar(value));
1789    let version = values.get("version").map(|value| unquote_scalar(value));
1790    let optional = values
1791        .get("optional")
1792        .is_some_and(|value| value.trim() == "true");
1793    let mut features = values
1794        .get("features")
1795        .map(|value| {
1796            toml_array(
1797                path,
1798                &TomlEntry {
1799                    section: item.section.clone(),
1800                    key: "features".to_owned(),
1801                    value: value.clone(),
1802                    line: item.line,
1803                },
1804            )
1805        })
1806        .transpose()?
1807        .unwrap_or_default();
1808    features.sort();
1809    features.dedup();
1810    Ok((name, version, optional, features))
1811}
1812
1813fn parse_go_mod(
1814    path: &str,
1815    content: &str,
1816    tracker: &mut ExtractionTracker,
1817) -> Result<PackageManifest, PackageManifestError> {
1818    reject_nul(path, content)?;
1819    let mut result = PackageManifest::default();
1820    let mut require_block = false;
1821    let mut saw_module = false;
1822    for (index, line) in content.lines().enumerate() {
1823        if index.is_multiple_of(1_024) {
1824            tracker.check_structured_time()?;
1825        }
1826        let line_number = index + 1;
1827        let trimmed = line.trim();
1828        if let Some(module) = trimmed.strip_prefix("module ") {
1829            let module = module.trim();
1830            if module.is_empty() || saw_module {
1831                return Err(malformed(
1832                    path,
1833                    format!("invalid module at line {line_number}"),
1834                ));
1835            }
1836            saw_module = true;
1837            push_package(
1838                &mut result.packages,
1839                package(
1840                    PackageEcosystem::Go,
1841                    module.to_owned(),
1842                    None,
1843                    path,
1844                    evidence_at(content, line_number),
1845                ),
1846                tracker,
1847            )?;
1848            continue;
1849        }
1850        if trimmed == "require (" {
1851            if require_block {
1852                return Err(malformed(
1853                    path,
1854                    format!("nested require block at line {line_number}"),
1855                ));
1856            }
1857            require_block = true;
1858            continue;
1859        }
1860        if trimmed == ")" && require_block {
1861            require_block = false;
1862            continue;
1863        }
1864        let declaration = if require_block {
1865            trimmed
1866        } else {
1867            trimmed.strip_prefix("require ").unwrap_or_default()
1868        };
1869        if declaration.is_empty() || declaration.starts_with("//") {
1870            continue;
1871        }
1872        let indirect = declaration.contains("// indirect");
1873        let declaration = declaration.split("//").next().unwrap_or_default().trim();
1874        let mut fields = declaration.split_whitespace();
1875        let Some(name) = fields.next() else {
1876            continue;
1877        };
1878        let Some(version) = fields.next() else {
1879            return Err(malformed(
1880                path,
1881                format!("requirement lacks a version at line {line_number}"),
1882            ));
1883        };
1884        if fields.next().is_some() {
1885            return Err(malformed(
1886                path,
1887                format!("invalid requirement at line {line_number}"),
1888            ));
1889        }
1890        push_dependency(
1891            &mut result.dependencies,
1892            dependency(DependencyInput {
1893                ecosystem: PackageEcosystem::Go,
1894                name: name.to_owned(),
1895                version: Some(version.to_owned()),
1896                scope: DependencyScope::Runtime,
1897                optional: indirect,
1898                condition: indirect.then(|| "indirect".to_owned()),
1899                path,
1900                evidence: evidence_at(content, line_number),
1901                confidence: EXACT_CONFIDENCE,
1902            }),
1903            tracker,
1904        )?;
1905    }
1906    if require_block {
1907        return Err(malformed(path, "unterminated require block"));
1908    }
1909    if !saw_module {
1910        return Err(malformed(path, "missing `module` declaration"));
1911    }
1912    Ok(result)
1913}
1914
1915fn parse_go_work(
1916    path: &str,
1917    content: &str,
1918    tracker: &mut ExtractionTracker,
1919) -> Result<PackageManifest, PackageManifestError> {
1920    reject_nul(path, content)?;
1921    let mut result = PackageManifest::default();
1922    let mut use_block = false;
1923    for (index, line) in content.lines().enumerate() {
1924        if index.is_multiple_of(1_024) {
1925            tracker.check_structured_time()?;
1926        }
1927        let line_number = index + 1;
1928        let trimmed = line.split("//").next().unwrap_or_default().trim();
1929        if trimmed == "use (" {
1930            if use_block {
1931                return Err(malformed(
1932                    path,
1933                    format!("nested use block at line {line_number}"),
1934                ));
1935            }
1936            use_block = true;
1937            continue;
1938        }
1939        if trimmed == ")" && use_block {
1940            use_block = false;
1941            continue;
1942        }
1943        let member = if use_block {
1944            trimmed
1945        } else {
1946            trimmed.strip_prefix("use ").unwrap_or_default()
1947        };
1948        if member.is_empty() || member.starts_with("go ") || member.starts_with("toolchain ") {
1949            continue;
1950        }
1951        if member.split_whitespace().count() != 1 {
1952            return Err(malformed(
1953                path,
1954                format!("invalid use declaration at line {line_number}"),
1955            ));
1956        }
1957        push_value(
1958            &mut result.workspace_members,
1959            value_fact(path, content, member.to_owned(), line_number, member),
1960            tracker,
1961        )?;
1962    }
1963    if use_block {
1964        return Err(malformed(path, "unterminated use block"));
1965    }
1966    Ok(result)
1967}
1968
1969#[derive(Debug, Default)]
1970struct XmlNode {
1971    name: String,
1972    attributes: BTreeMap<String, String>,
1973    text: String,
1974    line: usize,
1975    children: Vec<usize>,
1976}
1977
1978#[derive(Debug)]
1979struct XmlDocument {
1980    nodes: Vec<XmlNode>,
1981}
1982
1983impl XmlDocument {
1984    fn node(&self, index: usize) -> &XmlNode {
1985        &self.nodes[index]
1986    }
1987
1988    fn children<'a>(&'a self, node: &'a XmlNode) -> impl Iterator<Item = &'a XmlNode> + 'a {
1989        node.children.iter().map(|index| self.node(*index))
1990    }
1991}
1992
1993fn parse_xml(
1994    path: &str,
1995    content: &str,
1996    tracker: &mut ExtractionTracker,
1997) -> Result<XmlDocument, PackageManifestError> {
1998    reject_nul(path, content)?;
1999    let mut nodes = vec![XmlNode {
2000        name: "#document".to_owned(),
2001        line: 1,
2002        ..XmlNode::default()
2003    }];
2004    let mut stack = vec![0_usize];
2005    let mut cursor = 0;
2006    let mut line = 1_usize;
2007    while cursor < content.len() {
2008        tracker.charge_work(1)?;
2009        let Some(relative_open) = content[cursor..].find('<') else {
2010            append_xml_text(&mut nodes, &stack, &content[cursor..], tracker)?;
2011            break;
2012        };
2013        let open = cursor + relative_open;
2014        let open_line = line.saturating_add(count_newlines(&content[cursor..open]));
2015        append_xml_text(&mut nodes, &stack, &content[cursor..open], tracker)?;
2016        if content[open..].starts_with("<!--") {
2017            let end = content[open + 4..]
2018                .find("-->")
2019                .map(|index| open + 4 + index + 3)
2020                .ok_or_else(|| malformed(path, "unterminated XML comment"))?;
2021            advance_xml_cursor(content, &mut cursor, &mut line, end);
2022            continue;
2023        }
2024        if content[open..].starts_with("<![CDATA[") {
2025            let start = open + 9;
2026            let end = content[start..]
2027                .find("]]>")
2028                .map(|index| start + index)
2029                .ok_or_else(|| malformed(path, "unterminated CDATA section"))?;
2030            append_xml_text(&mut nodes, &stack, &content[start..end], tracker)?;
2031            advance_xml_cursor(content, &mut cursor, &mut line, end + 3);
2032            continue;
2033        }
2034        let close = find_xml_tag_end(content, open + 1)
2035            .ok_or_else(|| malformed(path, "unterminated XML tag"))?;
2036        let raw = content[open + 1..close].trim();
2037        advance_xml_cursor(content, &mut cursor, &mut line, close + 1);
2038        if raw.starts_with('?') || raw.starts_with('!') {
2039            continue;
2040        }
2041        if let Some(name) = raw.strip_prefix('/') {
2042            let name = local_xml_name(name.trim());
2043            if stack.len() <= 1 {
2044                return Err(malformed(path, format!("unexpected closing tag `{name}`")));
2045            }
2046            let node_index = stack
2047                .pop()
2048                .ok_or_else(|| malformed(path, "XML parser stack underflow"))?;
2049            let node = &nodes[node_index];
2050            if node.name != name {
2051                return Err(malformed(
2052                    path,
2053                    format!("closing tag `{name}` does not match `{}`", node.name),
2054                ));
2055            }
2056            continue;
2057        }
2058        let self_closing = raw.ends_with('/');
2059        let declaration = raw.trim_end_matches('/').trim();
2060        let (name, attributes) = parse_xml_opening(path, declaration)?;
2061        let depth = u64::try_from(stack.len()).unwrap_or(u64::MAX);
2062        tracker.check_structural_depth(depth)?;
2063        tracker.charge_identifier(&name)?;
2064        let node_index = nodes.len();
2065        nodes.push(XmlNode {
2066            name,
2067            attributes,
2068            text: String::new(),
2069            line: open_line,
2070            children: Vec::new(),
2071        });
2072        let parent = *stack
2073            .last()
2074            .ok_or_else(|| malformed(path, "XML parser stack underflow"))?;
2075        nodes[parent].children.push(node_index);
2076        if !self_closing {
2077            stack.push(node_index);
2078        }
2079    }
2080    if stack.len() != 1 {
2081        let name = stack.last().map_or("", |index| nodes[*index].name.as_str());
2082        return Err(malformed(path, format!("unclosed XML tag `{name}`")));
2083    }
2084    Ok(XmlDocument { nodes })
2085}
2086
2087fn count_newlines(value: &str) -> usize {
2088    value.bytes().filter(|byte| *byte == b'\n').count()
2089}
2090
2091fn advance_xml_cursor(content: &str, cursor: &mut usize, line: &mut usize, next: usize) {
2092    *line = line.saturating_add(count_newlines(&content[*cursor..next]));
2093    *cursor = next;
2094}
2095
2096fn append_xml_text(
2097    nodes: &mut [XmlNode],
2098    stack: &[usize],
2099    text: &str,
2100    tracker: &mut ExtractionTracker,
2101) -> Result<(), ExtractionLimitExceeded> {
2102    if !text.is_empty() {
2103        tracker.charge_string(text)?;
2104    }
2105    if let Some(index) = stack.last()
2106        && let Some(node) = nodes.get_mut(*index)
2107    {
2108        node.text.push_str(text);
2109    }
2110    Ok(())
2111}
2112
2113fn find_xml_tag_end(content: &str, start: usize) -> Option<usize> {
2114    let mut quote = None;
2115    for (relative, character) in content[start..].char_indices() {
2116        if character == '\'' || character == '"' {
2117            if quote == Some(character) {
2118                quote = None;
2119            } else if quote.is_none() {
2120                quote = Some(character);
2121            }
2122        } else if character == '>' && quote.is_none() {
2123            return Some(start + relative);
2124        }
2125    }
2126    None
2127}
2128
2129fn parse_xml_opening(
2130    path: &str,
2131    declaration: &str,
2132) -> Result<(String, BTreeMap<String, String>), PackageManifestError> {
2133    let name_end = declaration
2134        .find(char::is_whitespace)
2135        .unwrap_or(declaration.len());
2136    let name = local_xml_name(&declaration[..name_end]);
2137    if name.is_empty() {
2138        return Err(malformed(path, "empty XML element name"));
2139    }
2140    let mut attributes = BTreeMap::new();
2141    let mut rest = declaration[name_end..].trim();
2142    while !rest.is_empty() {
2143        let Some(equal) = rest.find('=') else {
2144            return Err(malformed(path, "XML attribute lacks `=`"));
2145        };
2146        let key = rest[..equal].trim();
2147        rest = rest[equal + 1..].trim_start();
2148        let quote = rest
2149            .chars()
2150            .next()
2151            .filter(|character| *character == '\'' || *character == '"')
2152            .ok_or_else(|| malformed(path, "XML attribute value must be quoted"))?;
2153        let tail = &rest[quote.len_utf8()..];
2154        let end = tail
2155            .find(quote)
2156            .ok_or_else(|| malformed(path, "unterminated XML attribute"))?;
2157        attributes.insert(local_xml_name(key), decode_xml_entities(&tail[..end]));
2158        rest = tail[end + quote.len_utf8()..].trim_start();
2159    }
2160    Ok((name, attributes))
2161}
2162
2163fn local_xml_name(name: &str) -> String {
2164    name.rsplit(':').next().unwrap_or(name).to_owned()
2165}
2166
2167fn decode_xml_entities(value: &str) -> String {
2168    value
2169        .replace("&quot;", "\"")
2170        .replace("&apos;", "'")
2171        .replace("&lt;", "<")
2172        .replace("&gt;", ">")
2173        .replace("&amp;", "&")
2174}
2175
2176fn child<'a>(document: &'a XmlDocument, node: &'a XmlNode, name: &str) -> Option<&'a XmlNode> {
2177    document.children(node).find(|item| item.name == name)
2178}
2179
2180fn child_text(document: &XmlDocument, node: &XmlNode, name: &str) -> Option<String> {
2181    child(document, node, name)
2182        .map(|item| decode_xml_entities(item.text.trim()))
2183        .filter(|value| !value.is_empty())
2184}
2185
2186fn parse_maven(
2187    path: &str,
2188    content: &str,
2189    tracker: &mut ExtractionTracker,
2190) -> Result<PackageManifest, PackageManifestError> {
2191    let document = parse_xml(path, content, tracker)?;
2192    let project = document
2193        .children(document.node(0))
2194        .find(|node| node.name == "project")
2195        .ok_or_else(|| malformed(path, "missing `project` root element"))?;
2196    let mut result = PackageManifest::default();
2197    let group = child_text(&document, project, "groupId").or_else(|| {
2198        child(&document, project, "parent").and_then(|node| child_text(&document, node, "groupId"))
2199    });
2200    let artifact = child_text(&document, project, "artifactId");
2201    let version = child_text(&document, project, "version").or_else(|| {
2202        child(&document, project, "parent").and_then(|node| child_text(&document, node, "version"))
2203    });
2204    if let (Some(group), Some(artifact)) = (group, artifact)
2205        && !contains_dynamic(&group)
2206        && !contains_dynamic(&artifact)
2207    {
2208        let evidence = child(&document, project, "artifactId").map_or_else(
2209            || evidence_at(content, project.line),
2210            |node| evidence_at(content, node.line),
2211        );
2212        push_package(
2213            &mut result.packages,
2214            package(
2215                PackageEcosystem::Maven,
2216                format!("{group}:{artifact}"),
2217                version.filter(|value| !contains_dynamic(value)),
2218                path,
2219                evidence,
2220            ),
2221            tracker,
2222        )?;
2223    }
2224    if let Some(dependencies) = child(&document, project, "dependencies") {
2225        collect_maven_dependencies(
2226            path,
2227            content,
2228            &document,
2229            dependencies,
2230            None,
2231            &mut result,
2232            tracker,
2233        )?;
2234    }
2235    if let Some(profiles) = child(&document, project, "profiles") {
2236        for profile in document
2237            .children(profiles)
2238            .filter(|node| node.name == "profile")
2239        {
2240            let profile_id = child_text(&document, profile, "id");
2241            if let Some(dependencies) = child(&document, profile, "dependencies") {
2242                collect_maven_dependencies(
2243                    path,
2244                    content,
2245                    &document,
2246                    dependencies,
2247                    profile_id
2248                        .as_deref()
2249                        .map(|id| format!("profile = \"{id}\""))
2250                        .as_deref(),
2251                    &mut result,
2252                    tracker,
2253                )?;
2254            }
2255        }
2256    }
2257    Ok(result)
2258}
2259
2260fn collect_maven_dependencies(
2261    path: &str,
2262    content: &str,
2263    document: &XmlDocument,
2264    dependencies: &XmlNode,
2265    profile: Option<&str>,
2266    output: &mut PackageManifest,
2267    tracker: &mut ExtractionTracker,
2268) -> Result<(), PackageManifestError> {
2269    for item in document
2270        .children(dependencies)
2271        .filter(|node| node.name == "dependency")
2272    {
2273        let (Some(group), Some(artifact)) = (
2274            child_text(document, item, "groupId"),
2275            child_text(document, item, "artifactId"),
2276        ) else {
2277            continue;
2278        };
2279        if contains_dynamic(&group) || contains_dynamic(&artifact) {
2280            continue;
2281        }
2282        let version =
2283            child_text(document, item, "version").filter(|value| !contains_dynamic(value));
2284        let declared_scope =
2285            child_text(document, item, "scope").unwrap_or_else(|| "compile".to_owned());
2286        let scope = match declared_scope.as_str() {
2287            "test" => DependencyScope::Test,
2288            "provided" | "system" => DependencyScope::Build,
2289            _ => DependencyScope::Runtime,
2290        };
2291        let optional = child_text(document, item, "optional").is_some_and(|value| value == "true");
2292        let type_condition = child_text(document, item, "type")
2293            .filter(|value| value != "jar")
2294            .map(|value| format!("type = \"{value}\""));
2295        push_dependency(
2296            &mut output.dependencies,
2297            dependency(DependencyInput {
2298                ecosystem: PackageEcosystem::Maven,
2299                name: format!("{group}:{artifact}"),
2300                version,
2301                scope: if optional {
2302                    DependencyScope::Optional
2303                } else {
2304                    scope
2305                },
2306                optional,
2307                condition: join_conditions(profile.map(str::to_owned), type_condition),
2308                path,
2309                evidence: evidence_at(content, item.line),
2310                confidence: EXACT_CONFIDENCE,
2311            }),
2312            tracker,
2313        )?;
2314    }
2315    Ok(())
2316}
2317
2318fn parse_gradle(
2319    path: &str,
2320    content: &str,
2321    tracker: &mut ExtractionTracker,
2322) -> Result<PackageManifest, PackageManifestError> {
2323    reject_nul(path, content)?;
2324    validate_gradle_balance(path, content, tracker)?;
2325    let mut result = PackageManifest::default();
2326    for (index, line) in content.lines().enumerate() {
2327        if index.is_multiple_of(1_024) {
2328            tracker.check_structured_time()?;
2329        }
2330        let line_number = index + 1;
2331        let trimmed = line.trim();
2332        if trimmed.starts_with("//") || trimmed.is_empty() {
2333            continue;
2334        }
2335        let Some((configuration, argument)) = parse_gradle_declaration(trimmed) else {
2336            continue;
2337        };
2338        let Some((group, artifact, version)) = parse_gradle_coordinate(argument) else {
2339            continue;
2340        };
2341        let (scope, optional) = gradle_scope(configuration);
2342        push_dependency(
2343            &mut result.dependencies,
2344            dependency(DependencyInput {
2345                ecosystem: PackageEcosystem::Gradle,
2346                name: format!("{group}:{artifact}"),
2347                version,
2348                scope,
2349                optional,
2350                condition: Some(format!("configuration = \"{configuration}\"")),
2351                path,
2352                evidence: evidence_at(content, line_number),
2353                confidence: STATIC_TEXT_CONFIDENCE,
2354            }),
2355            tracker,
2356        )?;
2357    }
2358    Ok(result)
2359}
2360
2361fn validate_gradle_balance(
2362    path: &str,
2363    content: &str,
2364    tracker: &ExtractionTracker,
2365) -> Result<(), PackageManifestError> {
2366    let mut curly = 0_i32;
2367    let mut round = 0_i32;
2368    let mut quote = None;
2369    let mut escaped = false;
2370    for (index, character) in content.chars().enumerate() {
2371        if index.is_multiple_of(1_024) {
2372            tracker.check_structured_time()?;
2373        }
2374        if escaped {
2375            escaped = false;
2376            continue;
2377        }
2378        if character == '\\' && quote.is_some() {
2379            escaped = true;
2380            continue;
2381        }
2382        if character == '\'' || character == '"' {
2383            if quote == Some(character) {
2384                quote = None;
2385            } else if quote.is_none() {
2386                quote = Some(character);
2387            }
2388            continue;
2389        }
2390        if quote.is_some() {
2391            continue;
2392        }
2393        match character {
2394            '{' => curly += 1,
2395            '}' => curly -= 1,
2396            '(' => round += 1,
2397            ')' => round -= 1,
2398            _ => {}
2399        }
2400        if curly < 0 || round < 0 {
2401            return Err(malformed(path, "unbalanced Gradle delimiters"));
2402        }
2403    }
2404    if curly != 0 || round != 0 || quote.is_some() {
2405        return Err(malformed(path, "unbalanced Gradle delimiters or quotes"));
2406    }
2407    Ok(())
2408}
2409
2410fn parse_gradle_declaration(line: &str) -> Option<(&str, &str)> {
2411    let configuration_end = line
2412        .char_indices()
2413        .find_map(|(index, character)| {
2414            (character.is_whitespace() || character == '(').then_some(index)
2415        })
2416        .unwrap_or(line.len());
2417    let configuration = &line[..configuration_end];
2418    if !matches!(
2419        configuration,
2420        "api"
2421            | "implementation"
2422            | "runtimeOnly"
2423            | "compileOnly"
2424            | "testImplementation"
2425            | "testRuntimeOnly"
2426            | "testCompileOnly"
2427            | "developmentOnly"
2428            | "annotationProcessor"
2429            | "kapt"
2430            | "classpath"
2431    ) {
2432        return None;
2433    }
2434    let rest = line[configuration_end..].trim();
2435    let argument = if rest.starts_with('(') && rest.ends_with(')') {
2436        rest[1..rest.len() - 1].trim()
2437    } else {
2438        rest
2439    };
2440    Some((configuration, argument))
2441}
2442
2443fn parse_gradle_coordinate(argument: &str) -> Option<(&str, &str, Option<String>)> {
2444    let literal = argument
2445        .strip_prefix('"')
2446        .and_then(|value| value.strip_suffix('"'))
2447        .or_else(|| {
2448            argument
2449                .strip_prefix('\'')
2450                .and_then(|value| value.strip_suffix('\''))
2451        })?;
2452    if contains_dynamic(literal) {
2453        return None;
2454    }
2455    let mut parts = literal.split(':');
2456    let group = parts.next()?;
2457    let artifact = parts.next()?;
2458    let version = parts.next().map(str::to_owned);
2459    if group.is_empty() || artifact.is_empty() || parts.next().is_some() {
2460        return None;
2461    }
2462    Some((group, artifact, version))
2463}
2464
2465fn gradle_scope(configuration: &str) -> (DependencyScope, bool) {
2466    if configuration.starts_with("test") {
2467        (DependencyScope::Test, false)
2468    } else if matches!(configuration, "annotationProcessor" | "kapt" | "classpath") {
2469        (DependencyScope::Build, false)
2470    } else if matches!(configuration, "compileOnly" | "developmentOnly") {
2471        (DependencyScope::Dev, false)
2472    } else {
2473        (DependencyScope::Runtime, false)
2474    }
2475}
2476
2477fn parse_packages_config(
2478    path: &str,
2479    content: &str,
2480    tracker: &mut ExtractionTracker,
2481) -> Result<PackageManifest, PackageManifestError> {
2482    let document = parse_xml(path, content, tracker)?;
2483    let packages = document
2484        .children(document.node(0))
2485        .find(|node| node.name == "packages")
2486        .ok_or_else(|| malformed(path, "missing `packages` root element"))?;
2487    let mut result = PackageManifest::default();
2488    for item in document
2489        .children(packages)
2490        .filter(|node| node.name == "package")
2491    {
2492        let Some(name) = item.attributes.get("id") else {
2493            return Err(malformed(path, "`package` requires an `id` attribute"));
2494        };
2495        let version = item.attributes.get("version").cloned();
2496        let development = item
2497            .attributes
2498            .get("developmentDependency")
2499            .is_some_and(|value| value.eq_ignore_ascii_case("true"));
2500        push_dependency(
2501            &mut result.dependencies,
2502            dependency(DependencyInput {
2503                ecosystem: PackageEcosystem::NuGet,
2504                name: name.clone(),
2505                version,
2506                scope: if development {
2507                    DependencyScope::Dev
2508                } else {
2509                    DependencyScope::Runtime
2510                },
2511                optional: false,
2512                condition: item
2513                    .attributes
2514                    .get("targetFramework")
2515                    .map(|value| format!("targetFramework = \"{value}\"")),
2516                path,
2517                evidence: evidence_at(content, item.line),
2518                confidence: EXACT_CONFIDENCE,
2519            }),
2520            tracker,
2521        )?;
2522    }
2523    Ok(result)
2524}
2525
2526fn parse_csproj(
2527    path: &str,
2528    content: &str,
2529    tracker: &mut ExtractionTracker,
2530) -> Result<PackageManifest, PackageManifestError> {
2531    let document = parse_xml(path, content, tracker)?;
2532    let project = document
2533        .children(document.node(0))
2534        .find(|node| node.name == "Project")
2535        .ok_or_else(|| malformed(path, "missing `Project` root element"))?;
2536    let mut result = PackageManifest::default();
2537    if let Some(identity) = csproj_package_identity(path, &document, project)? {
2538        push_package(
2539            &mut result.packages,
2540            package(
2541                PackageEcosystem::NuGet,
2542                identity.name,
2543                identity.version,
2544                path,
2545                evidence_at(content, identity.name_node.line),
2546            ),
2547            tracker,
2548        )?;
2549    }
2550    for group in document
2551        .children(project)
2552        .filter(|node| node.name == "ItemGroup")
2553    {
2554        let group_condition = group.attributes.get("Condition").cloned();
2555        for reference in document
2556            .children(group)
2557            .filter(|node| node.name == "PackageReference")
2558        {
2559            let Some(name) = reference
2560                .attributes
2561                .get("Include")
2562                .or_else(|| reference.attributes.get("Update"))
2563            else {
2564                return Err(malformed(
2565                    path,
2566                    "`PackageReference` requires `Include` or `Update`",
2567                ));
2568            };
2569            if contains_dynamic(name) {
2570                continue;
2571            }
2572            let version = reference
2573                .attributes
2574                .get("Version")
2575                .cloned()
2576                .or_else(|| child_text(&document, reference, "Version"))
2577                .filter(|value| !contains_dynamic(value));
2578            let private_assets = reference
2579                .attributes
2580                .get("PrivateAssets")
2581                .cloned()
2582                .or_else(|| child_text(&document, reference, "PrivateAssets"));
2583            let reference_condition = reference.attributes.get("Condition").cloned();
2584            push_dependency(
2585                &mut result.dependencies,
2586                dependency(DependencyInput {
2587                    ecosystem: PackageEcosystem::NuGet,
2588                    name: name.clone(),
2589                    version,
2590                    scope: DependencyScope::Runtime,
2591                    optional: false,
2592                    condition: join_conditions(group_condition.clone(), reference_condition),
2593                    path,
2594                    evidence: evidence_at(content, reference.line),
2595                    confidence: if private_assets.as_deref() == Some("all") {
2596                        STATIC_TEXT_CONFIDENCE
2597                    } else {
2598                        EXACT_CONFIDENCE
2599                    },
2600                }),
2601                tracker,
2602            )?;
2603        }
2604    }
2605    Ok(result)
2606}
2607
2608struct CsprojPackageIdentity<'a> {
2609    name_node: &'a XmlNode,
2610    name: String,
2611    version: Option<String>,
2612}
2613
2614fn csproj_package_identity<'a>(
2615    path: &str,
2616    document: &'a XmlDocument,
2617    project: &'a XmlNode,
2618) -> Result<Option<CsprojPackageIdentity<'a>>, PackageManifestError> {
2619    let mut identities = Vec::new();
2620    for group in document
2621        .children(project)
2622        .filter(|node| node.name == "PropertyGroup")
2623    {
2624        let Some(name_node) = child(document, group, "PackageId") else {
2625            continue;
2626        };
2627        let name = decode_xml_entities(name_node.text.trim());
2628        if name.is_empty() || contains_dynamic(&name) {
2629            continue;
2630        }
2631        let version = ["PackageVersion", "Version", "VersionPrefix"]
2632            .into_iter()
2633            .find_map(|field| child_text(document, group, field))
2634            .filter(|value| !contains_dynamic(value));
2635        identities.push(CsprojPackageIdentity {
2636            name_node,
2637            name,
2638            version,
2639        });
2640    }
2641    identities.sort_by(|left, right| left.name.cmp(&right.name));
2642    identities.dedup_by(|left, right| left.name == right.name && left.version == right.version);
2643    if identities.len() > 1 {
2644        return Err(malformed(
2645            path,
2646            "multiple distinct static `PackageId` declarations are ambiguous",
2647        ));
2648    }
2649    Ok(identities.pop())
2650}
2651
2652fn contains_dynamic(value: &str) -> bool {
2653    value.contains("${")
2654        || value.contains("$(")
2655        || value.contains('$')
2656        || value.contains("#{")
2657        || value.contains("{{")
2658}
2659
2660fn join_conditions(left: Option<String>, right: Option<String>) -> Option<String> {
2661    match (left, right) {
2662        (Some(left), Some(right)) => Some(format!("{left} and {right}")),
2663        (Some(value), None) | (None, Some(value)) => Some(value),
2664        (None, None) => None,
2665    }
2666}
2667
2668fn reject_nul(path: &str, content: &str) -> Result<(), PackageManifestError> {
2669    if content.contains('\0') {
2670        return Err(malformed(path, "input contains a NUL byte"));
2671    }
2672    Ok(())
2673}
2674
2675#[cfg(test)]
2676mod tests {
2677    use std::fmt::Write as _;
2678    use std::sync::atomic::{AtomicU64, Ordering};
2679    use std::time::Duration;
2680
2681    use super::*;
2682    use crate::{ExtractionClock, ExtractionResource};
2683
2684    #[derive(Debug)]
2685    struct FixedClock(Duration);
2686
2687    impl ExtractionClock for FixedClock {
2688        fn elapsed(&self) -> Duration {
2689            self.0
2690        }
2691    }
2692
2693    #[derive(Debug)]
2694    struct AdvancingClock(AtomicU64);
2695
2696    impl ExtractionClock for AdvancingClock {
2697        fn elapsed(&self) -> Duration {
2698            Duration::from_millis(self.0.fetch_add(2, Ordering::Relaxed))
2699        }
2700    }
2701
2702    fn extract(path: &str, source: &str) -> PackageManifest {
2703        extract_package_manifest(path, source).expect("fixture should parse")
2704    }
2705
2706    fn nested_maven(depth: usize) -> String {
2707        let mut source = String::from("<project>");
2708        source.push_str(&"<level>".repeat(depth.saturating_sub(1)));
2709        source.push_str(&"</level>".repeat(depth.saturating_sub(1)));
2710        source.push_str("</project>");
2711        source
2712    }
2713
2714    #[test]
2715    fn package_facts_should_honor_work_observation_and_identifier_budgets() {
2716        let work_budgets = ExtractionBudgets {
2717            max_work_units_per_artifact: 1,
2718            ..ExtractionBudgets::default()
2719        };
2720        let mut work = ExtractionTracker::new("package.json", "packages", &work_budgets);
2721        assert!(matches!(
2722            extract_package_manifest_with_tracker("package.json", "{}", &mut work),
2723            Err(PackageManifestError::LimitExceeded(error))
2724                if error.resource == ExtractionResource::WorkUnits
2725                    && error.observed == 2
2726                    && error.maximum == 1
2727        ));
2728
2729        let observation_budgets = ExtractionBudgets {
2730            max_observations_per_artifact: 1,
2731            ..ExtractionBudgets::default()
2732        };
2733        let mut observations =
2734            ExtractionTracker::new("package.json", "packages", &observation_budgets);
2735        assert!(matches!(
2736            extract_package_manifest_with_tracker(
2737                "package.json",
2738                r#"{"name":"service","dependencies":{"serde":"1"}}"#,
2739                &mut observations,
2740            ),
2741            Err(PackageManifestError::LimitExceeded(error))
2742                if error.resource == ExtractionResource::Observations
2743                    && error.observed == 2
2744                    && error.maximum == 1
2745        ));
2746
2747        let exact_observation_budgets = ExtractionBudgets {
2748            max_observations_per_artifact: 2,
2749            ..ExtractionBudgets::default()
2750        };
2751        let mut exact_observations =
2752            ExtractionTracker::new("package.json", "packages", &exact_observation_budgets);
2753        assert!(
2754            extract_package_manifest_with_tracker(
2755                "package.json",
2756                r#"{"name":"service","dependencies":{"serde":"1"}}"#,
2757                &mut exact_observations,
2758            )
2759            .is_ok()
2760        );
2761
2762        let identifier_budgets = ExtractionBudgets {
2763            max_identifier_bytes_per_value: 4,
2764            ..ExtractionBudgets::default()
2765        };
2766        let mut identifier =
2767            ExtractionTracker::new("package.json", "packages", &identifier_budgets);
2768        assert!(matches!(
2769            extract_package_manifest_with_tracker(
2770                "package.json",
2771                r#"{"name":"service"}"#,
2772                &mut identifier,
2773            ),
2774            Err(PackageManifestError::LimitExceeded(error))
2775                if error.resource == ExtractionResource::IdentifierBytesPerValue
2776                    && error.maximum == 4
2777        ));
2778    }
2779
2780    #[test]
2781    fn package_json_should_reject_depth_65_before_dom_materialization() {
2782        let budgets = ExtractionBudgets {
2783            max_structural_depth_per_artifact: 64,
2784            ..ExtractionBudgets::default()
2785        };
2786        let exact_source = format!("{}0{}", "[".repeat(64), "]".repeat(64));
2787        let mut exact = ExtractionTracker::new("package.json", "packages", &budgets);
2788        assert!(!matches!(
2789            extract_package_manifest_with_tracker("package.json", &exact_source, &mut exact),
2790            Err(PackageManifestError::LimitExceeded(error))
2791                if error.resource == ExtractionResource::StructuralDepth
2792        ));
2793
2794        let above_source = format!("{}0{}", "[".repeat(65), "]".repeat(65));
2795        let mut above = ExtractionTracker::new("package.json", "packages", &budgets);
2796        assert!(matches!(
2797            extract_package_manifest_with_tracker("package.json", &above_source, &mut above),
2798            Err(PackageManifestError::LimitExceeded(error))
2799                if error.resource == ExtractionResource::StructuralDepth
2800                    && error.observed == 65
2801                    && error.maximum == 64
2802        ));
2803    }
2804
2805    #[test]
2806    fn xml_depth_should_accept_64_and_reject_65_with_an_iterative_stack() {
2807        let budgets = ExtractionBudgets {
2808            max_structural_depth_per_artifact: 64,
2809            ..ExtractionBudgets::default()
2810        };
2811        let mut exact = ExtractionTracker::new("pom.xml", "packages", &budgets);
2812        let mut above = ExtractionTracker::new("pom.xml", "packages", &budgets);
2813
2814        assert!(
2815            extract_package_manifest_with_tracker("pom.xml", &nested_maven(64), &mut exact).is_ok()
2816        );
2817        assert!(matches!(
2818            extract_package_manifest_with_tracker("pom.xml", &nested_maven(65), &mut above),
2819            Err(PackageManifestError::LimitExceeded(error))
2820                if error.resource == ExtractionResource::StructuralDepth
2821                    && error.observed == 65
2822                    && error.maximum == 64
2823        ));
2824    }
2825
2826    #[test]
2827    fn short_package_extraction_should_check_its_final_deadline() {
2828        let budgets = ExtractionBudgets {
2829            max_structured_wall_time_ms_per_artifact: 1,
2830            ..ExtractionBudgets::default()
2831        };
2832        let mut tracker = ExtractionTracker::with_clock(
2833            "Cargo.toml",
2834            "packages",
2835            &budgets,
2836            Box::new(FixedClock(Duration::from_millis(2))),
2837        );
2838
2839        assert!(matches!(
2840            extract_package_manifest_with_tracker("Cargo.toml", "", &mut tracker),
2841            Err(PackageManifestError::LimitExceeded(error))
2842                if error.resource == ExtractionResource::StructuredWallTimeMs
2843        ));
2844    }
2845
2846    #[test]
2847    fn line_parser_should_check_deadline_during_materialization() {
2848        let budgets = ExtractionBudgets {
2849            max_structured_wall_time_ms_per_artifact: 1,
2850            ..ExtractionBudgets::default()
2851        };
2852        let mut tracker = ExtractionTracker::with_clock(
2853            "requirements.txt",
2854            "packages",
2855            &budgets,
2856            Box::new(AdvancingClock(AtomicU64::new(0))),
2857        );
2858        let source = "dep==1\n".repeat(1_025);
2859
2860        assert!(matches!(
2861            parse_requirements("requirements.txt", &source, &mut tracker),
2862            Err(PackageManifestError::LimitExceeded(error))
2863                if error.resource == ExtractionResource::StructuredWallTimeMs
2864        ));
2865    }
2866
2867    #[test]
2868    fn xml_parser_should_track_lines_without_prefix_rescans() {
2869        let mut source = String::from("<packages>\n");
2870        for index in 0..5_000 {
2871            writeln!(source, "<package id=\"p{index}\" version=\"1\"/>")
2872                .expect("String writes are infallible");
2873        }
2874        source.push_str("</packages>\n");
2875        let budgets = ExtractionBudgets::default();
2876        let mut tracker = ExtractionTracker::new("packages.config", "packages", &budgets);
2877        let document = parse_xml("packages.config", &source, &mut tracker).expect("bounded XML");
2878        let last_package = document
2879            .nodes
2880            .iter()
2881            .rev()
2882            .find(|node| node.name == "package")
2883            .expect("last package");
2884
2885        assert_eq!(last_package.line, 5_001);
2886    }
2887
2888    #[test]
2889    fn package_json_preserves_scopes_workspaces_exports_and_lines() {
2890        let source = r#"{
2891  "name": "@acme/web",
2892  "version": "1.2.3",
2893  "dependencies": {"zod": "^3.0.0"},
2894  "devDependencies": {"vitest": "~2.0"},
2895  "peerDependencies": {"react": ">=18"},
2896  "peerDependenciesMeta": {"react": {"optional": true}},
2897  "optionalDependencies": {"fsevents": "2.3.3"},
2898  "workspaces": ["apps/*", "packages/*"],
2899  "exports": {".": "./index.js", "./cli": "./cli.js"}
2900}"#;
2901        let result = extract("web/package.json", source);
2902
2903        assert_eq!(
2904            (
2905                result.packages[0].name.as_str(),
2906                result
2907                    .dependencies
2908                    .iter()
2909                    .map(|item| (item.name.as_str(), item.scope, item.optional))
2910                    .collect::<Vec<_>>(),
2911                result
2912                    .workspace_members
2913                    .iter()
2914                    .map(|item| item.value.as_str())
2915                    .collect::<Vec<_>>(),
2916                result
2917                    .exports
2918                    .iter()
2919                    .map(|item| item.value.as_str())
2920                    .collect::<Vec<_>>(),
2921                result.dependencies[0].evidence.line,
2922            ),
2923            (
2924                "@acme/web",
2925                vec![
2926                    ("fsevents", DependencyScope::Optional, true),
2927                    ("react", DependencyScope::Peer, true),
2928                    ("vitest", DependencyScope::Dev, false),
2929                    ("zod", DependencyScope::Runtime, false),
2930                ],
2931                vec!["apps/*", "packages/*"],
2932                vec![".", "./cli"],
2933                8,
2934            )
2935        );
2936    }
2937
2938    #[test]
2939    fn npm_lockfiles_emit_presence_without_transitive_dependencies() {
2940        let source = r#"{"name":"app","lockfileVersion":3,"packages":{"":{"name":"app"}}}"#;
2941        let result = extract("package-lock.json", source);
2942
2943        assert_eq!(
2944            (
2945                result.lockfiles[0].package_manager.as_str(),
2946                result.lockfiles[0].format_version.as_deref(),
2947                result.dependencies.len(),
2948            ),
2949            ("npm", Some("3"), 0)
2950        );
2951    }
2952
2953    #[test]
2954    fn cargo_lockfile_emits_presence_without_transitive_dependencies() {
2955        let source = "# This file is automatically @generated by Cargo.\nversion = 4\n\n[[package]]\nname = \"api\"\nversion = \"1.0.0\"\n";
2956        let result = extract("Cargo.lock", source);
2957
2958        assert_eq!(
2959            (
2960                result.lockfiles[0].ecosystem,
2961                result.lockfiles[0].package_manager.as_str(),
2962                result.lockfiles[0].format_version.as_deref(),
2963                result.dependencies.len(),
2964            ),
2965            (PackageEcosystem::Cargo, "cargo", Some("4"), 0)
2966        );
2967    }
2968
2969    #[test]
2970    fn pnpm_and_yarn_lockfiles_preserve_format_metadata() {
2971        let pnpm = extract("pnpm-lock.yaml", "lockfileVersion: '9.0'\nimporters: {}\n");
2972        let yarn = extract("yarn.lock", "__metadata:\n  version: 8\n  cacheKey: 10c0\n");
2973
2974        assert_eq!(
2975            (
2976                pnpm.lockfiles[0].format_version.as_deref(),
2977                yarn.lockfiles[0].format_version.as_deref(),
2978            ),
2979            (Some("9.0"), Some("8"))
2980        );
2981    }
2982
2983    #[test]
2984    fn pyproject_extracts_pep621_poetry_groups_markers_and_optional_flags() {
2985        let source = r#"[project]
2986name = "service"
2987version = "2.0.0"
2988dependencies = [
2989  "httpx>=0.27; python_version >= '3.11'",
2990]
2991[project.optional-dependencies]
2992docs = ["sphinx~=7.0"]
2993[tool.poetry.dependencies]
2994orjson = { version = "^3.10", optional = true, markers = "platform_python_implementation == 'CPython'" }
2995[tool.poetry.group.test.dependencies]
2996pytest = "^8.0"
2997"#;
2998        let result = extract("pyproject.toml", source);
2999
3000        assert_eq!(
3001            result
3002                .dependencies
3003                .iter()
3004                .map(|item| (
3005                    item.name.as_str(),
3006                    item.scope,
3007                    item.optional,
3008                    item.condition.as_deref()
3009                ))
3010                .collect::<Vec<_>>(),
3011            vec![
3012                (
3013                    "httpx",
3014                    DependencyScope::Runtime,
3015                    false,
3016                    Some("python_version >= '3.11'")
3017                ),
3018                (
3019                    "orjson",
3020                    DependencyScope::Optional,
3021                    true,
3022                    Some("markers = \"platform_python_implementation == 'CPython'\"")
3023                ),
3024                (
3025                    "pytest",
3026                    DependencyScope::Test,
3027                    false,
3028                    Some("poetry group = \"test\"")
3029                ),
3030                (
3031                    "sphinx",
3032                    DependencyScope::Optional,
3033                    true,
3034                    Some("extra = \"docs\"")
3035                ),
3036            ]
3037        );
3038    }
3039
3040    #[test]
3041    fn requirements_preserve_markers_and_ignore_includes_and_dynamic_urls() {
3042        let source = "# pinned\nrequests>=2.32 ; python_version >= \"3.10\"\n-r base.txt\npkg @ git+https://example.invalid/pkg\n";
3043        let result = extract("config/requirements-dev.txt", source);
3044
3045        assert_eq!(
3046            (
3047                result.dependencies.len(),
3048                result.dependencies[0].name.as_str(),
3049                result.dependencies[0].version_or_range.as_deref(),
3050                result.dependencies[0].condition.as_deref(),
3051                result.dependencies[0].evidence.line,
3052            ),
3053            (
3054                1,
3055                "requests",
3056                Some(">=2.32"),
3057                Some("python_version >= \"3.10\""),
3058                2,
3059            )
3060        );
3061    }
3062
3063    #[test]
3064    fn cargo_extracts_scopes_targets_features_renames_and_workspace_members() {
3065        let source = r#"[package]
3066name = "engine"
3067version = "0.4.0"
3068[dependencies]
3069serde = "1"
3070wire = { package = "prost", version = "0.13", optional = true }
3071[dev-dependencies]
3072insta = "1"
3073[build-dependencies]
3074cc = "1"
3075[target.'cfg(unix)'.dependencies]
3076nix = "0.29"
3077[features]
3078grpc = ["dep:wire"]
3079[workspace]
3080members = ["api", "worker"]
3081"#;
3082        let result = extract("Cargo.toml", source);
3083
3084        assert_eq!(
3085            (
3086                result
3087                    .dependencies
3088                    .iter()
3089                    .map(|item| (
3090                        item.name.as_str(),
3091                        item.scope,
3092                        item.optional,
3093                        item.condition.as_deref()
3094                    ))
3095                    .collect::<Vec<_>>(),
3096                result.features[0].value.as_str(),
3097                result
3098                    .workspace_members
3099                    .iter()
3100                    .map(|item| item.value.as_str())
3101                    .collect::<Vec<_>>(),
3102            ),
3103            (
3104                vec![
3105                    ("cc", DependencyScope::Build, false, None),
3106                    ("insta", DependencyScope::Dev, false, None),
3107                    (
3108                        "nix",
3109                        DependencyScope::Runtime,
3110                        false,
3111                        Some("target = cfg(unix)")
3112                    ),
3113                    (
3114                        "prost",
3115                        DependencyScope::Runtime,
3116                        true,
3117                        Some("feature = \"grpc\"")
3118                    ),
3119                    ("serde", DependencyScope::Runtime, false, None),
3120                ],
3121                "grpc",
3122                vec!["api", "worker"],
3123            )
3124        );
3125    }
3126
3127    #[test]
3128    fn cargo_preserves_sqlx_backend_and_capability_features() {
3129        let source = r#"
3130[package]
3131name = "api"
3132version = "1.0.0"
3133
3134[dependencies]
3135sqlx = { version = "0.8", features = ["postgres", "macros", "migrate", "postgres"] }
3136"#;
3137        let result = extract("Cargo.toml", source);
3138        let sqlx = result
3139            .dependencies
3140            .iter()
3141            .find(|dependency| dependency.name == "sqlx")
3142            .expect("SQLx dependency");
3143
3144        assert_eq!(
3145            (
3146                sqlx.version_or_range.as_deref(),
3147                sqlx.scope,
3148                sqlx.condition.as_deref(),
3149            ),
3150            (
3151                Some("0.8"),
3152                DependencyScope::Runtime,
3153                Some("dependency features = \"macros|migrate|postgres\""),
3154            )
3155        );
3156    }
3157
3158    #[test]
3159    fn cargo_preserves_mysql_async_transport_and_mapping_features() {
3160        let source = r#"
3161[package]
3162name = "api"
3163version = "1.0.0"
3164
3165[dependencies]
3166mysql_async = { version = "0.37", default-features = false, features = ["minimal-rust", "rustls-tls", "ring", "derive"] }
3167"#;
3168        let result = extract("Cargo.toml", source);
3169        let mysql_async = result
3170            .dependencies
3171            .iter()
3172            .find(|dependency| dependency.name == "mysql_async")
3173            .expect("mysql_async dependency");
3174
3175        assert_eq!(
3176            (
3177                mysql_async.version_or_range.as_deref(),
3178                mysql_async.scope,
3179                mysql_async.condition.as_deref(),
3180            ),
3181            (
3182                Some("0.37"),
3183                DependencyScope::Runtime,
3184                Some("dependency features = \"derive|minimal-rust|ring|rustls-tls\""),
3185            )
3186        );
3187    }
3188
3189    #[test]
3190    fn go_mod_preserves_indirect_condition_and_go_work_members() {
3191        let module = extract(
3192            "go.mod",
3193            "module example.com/service\n\ngo 1.23\nrequire (\n example.com/a v1.2.0\n example.com/b v2.0.0 // indirect\n)\n",
3194        );
3195        let workspace = extract("go.work", "go 1.23\nuse (\n ./api\n ./worker\n)\n");
3196
3197        assert_eq!(
3198            (
3199                module.packages[0].name.as_str(),
3200                module.dependencies[1].optional,
3201                module.dependencies[1].condition.as_deref(),
3202                workspace
3203                    .workspace_members
3204                    .iter()
3205                    .map(|item| item.value.as_str())
3206                    .collect::<Vec<_>>(),
3207            ),
3208            (
3209                "example.com/service",
3210                true,
3211                Some("indirect"),
3212                vec!["./api", "./worker"],
3213            )
3214        );
3215    }
3216
3217    #[test]
3218    fn maven_extracts_static_coordinates_scopes_optional_and_profiles() {
3219        let source = r"<project>
3220  <parent><groupId>com.acme</groupId><version>1.0</version></parent>
3221  <artifactId>service</artifactId>
3222  <dependencies>
3223    <dependency><groupId>org.slf4j</groupId><artifactId>slf4j-api</artifactId><version>2.0.16</version></dependency>
3224    <dependency><groupId>junit</groupId><artifactId>junit</artifactId><scope>test</scope><optional>true</optional></dependency>
3225    <dependency><groupId>${dynamic.group}</groupId><artifactId>ignored</artifactId></dependency>
3226  </dependencies>
3227  <profiles><profile><id>native</id><dependencies>
3228    <dependency><groupId>org.graalvm</groupId><artifactId>native-image</artifactId><version>24</version></dependency>
3229  </dependencies></profile></profiles>
3230</project>";
3231        let result = extract("pom.xml", source);
3232
3233        assert_eq!(
3234            (
3235                result.packages[0].name.as_str(),
3236                result
3237                    .dependencies
3238                    .iter()
3239                    .map(|item| (
3240                        item.name.as_str(),
3241                        item.scope,
3242                        item.optional,
3243                        item.condition.as_deref()
3244                    ))
3245                    .collect::<Vec<_>>(),
3246            ),
3247            (
3248                "com.acme:service",
3249                vec![
3250                    ("junit:junit", DependencyScope::Optional, true, None),
3251                    (
3252                        "org.graalvm:native-image",
3253                        DependencyScope::Runtime,
3254                        false,
3255                        Some("profile = \"native\"")
3256                    ),
3257                    ("org.slf4j:slf4j-api", DependencyScope::Runtime, false, None),
3258                ],
3259            )
3260        );
3261    }
3262
3263    #[test]
3264    fn gradle_extracts_only_literal_coordinates_with_configuration_conditions() {
3265        let source = r#"dependencies {
3266    implementation("com.squareup.okhttp3:okhttp:4.12.0")
3267    testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0'
3268    implementation(libs.jackson)
3269    annotationProcessor("org.example:processor:1.0")
3270}"#;
3271        let result = extract("app/build.gradle.kts", source);
3272
3273        assert_eq!(
3274            result
3275                .dependencies
3276                .iter()
3277                .map(|item| (item.name.as_str(), item.scope, item.condition.as_deref()))
3278                .collect::<Vec<_>>(),
3279            vec![
3280                (
3281                    "com.squareup.okhttp3:okhttp",
3282                    DependencyScope::Runtime,
3283                    Some("configuration = \"implementation\"")
3284                ),
3285                (
3286                    "org.example:processor",
3287                    DependencyScope::Build,
3288                    Some("configuration = \"annotationProcessor\"")
3289                ),
3290                (
3291                    "org.junit.jupiter:junit-jupiter",
3292                    DependencyScope::Test,
3293                    Some("configuration = \"testImplementation\"")
3294                ),
3295            ]
3296        );
3297    }
3298
3299    #[test]
3300    fn nuget_extracts_packages_config_and_sdk_package_references() {
3301        let packages_config = extract(
3302            "packages.config",
3303            r#"<packages>
3304  <package id="Newtonsoft.Json" version="13.0.3" targetFramework="net48" />
3305  <package id="NUnit" version="4.2.2" developmentDependency="true" />
3306</packages>"#,
3307        );
3308        let sdk = extract(
3309            "src/App.csproj",
3310            r#"<Project Sdk="Microsoft.NET.Sdk">
3311  <PropertyGroup><PackageId>Acme.App</PackageId><Version>1.4.0</Version></PropertyGroup>
3312  <ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
3313    <PackageReference Include="Serilog" Version="4.0.0" />
3314    <PackageReference Include="Dynamic" Version="$(DynamicVersion)" />
3315  </ItemGroup>
3316</Project>"#,
3317        );
3318
3319        assert_eq!(
3320            (
3321                packages_config
3322                    .dependencies
3323                    .iter()
3324                    .map(|item| (item.name.as_str(), item.scope, item.condition.as_deref()))
3325                    .collect::<Vec<_>>(),
3326                sdk.dependencies[0].name.as_str(),
3327                sdk.dependencies[0].condition.as_deref(),
3328                sdk.dependencies[1].version_or_range.as_deref(),
3329                sdk.packages
3330                    .first()
3331                    .map(|item| (item.name.as_str(), item.version.as_deref())),
3332            ),
3333            (
3334                vec![
3335                    ("NUnit", DependencyScope::Dev, None),
3336                    (
3337                        "Newtonsoft.Json",
3338                        DependencyScope::Runtime,
3339                        Some("targetFramework = \"net48\"")
3340                    ),
3341                ],
3342                "Dynamic",
3343                Some("'$(TargetFramework)' == 'net8.0'"),
3344                Some("4.0.0"),
3345                Some(("Acme.App", Some("1.4.0"))),
3346            )
3347        );
3348    }
3349
3350    #[test]
3351    fn malformed_structured_inputs_are_rejected() {
3352        let cases = [
3353            ("package.json", r#"{"dependencies": []}"#),
3354            ("pyproject.toml", "[project\nname = \"bad\""),
3355            ("Cargo.toml", "[dependencies]\nserde = { version = \"1\""),
3356            ("go.mod", "module example.com/a\nrequire (\na v1\n"),
3357            ("pom.xml", "<project><artifactId>x</project>"),
3358            ("build.gradle", "dependencies { implementation(\"a:b:1\")"),
3359            ("packages.config", "<packages><package /></packages>"),
3360            ("app.csproj", "<Project><ItemGroup></Project>"),
3361        ];
3362
3363        for (path, source) in cases {
3364            assert!(
3365                matches!(
3366                    extract_package_manifest(path, source),
3367                    Err(PackageManifestError::Malformed { .. })
3368                ),
3369                "{path} should reject malformed input"
3370            );
3371        }
3372    }
3373
3374    #[test]
3375    fn output_order_is_deterministic_across_declaration_order() {
3376        let first = extract(
3377            "package.json",
3378            r#"{"name":"x","dependencies":{"z":"1","a":"2"},"devDependencies":{"m":"3"}}"#,
3379        );
3380        let second = extract(
3381            "package.json",
3382            r#"{"devDependencies":{"m":"3"},"dependencies":{"a":"2","z":"1"},"name":"x"}"#,
3383        );
3384
3385        assert_eq!(
3386            first
3387                .dependencies
3388                .iter()
3389                .map(|item| (&item.name, item.scope, &item.version_or_range))
3390                .collect::<Vec<_>>(),
3391            second
3392                .dependencies
3393                .iter()
3394                .map(|item| (&item.name, item.scope, &item.version_or_range))
3395                .collect::<Vec<_>>()
3396        );
3397    }
3398
3399    #[test]
3400    fn unsupported_and_non_relative_paths_are_rejected() {
3401        for path in [
3402            "/package.json",
3403            "../package.json",
3404            "README.md",
3405            "C:\\package.json",
3406        ] {
3407            assert!(
3408                matches!(
3409                    extract_package_manifest(path, "{}"),
3410                    Err(PackageManifestError::UnsupportedPath(_))
3411                ),
3412                "{path} should be unsupported"
3413            );
3414        }
3415    }
3416}