Skip to main content

normalize_manifest/
pip.rs

1//! Parser for `requirements.txt` files (pip).
2
3use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
4
5/// Parser for `requirements.txt` files.
6pub struct PipParser;
7
8impl ManifestParser for PipParser {
9    fn filename(&self) -> &'static str {
10        "requirements.txt"
11    }
12
13    fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
14        let mut deps = Vec::new();
15
16        for line in content.lines() {
17            let line = line.trim();
18            // Skip comments, empty lines, and pip options (-r, -c, --index-url, etc.)
19            if line.is_empty() || line.starts_with('#') || line.starts_with('-') {
20                continue;
21            }
22            // Strip inline comment
23            let line = match line.find(" #") {
24                Some(idx) => line[..idx].trim(),
25                None => line,
26            };
27            if let Some(dep) = parse_pip_requirement(line) {
28                deps.push(dep);
29            }
30        }
31
32        Ok(ParsedManifest {
33            ecosystem: "pip",
34            name: None,
35            version: None,
36            dependencies: deps,
37        })
38    }
39}
40
41pub(crate) fn parse_pip_requirement(line: &str) -> Option<DeclaredDep> {
42    let line = line.trim();
43    if line.is_empty() {
44        return None;
45    }
46    // Split on version operators in order of specificity
47    const OPERATORS: &[&str] = &["===", "~=", "==", "!=", ">=", "<=", ">", "<"];
48    for op in OPERATORS {
49        if let Some(idx) = line.find(op) {
50            let name = line[..idx].trim().to_string();
51            if name.is_empty() {
52                continue;
53            }
54            let version_req = Some(line[idx..].trim().to_string());
55            return Some(DeclaredDep {
56                name,
57                version_req,
58                kind: DepKind::Normal,
59            });
60        }
61    }
62    // No version specifier — bare package name
63    if !line.is_empty() {
64        return Some(DeclaredDep {
65            name: line.to_string(),
66            version_req: None,
67            kind: DepKind::Normal,
68        });
69    }
70    None
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use crate::ManifestParser;
77
78    #[test]
79    fn test_parse_requirements_txt() {
80        let content = r#"# Production dependencies
81requests==2.28.0
82flask>=2.0
83numpy  # scientific computing
84# dev
85pytest
86"#;
87        let m = PipParser.parse(content).unwrap();
88        assert_eq!(m.ecosystem, "pip");
89        assert!(m.name.is_none());
90        assert_eq!(m.dependencies.len(), 4);
91
92        let requests = m
93            .dependencies
94            .iter()
95            .find(|d| d.name == "requests")
96            .unwrap();
97        assert_eq!(requests.version_req.as_deref(), Some("==2.28.0"));
98
99        let flask = m.dependencies.iter().find(|d| d.name == "flask").unwrap();
100        assert_eq!(flask.version_req.as_deref(), Some(">=2.0"));
101
102        let numpy = m.dependencies.iter().find(|d| d.name == "numpy").unwrap();
103        assert!(numpy.version_req.is_none());
104
105        let pytest = m.dependencies.iter().find(|d| d.name == "pytest").unwrap();
106        assert!(pytest.version_req.is_none());
107    }
108
109    #[test]
110    fn test_skip_pip_options() {
111        let content = "-r base.txt\n--index-url https://pypi.org\nrequests\n";
112        let m = PipParser.parse(content).unwrap();
113        assert_eq!(m.dependencies.len(), 1);
114        assert_eq!(m.dependencies[0].name, "requests");
115    }
116}