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::collections::HashMap;
26use std::path::{Path, PathBuf};
27use std::sync::LazyLock;
28
29use crate::parser_warn as warn;
30use packageurl::PackageUrl;
31use regex::Regex;
32
33use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
34use crate::parsers::PackageParser;
35use crate::parsers::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
36
37/// Parses CocoaPods Podfile dependency files.
38///
39/// Extracts dependency declarations from Podfile using regex-based Ruby DSL parsing.
40///
41/// # Supported Syntax
42/// - `pod 'Name', 'version'` - Standard pod with version
43/// - `pod 'Name'` - Pod without version constraint
44/// - `pod 'Name', :git => 'url'` - Git dependency
45/// - `pod 'Name', :path => '../LocalPod'` - Local path dependency
46/// - `pod 'Firebase/Analytics'` - Subspecs
47/// - Version operators: `~>`, `>=`, `<=`, etc.
48pub struct PodfileParser;
49
50impl PackageParser for PodfileParser {
51    const PACKAGE_TYPE: PackageType = PackageType::Cocoapods;
52
53    fn is_match(path: &Path) -> bool {
54        path.file_name().is_some_and(|name| name == "Podfile")
55    }
56
57    fn extract_packages(path: &Path) -> Vec<PackageData> {
58        let content = match read_file_to_string(path, None) {
59            Ok(c) => c,
60            Err(e) => {
61                warn!("Failed to read {:?}: {}", path, e);
62                return vec![default_package_data()];
63            }
64        };
65
66        let dependencies = extract_dependencies_with_context(&content, path.parent());
67
68        vec![PackageData {
69            package_type: Some(Self::PACKAGE_TYPE),
70            namespace: None,
71            name: None,
72            version: None,
73            qualifiers: None,
74            subpath: None,
75            primary_language: Some("Objective-C".to_string()),
76            description: None,
77            release_date: None,
78            parties: Vec::new(),
79            keywords: Vec::new(),
80            homepage_url: None,
81            download_url: None,
82            size: None,
83            sha1: None,
84            md5: None,
85            sha256: None,
86            sha512: None,
87            bug_tracking_url: None,
88            code_view_url: None,
89            vcs_url: None,
90            copyright: None,
91            holder: None,
92            declared_license_expression: None,
93            declared_license_expression_spdx: None,
94            license_detections: Vec::new(),
95            other_license_expression: None,
96            other_license_expression_spdx: None,
97            other_license_detections: Vec::new(),
98            extracted_license_statement: None,
99            notice_text: None,
100            source_packages: Vec::new(),
101            file_references: Vec::new(),
102            extra_data: None,
103            dependencies,
104            repository_homepage_url: None,
105            repository_download_url: None,
106            api_data_url: None,
107            datasource_id: Some(DatasourceId::CocoapodsPodfile),
108            purl: None,
109            is_private: false,
110            is_virtual: false,
111        }]
112    }
113}
114
115fn default_package_data() -> PackageData {
116    PackageData {
117        package_type: Some(PodfileParser::PACKAGE_TYPE),
118        primary_language: Some("Objective-C".to_string()),
119        datasource_id: Some(DatasourceId::CocoapodsPodfile),
120        ..Default::default()
121    }
122}
123
124static POD_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
125    Regex::new(r#"^\s*pod\s+['"]([^'"]+)['"](?:\s*,\s*(.+))?$"#).expect("valid regex")
126});
127
128static POD_HASH_LOOKUP_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
129    Regex::new(r#"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\[\s*['"]([^'"]+)['"]\s*\]"#).expect("valid regex")
130});
131
132static POD_QUOTED_VALUE_PATTERN: LazyLock<Regex> =
133    LazyLock::new(|| Regex::new(r#"^\s*['"]([^'"]+)['"]"#).expect("valid regex"));
134
135static POD_OPTION_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
136    Regex::new(r#"(?:^|,\s*):([A-Za-z_][A-Za-z0-9_]*)\s*=>\s*['"]([^'"]+)['"]"#)
137        .expect("valid regex")
138});
139
140static REQUIRE_RELATIVE_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
141    Regex::new(r#"(?m)^\s*require_relative\s+['"]([^'"]+)['"]"#).expect("valid regex")
142});
143
144static HASH_ASSIGNMENT_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
145    Regex::new(r#"(?ms)^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\{(.*?)\}"#).expect("valid regex")
146});
147
148static HASH_ENTRY_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
149    Regex::new(r#"['"]([^'"]+)['"]\s*=>\s*['"]([^'"]+)['"]"#).expect("valid regex")
150});
151
152/// Extract dependencies from Podfile
153#[cfg(test)]
154fn extract_dependencies(content: &str) -> Vec<Dependency> {
155    extract_dependencies_with_context(content, None)
156}
157
158fn extract_dependencies_with_context(content: &str, base_dir: Option<&Path>) -> Vec<Dependency> {
159    let mut dependencies = Vec::new();
160    let contexts = load_podfile_contexts(content, base_dir);
161    let version_hashes = extract_podfile_hash_assignments(&contexts);
162
163    for line in content.lines().take(MAX_ITERATION_COUNT) {
164        let cleaned_line = pre_process(line);
165        if let Some(caps) = POD_PATTERN.captures(&cleaned_line) {
166            let name = truncate_field(caps.get(1).map(|m| m.as_str()).unwrap_or("").to_string());
167            let args = caps.get(2).map(|m| m.as_str()).unwrap_or("");
168            let version_req = extract_pod_version_requirement(args, &version_hashes);
169            let git_url = extract_pod_option(args, "git");
170            let local_path = extract_pod_option(args, "path");
171
172            if let Some(dep) = create_dependency(name, version_req, git_url, local_path) {
173                dependencies.push(dep);
174            }
175        }
176    }
177
178    dependencies
179}
180
181/// Create a Dependency from parsed components
182fn create_dependency(
183    name: String,
184    version_req: Option<String>,
185    _git_url: Option<String>,
186    _local_path: Option<String>,
187) -> Option<Dependency> {
188    if name.is_empty() {
189        return None;
190    }
191
192    let purl = PackageUrl::new("cocoapods", &name).ok()?;
193
194    let is_pinned = version_req
195        .as_ref()
196        .map(|v| !v.contains(&['~', '>', '<', '='][..]))
197        .unwrap_or(false);
198
199    Some(Dependency {
200        purl: Some(truncate_field(purl.to_string())),
201        extracted_requirement: version_req.map(truncate_field),
202        scope: Some("dependencies".to_string()),
203        is_runtime: None,
204        is_optional: None,
205        is_pinned: Some(is_pinned),
206        is_direct: Some(true),
207        resolved_package: None,
208        extra_data: None,
209    })
210}
211
212/// Pre-process a line by removing comments and trimming
213fn pre_process(line: &str) -> String {
214    let line = if let Some(comment_pos) = line.find('#') {
215        &line[..comment_pos]
216    } else {
217        line
218    };
219    line.trim().to_string()
220}
221
222fn extract_pod_version_requirement(
223    args: &str,
224    version_hashes: &HashMap<String, HashMap<String, String>>,
225) -> Option<String> {
226    if args.is_empty() {
227        return None;
228    }
229
230    if let Some(captures) = POD_QUOTED_VALUE_PATTERN.captures(args) {
231        return captures
232            .get(1)
233            .map(|value| truncate_field(value.as_str().to_string()));
234    }
235
236    let captures = POD_HASH_LOOKUP_PATTERN.captures(args)?;
237    let hash_name = captures.get(1)?.as_str();
238    let key = captures.get(2)?.as_str();
239    version_hashes
240        .get(hash_name)
241        .and_then(|entries| entries.get(key))
242        .cloned()
243        .map(truncate_field)
244}
245
246fn extract_pod_option(args: &str, key: &str) -> Option<String> {
247    POD_OPTION_PATTERN.captures_iter(args).find_map(|captures| {
248        (captures.get(1)?.as_str() == key)
249            .then(|| {
250                captures
251                    .get(2)
252                    .map(|value| truncate_field(value.as_str().to_string()))
253            })
254            .flatten()
255    })
256}
257
258fn load_podfile_contexts(content: &str, base_dir: Option<&Path>) -> Vec<String> {
259    let mut contexts = vec![content.to_string()];
260    let Some(base_dir) = base_dir else {
261        return contexts;
262    };
263    let Ok(allowed_root) = base_dir.canonicalize() else {
264        return contexts;
265    };
266
267    for captures in REQUIRE_RELATIVE_PATTERN
268        .captures_iter(content)
269        .take(MAX_ITERATION_COUNT)
270    {
271        let Some(required) = captures.get(1).map(|value| value.as_str()) else {
272            continue;
273        };
274        for candidate in candidate_require_relative_paths(base_dir, required) {
275            let Ok(canonical_candidate) = candidate.canonicalize() else {
276                continue;
277            };
278            if !canonical_candidate.starts_with(&allowed_root) {
279                continue;
280            }
281            if let Ok(required_content) = read_file_to_string(&canonical_candidate, None) {
282                contexts.push(required_content);
283                break;
284            }
285        }
286    }
287
288    contexts
289}
290
291fn candidate_require_relative_paths(base_dir: &Path, required: &str) -> Vec<PathBuf> {
292    let required = if required.ends_with(".rb") {
293        required.to_string()
294    } else {
295        format!("{required}.rb")
296    };
297    vec![base_dir.join(required)]
298}
299
300fn extract_podfile_hash_assignments(
301    contexts: &[String],
302) -> HashMap<String, HashMap<String, String>> {
303    let mut hashes = HashMap::new();
304
305    for context in contexts.iter().take(MAX_ITERATION_COUNT) {
306        for captures in HASH_ASSIGNMENT_PATTERN
307            .captures_iter(context)
308            .take(MAX_ITERATION_COUNT)
309        {
310            let Some(hash_name) = captures.get(1).map(|value| value.as_str().to_string()) else {
311                continue;
312            };
313            let Some(body) = captures.get(2).map(|value| value.as_str()) else {
314                continue;
315            };
316
317            let mut entries = HashMap::new();
318            for entry in HASH_ENTRY_PATTERN
319                .captures_iter(body)
320                .take(MAX_ITERATION_COUNT)
321            {
322                let Some(key) = entry.get(1).map(|value| value.as_str().to_string()) else {
323                    continue;
324                };
325                let Some(value) = entry.get(2).map(|value| value.as_str().to_string()) else {
326                    continue;
327                };
328                entries.insert(key, value);
329            }
330
331            if !entries.is_empty() {
332                hashes.insert(hash_name, entries);
333            }
334        }
335    }
336
337    hashes
338}
339
340crate::register_parser!(
341    "CocoaPods Podfile",
342    &["**/Podfile"],
343    "cocoapods",
344    "Objective-C",
345    Some("https://guides.cocoapods.org/using/the-podfile.html"),
346);
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn test_is_match() {
354        assert!(PodfileParser::is_match(Path::new("Podfile")));
355        assert!(PodfileParser::is_match(Path::new("project/Podfile")));
356        assert!(!PodfileParser::is_match(Path::new("Podfile.lock")));
357        assert!(!PodfileParser::is_match(Path::new("FooPodfile")));
358        assert!(!PodfileParser::is_match(Path::new("config.podfile")));
359        assert!(!PodfileParser::is_match(Path::new("MyLib.podspec")));
360        assert!(!PodfileParser::is_match(Path::new("MyLib.podspec.json")));
361    }
362
363    #[test]
364    fn test_extract_simple_pod() {
365        let content = r#"
366platform :ios, '9.0'
367
368target 'MyApp' do
369  pod 'AFNetworking', '~> 4.0'
370  pod 'Alamofire'
371end
372"#;
373        let deps = extract_dependencies(content);
374        assert_eq!(deps.len(), 2);
375
376        assert_eq!(deps[0].purl, Some("pkg:cocoapods/AFNetworking".to_string()));
377        assert_eq!(deps[0].extracted_requirement, Some("~> 4.0".to_string()));
378        assert_eq!(deps[0].is_pinned, Some(false));
379        assert_eq!(deps[0].scope, Some("dependencies".to_string()));
380        assert_eq!(deps[0].is_runtime, None);
381        assert_eq!(deps[0].is_optional, None);
382
383        assert_eq!(deps[1].purl, Some("pkg:cocoapods/Alamofire".to_string()));
384        assert_eq!(deps[1].extracted_requirement, None);
385    }
386
387    #[test]
388    fn test_extract_pod_with_git() {
389        let content = r#"
390pod 'AFNetworking', :git => 'https://github.com/AFNetworking/AFNetworking.git'
391"#;
392        let deps = extract_dependencies(content);
393        assert_eq!(deps.len(), 1);
394        assert_eq!(deps[0].purl, Some("pkg:cocoapods/AFNetworking".to_string()));
395    }
396
397    #[test]
398    fn test_extract_pod_with_path() {
399        let content = r#"
400pod 'MyLocalPod', :path => '../MyLocalPod'
401"#;
402        let deps = extract_dependencies(content);
403        assert_eq!(deps.len(), 1);
404        assert_eq!(deps[0].purl, Some("pkg:cocoapods/MyLocalPod".to_string()));
405    }
406
407    #[test]
408    fn test_extract_pod_with_version_and_git() {
409        let content = r#"
410pod 'RestKit', '~> 0.20', :git => 'https://github.com/RestKit/RestKit.git'
411"#;
412        let deps = extract_dependencies(content);
413        assert_eq!(deps.len(), 1);
414        assert_eq!(deps[0].purl, Some("pkg:cocoapods/RestKit".to_string()));
415        assert_eq!(deps[0].extracted_requirement, Some("~> 0.20".to_string()));
416    }
417
418    #[test]
419    fn test_ignores_comments() {
420        let content = r#"
421# pod 'Commented', '1.0'
422pod 'Active', '2.0'  # inline comment
423"#;
424        let deps = extract_dependencies(content);
425        assert_eq!(deps.len(), 1);
426        assert_eq!(deps[0].purl, Some("pkg:cocoapods/Active".to_string()));
427    }
428
429    #[test]
430    fn test_extract_pod_version_from_required_hash() {
431        let temp_dir = tempfile::tempdir().expect("temp dir");
432        let version_file = temp_dir.path().join("PodVersions.rb");
433        std::fs::write(
434            &version_file,
435            r#"
436versions = {
437  'Flipper' => '0.125.0',
438}
439"#,
440        )
441        .expect("write version helper");
442        let podfile_path = temp_dir.path().join("Podfile");
443        std::fs::write(
444            &podfile_path,
445            r#"
446require_relative 'PodVersions'
447
448target 'Example' do
449  pod 'FlipperKit', versions['Flipper']
450end
451"#,
452        )
453        .expect("write podfile");
454
455        let package_data = PodfileParser::extract_first_package(&podfile_path);
456        assert_eq!(package_data.dependencies.len(), 1);
457        assert_eq!(
458            package_data.dependencies[0].purl.as_deref(),
459            Some("pkg:cocoapods/FlipperKit")
460        );
461        assert_eq!(
462            package_data.dependencies[0]
463                .extracted_requirement
464                .as_deref(),
465            Some("0.125.0")
466        );
467        assert_eq!(package_data.dependencies[0].is_pinned, Some(true));
468    }
469}