Skip to main content

provenant/parsers/
podfile.rs

1//! Parser for CocoaPods Podfile manifest files.
2//!
3//! Extracts dependency declarations from Podfile using regex-based Ruby Domain-Specific
4//! Language (DSL) parsing without full Ruby AST parsing.
5//!
6//! # Supported Formats
7//! - Podfile (CocoaPods manifest with Ruby DSL syntax)
8//!
9//! # Key Features
10//! - Regex-based Ruby DSL parsing for dependency declarations
11//! - Support for git, path, and source dependencies
12//! - Pod groups and target-specific dependencies
13//! - Version constraint parsing (exact, ranges, pessimistic)
14//! - Source URL extraction for custom pod repositories
15//!
16//! # Implementation Notes
17//! - Uses regex for pattern matching (not full Ruby parser)
18//! - Supports syntax: `pod 'Name', 'version'`, `pod 'Name', :git => 'url'`
19//! - Local path dependencies (`:path =>`) are tracked as dependencies
20//! - Graceful error handling with `warn!()` logs
21
22use std::path::Path;
23use std::sync::LazyLock;
24
25use crate::parser_warn as warn;
26use packageurl::PackageUrl;
27use regex::Regex;
28
29use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
30use crate::parsers::PackageParser;
31use crate::parsers::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
32
33/// Parses CocoaPods Podfile dependency files.
34///
35/// Extracts dependency declarations from Podfile using regex-based Ruby DSL parsing.
36///
37/// # Supported Syntax
38/// - `pod 'Name', 'version'` - Standard pod with version
39/// - `pod 'Name'` - Pod without version constraint
40/// - `pod 'Name', :git => 'url'` - Git dependency
41/// - `pod 'Name', :path => '../LocalPod'` - Local path dependency
42/// - `pod 'Firebase/Analytics'` - Subspecs
43/// - Version operators: `~>`, `>=`, `<=`, etc.
44pub struct PodfileParser;
45
46impl PackageParser for PodfileParser {
47    const PACKAGE_TYPE: PackageType = PackageType::Cocoapods;
48
49    fn is_match(path: &Path) -> bool {
50        path.file_name().is_some_and(|name| name == "Podfile")
51    }
52
53    fn extract_packages(path: &Path) -> Vec<PackageData> {
54        let content = match read_file_to_string(path, None) {
55            Ok(c) => c,
56            Err(e) => {
57                warn!("Failed to read {:?}: {}", path, e);
58                return vec![default_package_data()];
59            }
60        };
61
62        let dependencies = extract_dependencies(&content);
63
64        vec![PackageData {
65            package_type: Some(Self::PACKAGE_TYPE),
66            namespace: None,
67            name: None,
68            version: None,
69            qualifiers: None,
70            subpath: None,
71            primary_language: Some("Objective-C".to_string()),
72            description: None,
73            release_date: None,
74            parties: Vec::new(),
75            keywords: Vec::new(),
76            homepage_url: None,
77            download_url: None,
78            size: None,
79            sha1: None,
80            md5: None,
81            sha256: None,
82            sha512: None,
83            bug_tracking_url: None,
84            code_view_url: None,
85            vcs_url: None,
86            copyright: None,
87            holder: None,
88            declared_license_expression: None,
89            declared_license_expression_spdx: None,
90            license_detections: Vec::new(),
91            other_license_expression: None,
92            other_license_expression_spdx: None,
93            other_license_detections: Vec::new(),
94            extracted_license_statement: None,
95            notice_text: None,
96            source_packages: Vec::new(),
97            file_references: Vec::new(),
98            extra_data: None,
99            dependencies,
100            repository_homepage_url: None,
101            repository_download_url: None,
102            api_data_url: None,
103            datasource_id: Some(DatasourceId::CocoapodsPodfile),
104            purl: None,
105            is_private: false,
106            is_virtual: false,
107        }]
108    }
109}
110
111fn default_package_data() -> PackageData {
112    PackageData {
113        package_type: Some(PodfileParser::PACKAGE_TYPE),
114        primary_language: Some("Objective-C".to_string()),
115        datasource_id: Some(DatasourceId::CocoapodsPodfile),
116        ..Default::default()
117    }
118}
119
120static POD_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
121    Regex::new(
122        r#"pod\s+['"]([^'"]+)['"](?:\s*,\s*['"]([^'"]+)['"])?(?:\s*,\s*:git\s*=>\s*['"]([^'"]+)['"])?(?:\s*,\s*:path\s*=>\s*['"]([^'"]+)['"])?"#
123    ).expect("valid regex")
124});
125
126/// Extract dependencies from Podfile
127fn extract_dependencies(content: &str) -> Vec<Dependency> {
128    let mut dependencies = Vec::new();
129
130    for line in content.lines().take(MAX_ITERATION_COUNT) {
131        let cleaned_line = pre_process(line);
132        if let Some(caps) = POD_PATTERN.captures(&cleaned_line) {
133            let name = truncate_field(caps.get(1).map(|m| m.as_str()).unwrap_or("").to_string());
134            let version_req = caps.get(2).map(|m| truncate_field(m.as_str().to_string()));
135            let git_url = caps.get(3).map(|m| truncate_field(m.as_str().to_string()));
136            let local_path = caps.get(4).map(|m| truncate_field(m.as_str().to_string()));
137
138            if let Some(dep) = create_dependency(name, version_req, git_url, local_path) {
139                dependencies.push(dep);
140            }
141        }
142    }
143
144    dependencies
145}
146
147/// Create a Dependency from parsed components
148fn create_dependency(
149    name: String,
150    version_req: Option<String>,
151    _git_url: Option<String>,
152    _local_path: Option<String>,
153) -> Option<Dependency> {
154    if name.is_empty() {
155        return None;
156    }
157
158    let purl = PackageUrl::new("cocoapods", &name).ok()?;
159
160    let is_pinned = version_req
161        .as_ref()
162        .map(|v| !v.contains(&['~', '>', '<', '='][..]))
163        .unwrap_or(false);
164
165    Some(Dependency {
166        purl: Some(truncate_field(purl.to_string())),
167        extracted_requirement: version_req.map(truncate_field),
168        scope: Some("dependencies".to_string()),
169        is_runtime: None,
170        is_optional: None,
171        is_pinned: Some(is_pinned),
172        is_direct: Some(true),
173        resolved_package: None,
174        extra_data: None,
175    })
176}
177
178/// Pre-process a line by removing comments and trimming
179fn pre_process(line: &str) -> String {
180    let line = if let Some(comment_pos) = line.find('#') {
181        &line[..comment_pos]
182    } else {
183        line
184    };
185    line.trim().to_string()
186}
187
188crate::register_parser!(
189    "CocoaPods Podfile",
190    &["**/Podfile"],
191    "cocoapods",
192    "Objective-C",
193    Some("https://guides.cocoapods.org/using/the-podfile.html"),
194);
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn test_is_match() {
202        assert!(PodfileParser::is_match(Path::new("Podfile")));
203        assert!(PodfileParser::is_match(Path::new("project/Podfile")));
204        assert!(!PodfileParser::is_match(Path::new("Podfile.lock")));
205        assert!(!PodfileParser::is_match(Path::new("FooPodfile")));
206        assert!(!PodfileParser::is_match(Path::new("config.podfile")));
207        assert!(!PodfileParser::is_match(Path::new("MyLib.podspec")));
208        assert!(!PodfileParser::is_match(Path::new("MyLib.podspec.json")));
209    }
210
211    #[test]
212    fn test_extract_simple_pod() {
213        let content = r#"
214platform :ios, '9.0'
215
216target 'MyApp' do
217  pod 'AFNetworking', '~> 4.0'
218  pod 'Alamofire'
219end
220"#;
221        let deps = extract_dependencies(content);
222        assert_eq!(deps.len(), 2);
223
224        assert_eq!(deps[0].purl, Some("pkg:cocoapods/AFNetworking".to_string()));
225        assert_eq!(deps[0].extracted_requirement, Some("~> 4.0".to_string()));
226        assert_eq!(deps[0].is_pinned, Some(false));
227        assert_eq!(deps[0].scope, Some("dependencies".to_string()));
228        assert_eq!(deps[0].is_runtime, None);
229        assert_eq!(deps[0].is_optional, None);
230
231        assert_eq!(deps[1].purl, Some("pkg:cocoapods/Alamofire".to_string()));
232        assert_eq!(deps[1].extracted_requirement, None);
233    }
234
235    #[test]
236    fn test_extract_pod_with_git() {
237        let content = r#"
238pod 'AFNetworking', :git => 'https://github.com/AFNetworking/AFNetworking.git'
239"#;
240        let deps = extract_dependencies(content);
241        assert_eq!(deps.len(), 1);
242        assert_eq!(deps[0].purl, Some("pkg:cocoapods/AFNetworking".to_string()));
243    }
244
245    #[test]
246    fn test_extract_pod_with_path() {
247        let content = r#"
248pod 'MyLocalPod', :path => '../MyLocalPod'
249"#;
250        let deps = extract_dependencies(content);
251        assert_eq!(deps.len(), 1);
252        assert_eq!(deps[0].purl, Some("pkg:cocoapods/MyLocalPod".to_string()));
253    }
254
255    #[test]
256    fn test_extract_pod_with_version_and_git() {
257        let content = r#"
258pod 'RestKit', '~> 0.20', :git => 'https://github.com/RestKit/RestKit.git'
259"#;
260        let deps = extract_dependencies(content);
261        assert_eq!(deps.len(), 1);
262        assert_eq!(deps[0].purl, Some("pkg:cocoapods/RestKit".to_string()));
263        assert_eq!(deps[0].extracted_requirement, Some("~> 0.20".to_string()));
264    }
265
266    #[test]
267    fn test_ignores_comments() {
268        let content = r#"
269# pod 'Commented', '1.0'
270pod 'Active', '2.0'  # inline comment
271"#;
272        let deps = extract_dependencies(content);
273        assert_eq!(deps.len(), 1);
274        assert_eq!(deps[0].purl, Some("pkg:cocoapods/Active".to_string()));
275    }
276}