Skip to main content

provenant/parsers/
podfile.rs

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