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