Skip to main content

pcu/parsers/
requirements.rs

1use super::{Dependency, DependencyParser};
2use check_updates_core::VersionSpec;
3use anyhow::{Context, Result};
4use std::fs;
5use std::path::Path;
6
7/// Parser for requirements.txt files
8pub struct RequirementsParser;
9
10impl Default for RequirementsParser {
11    fn default() -> Self {
12        Self::new()
13    }
14}
15
16impl RequirementsParser {
17    pub fn new() -> Self {
18        Self
19    }
20
21    /// Parse a single line from requirements.txt
22    fn parse_line(line: &str, line_number: usize, source_file: &Path) -> Option<Dependency> {
23        let original_line = line.to_string();
24        let line = line.trim();
25
26        // Skip empty lines
27        if line.is_empty() {
28            return None;
29        }
30
31        // Skip comments
32        if line.starts_with('#') {
33            return None;
34        }
35
36        // Skip -r includes (don't follow them)
37        if line.starts_with("-r ") || line.starts_with("-r\t") {
38            return None;
39        }
40
41        // Skip --index-url and other pip options
42        if line.starts_with("--") || line.starts_with('-') {
43            return None;
44        }
45
46        // Handle environment markers - split on semicolon
47        // e.g., "package>=1.0; python_version >= '3.8'"
48        let line_without_marker = if let Some(idx) = line.find(';') {
49            line[..idx].trim()
50        } else {
51            line
52        };
53
54        // Handle inline comments
55        let line_clean = if let Some(idx) = line_without_marker.find('#') {
56            line_without_marker[..idx].trim()
57        } else {
58            line_without_marker
59        };
60
61        if line_clean.is_empty() {
62            return None;
63        }
64
65        // Parse package name and version specifier
66        // Package name can include extras: package[extra1,extra2]>=1.0
67        let (package_with_extras, version_str) = Self::split_package_version(line_clean)?;
68
69        // Extract package name (remove extras)
70        let package_name = if let Some(bracket_idx) = package_with_extras.find('[') {
71            package_with_extras[..bracket_idx].trim()
72        } else {
73            package_with_extras
74        };
75
76        // Normalize package name to lowercase with underscores/hyphens handled
77        let normalized_name = package_name.to_lowercase().replace('_', "-");
78
79        // Parse version specification
80        let version_spec = if version_str.is_empty() {
81            VersionSpec::Any
82        } else {
83            match VersionSpec::parse(version_str) {
84                Ok(spec) => spec,
85                Err(_) => {
86                    // If parsing fails, store as complex constraint
87                    VersionSpec::Complex(version_str.to_string())
88                }
89            }
90        };
91
92        Some(Dependency {
93            name: normalized_name,
94            version_spec,
95            source_file: source_file.to_path_buf(),
96            line_number,
97            original_line,
98        })
99    }
100
101    /// Split a package specification into name (with extras) and version
102    /// Returns (package_with_extras, version_spec)
103    fn split_package_version(spec: &str) -> Option<(&str, &str)> {
104        // Try to find version operators
105        // Order matters: check two-char operators first
106        let operators = ["==", ">=", "<=", "~=", "!=", ">", "<"];
107
108        // Find the first operator
109        let mut first_op_idx = None;
110        for op in &operators {
111            if let Some(idx) = spec.find(op) {
112                // Make sure we're not inside brackets (extras)
113                let before = &spec[..idx];
114                let open_brackets = before.matches('[').count();
115                let close_brackets = before.matches(']').count();
116
117                // Only consider this operator if we're not inside brackets
118                if open_brackets == close_brackets {
119                    first_op_idx = Some(idx);
120                    break;
121                }
122            }
123        }
124
125        if let Some(idx) = first_op_idx {
126            let package = spec[..idx].trim();
127            let version = spec[idx..].trim();
128            Some((package, version))
129        } else {
130            // No version specifier - just package name
131            Some((spec.trim(), ""))
132        }
133    }
134}
135
136impl DependencyParser for RequirementsParser {
137    fn parse(&self, path: &Path) -> Result<Vec<Dependency>> {
138        let content = fs::read_to_string(path)
139            .with_context(|| format!("Failed to read requirements file: {path:?}"))?;
140
141        let dependencies: Vec<Dependency> = content
142            .lines()
143            .enumerate()
144            .filter_map(|(idx, line)| {
145                // Line numbers are 1-indexed
146                Self::parse_line(line, idx + 1, path)
147            })
148            .collect();
149
150        Ok(dependencies)
151    }
152
153    fn can_parse(&self, path: &Path) -> bool {
154        path.file_name()
155            .and_then(|n| n.to_str())
156            .map(|n| n.starts_with("requirements") && n.ends_with(".txt"))
157            .unwrap_or(false)
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use std::io::Write;
165    use std::path::PathBuf;
166    use tempfile::NamedTempFile;
167
168    #[test]
169    fn test_parse_simple_package() {
170        let mut file = NamedTempFile::new().unwrap();
171        writeln!(file, "requests==2.28.0").unwrap();
172        writeln!(file, "numpy>=1.24.0").unwrap();
173        writeln!(file, "flask").unwrap();
174
175        let parser = RequirementsParser::new();
176        let deps = parser.parse(&file.path().to_path_buf()).unwrap();
177
178        assert_eq!(deps.len(), 3);
179        assert_eq!(deps[0].name, "requests");
180        assert!(matches!(deps[0].version_spec, VersionSpec::Pinned(_)));
181        assert_eq!(deps[1].name, "numpy");
182        assert!(matches!(deps[1].version_spec, VersionSpec::Minimum(_)));
183        assert_eq!(deps[2].name, "flask");
184        assert!(matches!(deps[2].version_spec, VersionSpec::Any));
185    }
186
187    #[test]
188    fn test_parse_with_extras() {
189        let mut file = NamedTempFile::new().unwrap();
190        writeln!(file, "requests[security]>=2.0.0").unwrap();
191        writeln!(file, "celery[redis,msgpack]==5.2.0").unwrap();
192
193        let parser = RequirementsParser::new();
194        let deps = parser.parse(&file.path().to_path_buf()).unwrap();
195
196        assert_eq!(deps.len(), 2);
197        assert_eq!(deps[0].name, "requests");
198        assert_eq!(deps[1].name, "celery");
199    }
200
201    #[test]
202    fn test_parse_with_comments() {
203        let mut file = NamedTempFile::new().unwrap();
204        writeln!(file, "# This is a comment").unwrap();
205        writeln!(file, "requests==2.28.0  # inline comment").unwrap();
206        writeln!(file, "").unwrap();
207        writeln!(file, "numpy>=1.24.0").unwrap();
208
209        let parser = RequirementsParser::new();
210        let deps = parser.parse(&file.path().to_path_buf()).unwrap();
211
212        assert_eq!(deps.len(), 2);
213        assert_eq!(deps[0].name, "requests");
214        assert_eq!(deps[1].name, "numpy");
215    }
216
217    #[test]
218    fn test_parse_with_environment_markers() {
219        let mut file = NamedTempFile::new().unwrap();
220        writeln!(file, "dataclasses>=0.6; python_version < '3.7'").unwrap();
221        writeln!(file, "typing-extensions>=3.7; python_version >= '3.8'").unwrap();
222
223        let parser = RequirementsParser::new();
224        let deps = parser.parse(&file.path().to_path_buf()).unwrap();
225
226        assert_eq!(deps.len(), 2);
227        assert_eq!(deps[0].name, "dataclasses");
228        assert_eq!(deps[1].name, "typing-extensions");
229    }
230
231    #[test]
232    fn test_parse_skip_directives() {
233        let mut file = NamedTempFile::new().unwrap();
234        writeln!(file, "--index-url https://pypi.org/simple").unwrap();
235        writeln!(file, "-r requirements-dev.txt").unwrap();
236        writeln!(file, "requests==2.28.0").unwrap();
237
238        let parser = RequirementsParser::new();
239        let deps = parser.parse(&file.path().to_path_buf()).unwrap();
240
241        assert_eq!(deps.len(), 1);
242        assert_eq!(deps[0].name, "requests");
243    }
244
245    #[test]
246    fn test_parse_complex_version_specs() {
247        let mut file = NamedTempFile::new().unwrap();
248        writeln!(file, "django>=2.0,<3.0").unwrap();
249        writeln!(file, "pytest~=7.0").unwrap();
250        writeln!(file, "click!=8.0.0").unwrap();
251
252        let parser = RequirementsParser::new();
253        let deps = parser.parse(&file.path().to_path_buf()).unwrap();
254
255        assert_eq!(deps.len(), 3);
256        assert_eq!(deps[0].name, "django");
257        assert!(matches!(deps[0].version_spec, VersionSpec::Range { .. }));
258        assert_eq!(deps[1].name, "pytest");
259        assert_eq!(deps[2].name, "click");
260    }
261
262    #[test]
263    fn test_line_numbers() {
264        let mut file = NamedTempFile::new().unwrap();
265        writeln!(file, "# Comment line").unwrap();
266        writeln!(file, "requests==2.28.0").unwrap();
267        writeln!(file, "").unwrap();
268        writeln!(file, "numpy>=1.24.0").unwrap();
269
270        let parser = RequirementsParser::new();
271        let deps = parser.parse(&file.path().to_path_buf()).unwrap();
272
273        assert_eq!(deps.len(), 2);
274        assert_eq!(deps[0].line_number, 2);
275        assert_eq!(deps[1].line_number, 4);
276    }
277
278    #[test]
279    fn test_can_parse() {
280        let parser = RequirementsParser::new();
281        assert!(parser.can_parse(&PathBuf::from("requirements.txt")));
282        assert!(parser.can_parse(&PathBuf::from("requirements-dev.txt")));
283        assert!(parser.can_parse(&PathBuf::from("requirements-test.txt")));
284        assert!(!parser.can_parse(&PathBuf::from("pyproject.toml")));
285        assert!(!parser.can_parse(&PathBuf::from("setup.py")));
286    }
287}