Skip to main content

provenant/parsers/
ruby.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// SPDX-FileCopyrightText: Provenant contributors
3// SPDX-License-Identifier: Apache-2.0
4// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
5
6//! Parser for Ruby/RubyGems package manifests.
7//!
8//! Extracts package metadata, dependencies, and platform information from
9//! Gemfile and Gemfile.lock files used by Ruby/Bundler projects.
10//!
11//! # Supported Formats
12//! - Gemfile (manifest with Ruby DSL)
13//! - Gemfile.lock (lockfile with state machine sections)
14//! - *.gemspec (gem specification files)
15//! - *.gem (gem archive packages)
16//! - metadata.gz-extract (pre-extracted gem metadata)
17//!
18//! # Key Features
19//! - State machine parsing for Gemfile.lock sections (GEM, GIT, PATH, SVN, PLATFORMS, BUNDLED WITH, DEPENDENCIES)
20//! - Regex-based Ruby DSL parsing for Gemfile
21//! - Dependency group handling (:development, :test, etc.)
22//! - Platform-specific gem support
23//! - Pessimistic version operator (~>) support
24//! - Bug Fix #1: Strip .freeze suffix from strings
25//! - Bug Fix #4: Correct dependency scope mapping (:runtime → None, :development → "development")
26//!
27//! # Implementation Notes
28//! - Uses regex for pattern matching (not full Ruby AST)
29//! - Graceful error handling: logs warnings and returns default on parse failure
30//! - PURL type: "gem"
31
32use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party, PartyType};
33use crate::parser_warn as warn;
34use crate::parsers::utils::{
35    CappedIterExt, MAX_ITERATION_COUNT, capped_iteration_limit, read_file_to_string,
36    split_name_email, truncate_field,
37};
38use flate2::read::GzDecoder;
39use packageurl::PackageUrl;
40use regex::Regex;
41use std::collections::HashMap;
42use std::fs::{self, File};
43use std::io::Read;
44use std::path::{Path, PathBuf};
45use tar::Archive;
46
47use super::PackageParser;
48use super::license_normalization::normalize_spdx_declared_license;
49use super::metadata::ParserMetadata;
50
51const PACKAGE_TYPE: PackageType = PackageType::Gem;
52
53// =============================================================================
54// Bug Fix #1: Strip .freeze suffix from strings
55// =============================================================================
56
57/// Strips the `.freeze` suffix from Ruby frozen string literals.
58///
59/// In Ruby, `.freeze` makes a string immutable. We need to remove this suffix
60/// when parsing gem names and versions from Gemfile.
61///
62/// For example, `"name".freeze` becomes `"name"` and `'1.0.0'.freeze`
63/// becomes `'1.0.0'`.
64pub fn strip_freeze_suffix(s: &str) -> &str {
65    s.trim_end_matches(".freeze")
66}
67
68enum GemfileBlock {
69    Group(Vec<String>),
70    Source(String),
71}
72
73// =============================================================================
74// Gemfile Parser (Ruby DSL)
75// =============================================================================
76
77/// Ruby Gemfile parser for manifest files.
78///
79/// Parses Ruby DSL syntax to extract gem declarations, dependency groups,
80/// platform-specific gems, and version constraints.
81pub struct GemfileParser;
82
83impl PackageParser for GemfileParser {
84    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
85
86    fn metadata() -> Vec<ParserMetadata> {
87        vec![ParserMetadata {
88            description: "Ruby Gemfile manifest",
89            file_patterns: &["**/Gemfile", "**/data.gz-extract/Gemfile"],
90            package_type: "gem",
91            primary_language: "Ruby",
92            documentation_url: Some("https://bundler.io/man/gemfile.5.html"),
93        }]
94    }
95
96    fn extract_packages(path: &Path) -> Vec<PackageData> {
97        let datasource_id = gemfile_datasource_id(path);
98        let content = match read_file_to_string(path, None) {
99            Ok(c) => c,
100            Err(e) => {
101                warn!("Failed to read Gemfile at {:?}: {}", path, e);
102                return vec![default_package_data_with_datasource(datasource_id)];
103            }
104        };
105
106        let mut package_data = parse_gemfile(&content);
107        package_data.datasource_id = Some(datasource_id);
108        vec![package_data]
109    }
110
111    fn is_match(path: &Path) -> bool {
112        path.file_name()
113            .and_then(|n| n.to_str())
114            .is_some_and(|name| name == "Gemfile")
115            || path
116                .to_str()
117                .is_some_and(|p| p.contains("data.gz-extract/") && p.ends_with("/Gemfile"))
118    }
119}
120
121/// Parses Gemfile content and extracts dependencies with groups.
122fn parse_gemfile(content: &str) -> PackageData {
123    let mut dependencies = Vec::new();
124    let mut block_stack = Vec::new();
125    let mut default_source = None;
126    let mut sources = Vec::new();
127
128    // Regex patterns for Gemfile parsing
129    // gem "name", "version", options...
130    let gem_regex = match Regex::new(
131        r#"^\s*gem\s+["']([^"']+)["'](?:\.freeze)?(?:\s*,\s*["']([^"']+)["'](?:\.freeze)?)?(?:\s*,\s*["']([^"']+)["'](?:\.freeze)?)?(?:\s*,\s*(.+))?"#,
132    ) {
133        Ok(r) => r,
134        Err(e) => {
135            warn!("Failed to compile gem regex: {}", e);
136            return default_package_data_with_datasource(DatasourceId::Gemfile);
137        }
138    };
139
140    // group :name do ... end
141    let group_start_regex = match Regex::new(r"^\s*group\s+(.+?)\s+do\s*$") {
142        Ok(r) => r,
143        Err(e) => {
144            warn!("Failed to compile group regex: {}", e);
145            return default_package_data_with_datasource(DatasourceId::Gemfile);
146        }
147    };
148
149    let group_end_regex = match Regex::new(r"^\s*end\s*$") {
150        Ok(r) => r,
151        Err(e) => {
152            warn!("Failed to compile end regex: {}", e);
153            return default_package_data_with_datasource(DatasourceId::Gemfile);
154        }
155    };
156
157    let source_block_start_regex = match Regex::new(r#"^\s*source\s+["']([^"']+)["']\s+do\s*$"#) {
158        Ok(r) => r,
159        Err(e) => {
160            warn!("Failed to compile source block regex: {}", e);
161            return default_package_data_with_datasource(DatasourceId::Gemfile);
162        }
163    };
164
165    let source_regex = match Regex::new(r#"^\s*source\s+["']([^"']+)["']\s*$"#) {
166        Ok(r) => r,
167        Err(e) => {
168            warn!("Failed to compile source regex: {}", e);
169            return default_package_data_with_datasource(DatasourceId::Gemfile);
170        }
171    };
172
173    // Parse symbols like :development, :test
174    let symbol_regex = match Regex::new(r":(\w+)") {
175        Ok(r) => r,
176        Err(e) => {
177            warn!("Failed to compile symbol regex: {}", e);
178            return default_package_data_with_datasource(DatasourceId::Gemfile);
179        }
180    };
181
182    for line in content.lines().capped("Gemfile lines") {
183        let trimmed = line.trim();
184
185        // Skip comments and empty lines
186        if trimmed.is_empty() || trimmed.starts_with('#') {
187            continue;
188        }
189
190        // Check for group start
191        if let Some(caps) = group_start_regex.captures(trimmed) {
192            let groups_str = caps.get(1).map(|m| m.as_str()).unwrap_or("");
193            let mut current_groups = Vec::new();
194            for cap in symbol_regex.captures_iter(groups_str) {
195                if let Some(group_name) = cap.get(1) {
196                    current_groups.push(group_name.as_str().to_string());
197                }
198            }
199            block_stack.push(GemfileBlock::Group(current_groups));
200            continue;
201        }
202
203        if let Some(caps) = source_block_start_regex.captures(trimmed) {
204            let source = caps
205                .get(1)
206                .map(|m| m.as_str().to_string())
207                .unwrap_or_default();
208            if !source.is_empty() {
209                push_unique_string(&mut sources, source.clone());
210                block_stack.push(GemfileBlock::Source(source));
211            }
212            continue;
213        }
214
215        if let Some(caps) = source_regex.captures(trimmed) {
216            if let Some(source) = caps.get(1).map(|m| m.as_str().to_string()) {
217                push_unique_string(&mut sources, source.clone());
218                default_source = Some(source);
219            }
220            continue;
221        }
222
223        // Check for group end
224        if group_end_regex.is_match(trimmed) {
225            block_stack.pop();
226            continue;
227        }
228
229        // Parse gem declaration
230        if let Some(caps) = gem_regex.captures(trimmed) {
231            let name = strip_freeze_suffix(caps.get(1).map(|m| m.as_str()).unwrap_or(""));
232            if name.is_empty() {
233                continue;
234            }
235
236            // Collect version constraints
237            let mut version_parts = Vec::new();
238            if let Some(v) = caps.get(2) {
239                version_parts.push(strip_freeze_suffix(v.as_str()).to_string());
240            }
241            if let Some(v) = caps.get(3) {
242                let v_str = strip_freeze_suffix(v.as_str());
243                // Check if it looks like a version constraint
244                if looks_like_version_constraint(v_str) {
245                    version_parts.push(v_str.to_string());
246                }
247            }
248
249            let extracted_requirement = if version_parts.is_empty() {
250                None
251            } else {
252                Some(version_parts.join(", "))
253            };
254
255            let current_groups = current_group_names(&block_stack);
256
257            // Determine scope based on current group
258            // Bug Fix #4: :runtime → None, :development → "development"
259            let (scope, is_runtime, is_optional) = if current_groups.is_empty() {
260                // No group = runtime dependency
261                (None, true, false)
262            } else if current_groups.iter().any(|g| g == "development") {
263                (Some("development".to_string()), false, true)
264            } else if current_groups.iter().any(|g| g == "test") {
265                (Some("test".to_string()), false, true)
266            } else {
267                // Other groups (e.g., :production)
268                let group = current_groups.first().cloned();
269                (group, true, false)
270            };
271
272            // Create PURL
273            let purl = create_gem_purl(name, None);
274            let inherited_source = current_source(&block_stack, default_source.as_deref());
275            let extra_data = build_gemfile_dependency_extra_data(
276                caps.get(4).map(|m| m.as_str()),
277                inherited_source.as_deref(),
278            );
279
280            dependencies.push(Dependency {
281                purl,
282                extracted_requirement,
283                scope,
284                is_runtime: Some(is_runtime),
285                is_optional: Some(is_optional),
286                is_pinned: None,
287                is_direct: Some(true),
288                resolved_package: None,
289                extra_data,
290            });
291        }
292    }
293
294    let extra_data = if sources.is_empty() {
295        None
296    } else {
297        Some(HashMap::from([(
298            "sources".to_string(),
299            serde_json::Value::Array(sources.into_iter().map(serde_json::Value::String).collect()),
300        )]))
301    };
302
303    PackageData {
304        package_type: Some(PACKAGE_TYPE),
305        primary_language: Some("Ruby".to_string()),
306        dependencies,
307        extra_data,
308        datasource_id: Some(DatasourceId::Gemfile),
309        ..default_package_data()
310    }
311}
312
313fn current_group_names(block_stack: &[GemfileBlock]) -> Vec<String> {
314    block_stack
315        .iter()
316        .rev()
317        .find_map(|block| match block {
318            GemfileBlock::Group(groups) => Some(groups.clone()),
319            GemfileBlock::Source(_) => None,
320        })
321        .unwrap_or_default()
322}
323
324fn current_source(block_stack: &[GemfileBlock], default_source: Option<&str>) -> Option<String> {
325    block_stack
326        .iter()
327        .rev()
328        .find_map(|block| match block {
329            GemfileBlock::Source(source) => Some(source.clone()),
330            GemfileBlock::Group(_) => None,
331        })
332        .or_else(|| default_source.map(str::to_string))
333}
334
335fn push_unique_string(values: &mut Vec<String>, value: String) {
336    if !values.contains(&value) {
337        values.push(value);
338    }
339}
340
341fn build_gemfile_dependency_extra_data(
342    options: Option<&str>,
343    inherited_source: Option<&str>,
344) -> Option<HashMap<String, serde_json::Value>> {
345    let mut extra = HashMap::new();
346    let options = options.unwrap_or("");
347
348    if let Some(git) = extract_gemfile_quoted_option(options, "git") {
349        extra.insert(
350            "source_type".to_string(),
351            serde_json::Value::String("GIT".to_string()),
352        );
353        extra.insert("git".to_string(), serde_json::Value::String(git.clone()));
354        extra.insert("remote".to_string(), serde_json::Value::String(git));
355    }
356
357    if let Some(path) = extract_gemfile_quoted_option(options, "path") {
358        extra.insert(
359            "source_type".to_string(),
360            serde_json::Value::String("PATH".to_string()),
361        );
362        extra.insert("path".to_string(), serde_json::Value::String(path));
363    }
364
365    for key in ["branch", "ref", "tag"] {
366        if let Some(value) = extract_gemfile_quoted_option(options, key) {
367            extra.insert(key.to_string(), serde_json::Value::String(value));
368        }
369    }
370
371    let direct_source = extract_gemfile_quoted_option(options, "source");
372    if let Some(source) = direct_source {
373        extra.insert("source".to_string(), serde_json::Value::String(source));
374    } else if !extra.contains_key("source_type")
375        && let Some(source) = inherited_source
376    {
377        extra.insert(
378            "source".to_string(),
379            serde_json::Value::String(source.to_string()),
380        );
381    }
382
383    (!extra.is_empty()).then_some(extra)
384}
385
386fn extract_gemfile_quoted_option(options: &str, key: &str) -> Option<String> {
387    if options.is_empty() {
388        return None;
389    }
390
391    let pattern = format!(r#"(?:^|,\s*){}\s*:\s*["']([^"']+)["']"#, regex::escape(key));
392    Regex::new(&pattern)
393        .ok()
394        .and_then(|regex| regex.captures(options))
395        .and_then(|captures| captures.get(1).map(|m| m.as_str().to_string()))
396}
397
398/// Checks if a string looks like a version constraint.
399fn looks_like_version_constraint(s: &str) -> bool {
400    s.starts_with('~')
401        || s.starts_with('>')
402        || s.starts_with('<')
403        || s.starts_with('=')
404        || s.starts_with('!')
405        || s.chars().next().is_some_and(|c| c.is_ascii_digit())
406}
407
408// =============================================================================
409// Gemfile.lock Parser (State Machine)
410// =============================================================================
411
412/// Ruby Gemfile.lock parser for lockfiles.
413///
414/// Uses a state machine to parse sections: GEM, GIT, PATH, SVN,
415/// PLATFORMS, BUNDLED WITH, DEPENDENCIES.
416pub struct GemfileLockParser;
417
418impl PackageParser for GemfileLockParser {
419    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
420
421    fn metadata() -> Vec<ParserMetadata> {
422        vec![ParserMetadata {
423            description: "Ruby Gemfile.lock lockfile",
424            file_patterns: &["**/Gemfile.lock", "**/data.gz-extract/Gemfile.lock"],
425            package_type: "gem",
426            primary_language: "Ruby",
427            documentation_url: Some("https://bundler.io/man/gemfile.5.html"),
428        }]
429    }
430
431    fn extract_packages(path: &Path) -> Vec<PackageData> {
432        let datasource_id = gemfile_lock_datasource_id(path);
433        let content = match read_file_to_string(path, None) {
434            Ok(c) => c,
435            Err(e) => {
436                warn!("Failed to read Gemfile.lock at {:?}: {}", path, e);
437                return vec![default_package_data_with_datasource(datasource_id)];
438            }
439        };
440
441        let mut package_data = parse_gemfile_lock(&content);
442        package_data.datasource_id = Some(datasource_id);
443        vec![package_data]
444    }
445
446    fn is_match(path: &Path) -> bool {
447        path.file_name()
448            .and_then(|n| n.to_str())
449            .is_some_and(|name| name == "Gemfile.lock")
450            || path
451                .to_str()
452                .is_some_and(|p| p.contains("data.gz-extract/") && p.ends_with("/Gemfile.lock"))
453    }
454}
455
456/// Parse state for Gemfile.lock state machine.
457#[derive(Debug, Clone, PartialEq)]
458enum ParseState {
459    None,
460    Gem,
461    Git,
462    Path,
463    Svn,
464    Specs,
465    Platforms,
466    BundledWith,
467    Dependencies,
468}
469
470/// Parsed gem information from Gemfile.lock.
471///
472/// All fields are actively used:
473/// - `gem_type`, `remote`, `revision`, `ref_field`, `branch`, `tag`: Stored in extra_data for GIT/PATH/SVN sources
474/// - `name`, `version`, `platform`, `pinned`: Used for dependency PURL and metadata generation
475/// - `requirements`: Stored as extracted_requirement for version constraints
476#[derive(Debug, Clone, Default)]
477struct GemInfo {
478    name: String,
479    version: Option<String>,
480    platform: Option<String>,
481    gem_type: String,
482    remote: Option<String>,
483    revision: Option<String>,
484    ref_field: Option<String>,
485    branch: Option<String>,
486    tag: Option<String>,
487    pinned: bool,
488    requirements: Vec<String>,
489}
490
491fn select_primary_path_gem(gems: &HashMap<String, GemInfo>) -> Option<GemInfo> {
492    let mut path_gems: Vec<&GemInfo> = gems.values().filter(|gem| gem.gem_type == "PATH").collect();
493    path_gems.sort_by(|left, right| {
494        left.remote
495            .as_deref()
496            .cmp(&right.remote.as_deref())
497            .then_with(|| left.name.cmp(&right.name))
498    });
499
500    path_gems
501        .iter()
502        .copied()
503        .find(|gem| gem.pinned && gem.remote.as_deref() == Some("."))
504        .or_else(|| path_gems.iter().copied().find(|gem| gem.pinned))
505        .or_else(|| {
506            path_gems
507                .iter()
508                .copied()
509                .find(|gem| gem.remote.as_deref() == Some("."))
510        })
511        .or_else(|| path_gems.first().copied())
512        .cloned()
513}
514
515/// Parses Gemfile.lock content using a state machine.
516fn parse_gemfile_lock(content: &str) -> PackageData {
517    let mut state = ParseState::None;
518    let mut dependencies = Vec::new();
519    let mut gems: HashMap<String, GemInfo> = HashMap::new();
520    let mut platforms: Vec<String> = Vec::new();
521    let mut bundler_version: Option<String> = None;
522    let mut current_gem_type = String::new();
523    let mut current_remote: Option<String> = None;
524    let mut current_options: HashMap<String, String> = HashMap::new();
525
526    // DEPS pattern: 2 spaces at line start
527    let deps_regex = match Regex::new(r"^ {2}([^ \)\(,!:]+)(?: \(([^)]+)\))?(!)?$") {
528        Ok(r) => r,
529        Err(e) => {
530            warn!("Failed to compile deps regex: {}", e);
531            return default_package_data_with_datasource(DatasourceId::GemfileLock);
532        }
533    };
534
535    // SPEC_DEPS pattern: 4 spaces at line start
536    let spec_deps_regex = match Regex::new(r"^ {4}([^ \)\(,!:]+)(?: \(([^)]+)\))?$") {
537        Ok(r) => r,
538        Err(e) => {
539            warn!("Failed to compile spec_deps regex: {}", e);
540            return default_package_data_with_datasource(DatasourceId::GemfileLock);
541        }
542    };
543
544    // OPTIONS pattern: key: value
545    let options_regex = match Regex::new(r"^ {2}([a-z]+): (.+)$") {
546        Ok(r) => r,
547        Err(e) => {
548            warn!("Failed to compile options regex: {}", e);
549            return default_package_data_with_datasource(DatasourceId::GemfileLock);
550        }
551    };
552
553    // VERSION pattern for BUNDLED WITH
554    let version_regex = match Regex::new(r"^\s+(\d+(?:\.\d+)+)\s*$") {
555        Ok(r) => r,
556        Err(e) => {
557            warn!("Failed to compile version regex: {}", e);
558            return default_package_data_with_datasource(DatasourceId::GemfileLock);
559        }
560    };
561
562    for line in content.lines().capped("Gemfile.lock lines") {
563        let trimmed = line.trim_end();
564
565        // Empty line resets state
566        if trimmed.is_empty() {
567            current_options.clear();
568            continue;
569        }
570
571        // Section headers (no leading whitespace) and sub-section headers
572        match trimmed {
573            "GEM" => {
574                state = ParseState::Gem;
575                current_gem_type = "GEM".to_string();
576                current_remote = None;
577                current_options.clear();
578                continue;
579            }
580            "GIT" => {
581                state = ParseState::Git;
582                current_gem_type = "GIT".to_string();
583                current_remote = None;
584                current_options.clear();
585                continue;
586            }
587            "PATH" => {
588                state = ParseState::Path;
589                current_gem_type = "PATH".to_string();
590                current_remote = None;
591                current_options.clear();
592                continue;
593            }
594            "SVN" => {
595                state = ParseState::Svn;
596                current_gem_type = "SVN".to_string();
597                current_remote = None;
598                current_options.clear();
599                continue;
600            }
601            "PLATFORMS" => {
602                state = ParseState::Platforms;
603                continue;
604            }
605            "BUNDLED WITH" => {
606                state = ParseState::BundledWith;
607                continue;
608            }
609            "DEPENDENCIES" => {
610                state = ParseState::Dependencies;
611                continue;
612            }
613            _ => {}
614        }
615
616        // Check for "  specs:" sub-section header (2-space indent) within
617        // GEM/GIT/PATH/SVN sections. This must be checked separately because
618        // the leading whitespace is preserved by trim_end().
619        if trimmed.trim() == "specs:" {
620            state = match state {
621                ParseState::Gem | ParseState::Git | ParseState::Path | ParseState::Svn => {
622                    ParseState::Specs
623                }
624                _ => state,
625            };
626            continue;
627        }
628
629        // Process based on current state
630        match state {
631            ParseState::Gem | ParseState::Git | ParseState::Path | ParseState::Svn => {
632                // Parse options (remote:, revision:, ref:, branch:, tag:)
633                if let Some(caps) = options_regex.captures(line) {
634                    let key = caps.get(1).map(|m| m.as_str()).unwrap_or("");
635                    let value = caps.get(2).map(|m| m.as_str()).unwrap_or("");
636                    current_options.insert(key.to_string(), value.to_string());
637                    if key == "remote" {
638                        current_remote = Some(value.to_string());
639                    }
640                }
641            }
642            ParseState::Specs => {
643                // Parse gem specs (4 spaces indent)
644                if let Some(caps) = spec_deps_regex.captures(line) {
645                    let name = caps.get(1).map(|m| m.as_str()).unwrap_or("").to_string();
646                    let version_str = caps.get(2).map(|m| m.as_str()).unwrap_or("");
647
648                    // Parse version and platform
649                    let (version, platform) = parse_version_platform(version_str);
650
651                    if !name.is_empty() {
652                        let gem_info = GemInfo {
653                            name: name.clone(),
654                            version,
655                            platform,
656                            gem_type: current_gem_type.clone(),
657                            remote: current_remote.clone(),
658                            revision: current_options.get("revision").cloned(),
659                            ref_field: current_options.get("ref").cloned(),
660                            branch: current_options.get("branch").cloned(),
661                            tag: current_options.get("tag").cloned(),
662                            pinned: false,
663                            requirements: Vec::new(),
664                        };
665                        gems.insert(name, gem_info);
666                    }
667                }
668            }
669            ParseState::Platforms => {
670                // Parse platform entries (2 spaces indent)
671                let platform = trimmed.trim();
672                if !platform.is_empty() {
673                    platforms.push(platform.to_string());
674                }
675            }
676            ParseState::BundledWith => {
677                // Parse bundler version
678                if let Some(caps) = version_regex.captures(line) {
679                    bundler_version = caps.get(1).map(|m| m.as_str().to_string());
680                }
681            }
682            ParseState::Dependencies => {
683                // Parse direct dependencies (2 spaces indent)
684                if let Some(caps) = deps_regex.captures(line) {
685                    let name = caps.get(1).map(|m| m.as_str()).unwrap_or("").to_string();
686                    let version_constraint = caps.get(2).map(|m| m.as_str().to_string());
687                    let pinned = caps.get(3).is_some();
688
689                    if !name.is_empty() {
690                        // Update gem info if exists, or create new
691                        if let Some(gem) = gems.get_mut(&name) {
692                            gem.pinned = pinned;
693                            if let Some(vc) = &version_constraint {
694                                gem.requirements.push(vc.clone());
695                            }
696                        } else {
697                            let gem_info = GemInfo {
698                                name: name.clone(),
699                                version: None,
700                                platform: None,
701                                gem_type: "GEM".to_string(),
702                                remote: None,
703                                revision: None,
704                                ref_field: None,
705                                branch: None,
706                                tag: None,
707                                pinned,
708                                requirements: version_constraint.into_iter().collect(),
709                            };
710                            gems.insert(name, gem_info);
711                        }
712                    }
713                }
714            }
715            ParseState::None => {}
716        }
717    }
718
719    let primary_gem = select_primary_path_gem(&gems);
720
721    let (
722        package_name,
723        package_version,
724        repository_homepage_url,
725        repository_download_url,
726        api_data_url,
727        download_url,
728    ) = if let Some(ref pg) = primary_gem {
729        let urls = get_rubygems_urls(&pg.name, pg.version.as_deref(), pg.platform.as_deref());
730        (
731            Some(pg.name.clone()),
732            pg.version.clone(),
733            urls.0,
734            urls.1,
735            urls.2,
736            urls.3,
737        )
738    } else {
739        (None, None, None, None, None, None)
740    };
741
742    for (_, gem) in gems {
743        if let Some(ref pg) = primary_gem
744            && gem.name == pg.name
745        {
746            continue;
747        }
748
749        let version_for_purl = gem.version.as_deref();
750        let purl = create_gem_purl(&gem.name, version_for_purl);
751
752        let extracted_requirement = if !gem.requirements.is_empty() {
753            Some(gem.requirements.join(", "))
754        } else {
755            gem.version.clone()
756        };
757
758        let extra_data = build_gem_source_extra_data(&gem);
759
760        dependencies.push(Dependency {
761            purl,
762            extracted_requirement,
763            scope: Some("dependencies".to_string()),
764            is_runtime: Some(true),
765            is_optional: Some(false),
766            is_pinned: Some(gem.pinned),
767            is_direct: Some(true),
768            resolved_package: None,
769            extra_data,
770        });
771    }
772
773    dependencies.sort_by(|left, right| {
774        left.purl
775            .as_deref()
776            .cmp(&right.purl.as_deref())
777            .then_with(|| {
778                left.extracted_requirement
779                    .as_deref()
780                    .cmp(&right.extracted_requirement.as_deref())
781            })
782    });
783
784    // Build extra_data
785    let mut extra_data = HashMap::new();
786    if !platforms.is_empty() {
787        extra_data.insert(
788            "platforms".to_string(),
789            serde_json::Value::Array(
790                platforms
791                    .into_iter()
792                    .map(serde_json::Value::String)
793                    .collect(),
794            ),
795        );
796    }
797    if let Some(bv) = bundler_version {
798        extra_data.insert("bundler_version".to_string(), serde_json::Value::String(bv));
799    }
800
801    let purl = package_name
802        .as_deref()
803        .map(|n| create_gem_purl(n, package_version.as_deref()))
804        .unwrap_or(None);
805
806    PackageData {
807        package_type: Some(PACKAGE_TYPE),
808        name: package_name,
809        version: package_version,
810        primary_language: Some("Ruby".to_string()),
811        download_url,
812        dependencies,
813        repository_homepage_url,
814        repository_download_url,
815        api_data_url,
816        extra_data: if extra_data.is_empty() {
817            None
818        } else {
819            Some(extra_data)
820        },
821        datasource_id: Some(DatasourceId::GemfileLock),
822        purl,
823        ..default_package_data()
824    }
825}
826
827fn build_gem_source_extra_data(gem: &GemInfo) -> Option<HashMap<String, serde_json::Value>> {
828    if gem.gem_type != "GIT" && gem.gem_type != "PATH" && gem.gem_type != "SVN" {
829        return None;
830    }
831
832    let mut extra = HashMap::new();
833    extra.insert(
834        "source_type".to_string(),
835        serde_json::Value::String(gem.gem_type.clone()),
836    );
837
838    if let Some(ref remote) = gem.remote {
839        extra.insert(
840            "remote".to_string(),
841            serde_json::Value::String(remote.clone()),
842        );
843    }
844    if let Some(ref revision) = gem.revision {
845        extra.insert(
846            "revision".to_string(),
847            serde_json::Value::String(revision.clone()),
848        );
849    }
850    if let Some(ref ref_field) = gem.ref_field {
851        extra.insert(
852            "ref".to_string(),
853            serde_json::Value::String(ref_field.clone()),
854        );
855    }
856    if let Some(ref branch) = gem.branch {
857        extra.insert(
858            "branch".to_string(),
859            serde_json::Value::String(branch.clone()),
860        );
861    }
862    if let Some(ref tag) = gem.tag {
863        extra.insert("tag".to_string(), serde_json::Value::String(tag.clone()));
864    }
865
866    Some(extra)
867}
868
869/// Parses version and platform from a combined string.
870/// Examples: "2.6.3" -> ("2.6.3", None), "2.6.3-java" -> ("2.6.3", Some("java"))
871fn parse_version_platform(s: &str) -> (Option<String>, Option<String>) {
872    if s.is_empty() {
873        return (None, None);
874    }
875    if let Some(idx) = s.find('-') {
876        let version = &s[..idx];
877        let platform = &s[idx + 1..];
878        (Some(version.to_string()), Some(platform.to_string()))
879    } else {
880        (Some(s.to_string()), None)
881    }
882}
883
884/// Creates a gem PURL.
885fn create_gem_purl(name: &str, version: Option<&str>) -> Option<String> {
886    let mut purl = match PackageUrl::new(PACKAGE_TYPE.as_str(), name) {
887        Ok(p) => p,
888        Err(e) => {
889            warn!("Failed to create PURL for gem '{}': {}", name, e);
890            return None;
891        }
892    };
893
894    if let Some(v) = version
895        && let Err(e) = purl.with_version(v)
896    {
897        warn!("Failed to set version '{}' for gem '{}': {}", v, name, e);
898    }
899
900    Some(purl.to_string())
901}
902
903fn rubygems_homepage_url(name: &str, version: Option<&str>) -> Option<String> {
904    if name.is_empty() {
905        return None;
906    }
907
908    if let Some(v) = version {
909        let v = v.trim().trim_matches('/');
910        Some(format!("https://rubygems.org/gems/{}/versions/{}", name, v))
911    } else {
912        Some(format!("https://rubygems.org/gems/{}", name))
913    }
914}
915
916fn rubygems_download_url(
917    name: &str,
918    version: Option<&str>,
919    platform: Option<&str>,
920) -> Option<String> {
921    if name.is_empty() || version.is_none() {
922        return None;
923    }
924
925    let name = name.trim().trim_matches('/');
926    let version = version?.trim().trim_matches('/');
927
928    let version_plat = if let Some(p) = platform {
929        if p != "ruby" {
930            format!("{}-{}", version, p)
931        } else {
932            version.to_string()
933        }
934    } else {
935        version.to_string()
936    };
937
938    Some(format!(
939        "https://rubygems.org/downloads/{}-{}.gem",
940        name, version_plat
941    ))
942}
943
944fn rubygems_api_url(name: &str, version: Option<&str>) -> Option<String> {
945    if name.is_empty() {
946        return None;
947    }
948
949    if let Some(v) = version {
950        Some(format!(
951            "https://rubygems.org/api/v2/rubygems/{}/versions/{}.json",
952            name, v
953        ))
954    } else {
955        Some(format!(
956            "https://rubygems.org/api/v1/versions/{}.json",
957            name
958        ))
959    }
960}
961
962fn get_rubygems_urls(
963    name: &str,
964    version: Option<&str>,
965    platform: Option<&str>,
966) -> (
967    Option<String>,
968    Option<String>,
969    Option<String>,
970    Option<String>,
971) {
972    let repository_homepage_url = rubygems_homepage_url(name, version);
973    let repository_download_url = rubygems_download_url(name, version, platform);
974    let api_data_url = rubygems_api_url(name, version);
975    let download_url = repository_download_url.clone();
976
977    (
978        repository_homepage_url,
979        repository_download_url,
980        api_data_url,
981        download_url,
982    )
983}
984
985/// Returns a default PackageData with gem-specific settings.
986fn default_package_data() -> PackageData {
987    PackageData {
988        package_type: Some(PACKAGE_TYPE),
989        primary_language: Some("Ruby".to_string()),
990        ..Default::default()
991    }
992}
993
994fn default_package_data_with_datasource(datasource_id: DatasourceId) -> PackageData {
995    PackageData {
996        datasource_id: Some(datasource_id),
997        ..default_package_data()
998    }
999}
1000
1001// =============================================================================
1002// Gemspec Parser (Ruby DSL)
1003// =============================================================================
1004
1005/// Ruby .gemspec file parser.
1006///
1007/// Parses `Gem::Specification.new` blocks using regex-based extraction.
1008/// Handles frozen strings (Bug #1), variable version resolution (Bug #2),
1009/// and RFC 5322 email parsing (Bug #6).
1010pub struct GemspecParser;
1011
1012impl PackageParser for GemspecParser {
1013    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
1014
1015    fn metadata() -> Vec<ParserMetadata> {
1016        vec![ParserMetadata {
1017            description: "Ruby .gemspec manifest",
1018            file_patterns: &[
1019                "**/*.gemspec",
1020                "**/data.gz-extract/*.gemspec",
1021                "**/specifications/*.gemspec",
1022            ],
1023            package_type: "gem",
1024            primary_language: "Ruby",
1025            documentation_url: Some("https://guides.rubygems.org/specification-reference/"),
1026        }]
1027    }
1028
1029    fn extract_packages(path: &Path) -> Vec<PackageData> {
1030        let datasource_id = gemspec_datasource_id(path);
1031        let content = match read_file_to_string(path, None) {
1032            Ok(c) => c,
1033            Err(e) => {
1034                warn!("Failed to read .gemspec at {:?}: {}", path, e);
1035                return vec![default_package_data_with_datasource(datasource_id)];
1036            }
1037        };
1038
1039        let mut package_data = parse_gemspec_with_context(&content, path.parent());
1040        package_data.datasource_id = Some(datasource_id);
1041        vec![package_data]
1042    }
1043
1044    fn is_match(path: &Path) -> bool {
1045        path.extension()
1046            .and_then(|ext| ext.to_str())
1047            .is_some_and(|ext| ext == "gemspec")
1048    }
1049}
1050
1051fn normalized_ruby_path(path: &Path) -> String {
1052    path.to_string_lossy().replace('\\', "/")
1053}
1054
1055fn gemfile_datasource_id(path: &Path) -> DatasourceId {
1056    if normalized_ruby_path(path).contains("/data.gz-extract/") {
1057        DatasourceId::GemfileExtracted
1058    } else {
1059        DatasourceId::Gemfile
1060    }
1061}
1062
1063fn gemfile_lock_datasource_id(path: &Path) -> DatasourceId {
1064    if normalized_ruby_path(path).contains("/data.gz-extract/") {
1065        DatasourceId::GemfileLockExtracted
1066    } else {
1067        DatasourceId::GemfileLock
1068    }
1069}
1070
1071fn gemspec_datasource_id(path: &Path) -> DatasourceId {
1072    let normalized = normalized_ruby_path(path);
1073    if normalized.contains("/data.gz-extract/") {
1074        DatasourceId::GemspecExtracted
1075    } else if normalized.contains("/specifications/") {
1076        DatasourceId::GemGemspecInstalledSpecifications
1077    } else {
1078        DatasourceId::Gemspec
1079    }
1080}
1081
1082/// Cleans a value extracted from gemspec by stripping quotes, .freeze, %q{}, and brackets.
1083fn clean_gemspec_value(s: &str) -> String {
1084    let s = strip_freeze_suffix(s).trim();
1085
1086    let s = if let Some(pos) = s.find(" #") {
1087        s[..pos].trim()
1088    } else {
1089        s
1090    };
1091
1092    let s = if let Some(stripped) = s.strip_prefix("%q{") {
1093        stripped.strip_suffix('}').unwrap_or(stripped)
1094    } else if let Some(stripped) = s.strip_prefix("%q<") {
1095        stripped.strip_suffix('>').unwrap_or(stripped)
1096    } else if let Some(stripped) = s.strip_prefix("%q[") {
1097        stripped.strip_suffix(']').unwrap_or(stripped)
1098    } else if let Some(stripped) = s.strip_prefix("%q(") {
1099        stripped.strip_suffix(')').unwrap_or(stripped)
1100    } else {
1101        s
1102    };
1103
1104    let s = s
1105        .trim_start_matches('"')
1106        .trim_end_matches('"')
1107        .trim_start_matches('\'')
1108        .trim_end_matches('\'');
1109    let s = strip_freeze_suffix(s).trim();
1110    s.to_string()
1111}
1112
1113/// Extracts items from a Ruby array literal like `["a", "b", "c"]`.
1114fn extract_ruby_array(s: &str) -> Vec<String> {
1115    let s = strip_freeze_suffix(s.trim());
1116    let s = s.trim_start_matches('[').trim_end_matches(']');
1117    let item_re = match Regex::new(r#"["']([^"']*?)["'](?:\.freeze)?"#) {
1118        Ok(r) => r,
1119        Err(_) => return Vec::new(),
1120    };
1121    item_re
1122        .captures_iter(s)
1123        .filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string()))
1124        .collect()
1125}
1126
1127fn extract_all_ruby_values(s: &str) -> Vec<String> {
1128    let value_re = match Regex::new(r#"%q[\{<\[(]([^\}>\])]+)[\}>\])]|["']([^"']+)["']"#) {
1129        Ok(r) => r,
1130        Err(_) => return Vec::new(),
1131    };
1132
1133    value_re
1134        .captures_iter(s)
1135        .filter_map(|caps| caps.get(1).or_else(|| caps.get(2)))
1136        .map(|m| clean_gemspec_value(m.as_str()))
1137        .collect()
1138}
1139
1140fn extract_first_ruby_value(s: &str) -> Option<String> {
1141    extract_all_ruby_values(s).into_iter().next()
1142}
1143
1144fn after_first_argument(args: &str) -> &str {
1145    let mut bracket_depth = 0usize;
1146    let mut paren_depth = 0usize;
1147    let mut in_quote: Option<char> = None;
1148    let chars: Vec<(usize, char)> = args.char_indices().collect();
1149    let mut i = 0;
1150
1151    while i < chars.len() {
1152        let (idx, ch) = chars[i];
1153
1154        if let Some(quote) = in_quote {
1155            if ch == '\\' {
1156                i += 2;
1157                continue;
1158            }
1159            if ch == quote {
1160                in_quote = None;
1161            }
1162            i += 1;
1163            continue;
1164        }
1165
1166        match ch {
1167            '\'' | '"' => in_quote = Some(ch),
1168            '[' | '{' | '<' => bracket_depth += 1,
1169            ']' | '}' | '>' => bracket_depth = bracket_depth.saturating_sub(1),
1170            '(' => paren_depth += 1,
1171            ')' => paren_depth = paren_depth.saturating_sub(1),
1172            ',' if bracket_depth == 0 && paren_depth == 0 => return args[idx + 1..].trim(),
1173            _ => {}
1174        }
1175
1176        i += 1;
1177    }
1178
1179    ""
1180}
1181
1182/// Bug #2: Resolves variable version references like `CSV::VERSION` or `RAILS_VERSION`.
1183///
1184/// Scans the file content for constant definitions matching the variable name
1185/// and returns the resolved string value.
1186fn resolve_variable_version(var_name: &str, contexts: &[String]) -> Option<String> {
1187    let var_name = var_name.trim();
1188    if var_name.is_empty() {
1189        return None;
1190    }
1191
1192    for candidate in candidate_constant_names(var_name) {
1193        let escaped = regex::escape(&candidate);
1194        let pattern = format!(r#"(?m)^\s*{}\s*=\s*(.+)$"#, escaped);
1195        let Ok(re) = Regex::new(&pattern) else {
1196            continue;
1197        };
1198
1199        for context in contexts {
1200            if let Some(caps) = re.captures(context)
1201                && let Some(expression) = caps.get(1)
1202                && let Some(resolved) =
1203                    resolve_scalar_expression(expression.as_str(), None, contexts)
1204            {
1205                return Some(resolved);
1206            }
1207        }
1208    }
1209
1210    None
1211}
1212
1213fn resolve_variable_array(var_name: &str, contexts: &[String]) -> Option<Vec<String>> {
1214    let var_name = var_name.trim();
1215    if var_name.is_empty() {
1216        return None;
1217    }
1218
1219    for candidate in candidate_constant_names(var_name) {
1220        let escaped = regex::escape(&candidate);
1221        let pattern = format!(r#"(?m)^\s*{}\s*=\s*(\[[^\n]+\])"#, escaped);
1222        let Ok(re) = Regex::new(&pattern) else {
1223            continue;
1224        };
1225
1226        for context in contexts {
1227            if let Some(caps) = re.captures(context)
1228                && let Some(raw) = caps.get(1)
1229            {
1230                let values = extract_ruby_array(raw.as_str());
1231                if !values.is_empty() {
1232                    return Some(values);
1233                }
1234            }
1235        }
1236    }
1237
1238    None
1239}
1240
1241fn candidate_constant_names(var_name: &str) -> Vec<String> {
1242    let mut names = vec![var_name.to_string()];
1243    if let Some(last) = var_name.split("::").last()
1244        && last != var_name
1245    {
1246        names.push(last.to_string());
1247    }
1248    names
1249}
1250
1251fn looks_like_local_variable_reference(s: &str) -> bool {
1252    let mut chars = s.chars();
1253    matches!(chars.next(), Some('_' | 'a'..='z'))
1254        && chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
1255}
1256
1257fn resolve_ruby_read_root(base_dir: Option<&Path>) -> Option<PathBuf> {
1258    let base_dir = base_dir?;
1259    let current_dir = std::env::current_dir().ok();
1260
1261    current_dir
1262        .and_then(|cwd| {
1263            let canonical_cwd = cwd.canonicalize().ok()?;
1264            let canonical_base = base_dir.canonicalize().ok()?;
1265            canonical_base
1266                .starts_with(&canonical_cwd)
1267                .then_some(canonical_cwd)
1268        })
1269        .or_else(|| base_dir.canonicalize().ok())
1270}
1271
1272fn resolve_ruby_read_path(path: PathBuf, allowed_root: &Path) -> Option<PathBuf> {
1273    let canonical_path = path.canonicalize().ok()?;
1274    canonical_path
1275        .starts_with(allowed_root)
1276        .then_some(canonical_path)
1277}
1278
1279fn resolve_file_read_argument(args: &str, base_dir: Option<&Path>) -> Option<String> {
1280    let base_dir = base_dir?;
1281    let allowed_root = resolve_ruby_read_root(base_dir.into())?;
1282    let relative_path = extract_first_ruby_value(args)?;
1283    if relative_path.is_empty() {
1284        return None;
1285    }
1286
1287    let candidate = Path::new(&relative_path);
1288    let path = if candidate.is_absolute() {
1289        candidate.to_path_buf()
1290    } else {
1291        base_dir.join(candidate)
1292    };
1293
1294    let safe_path = resolve_ruby_read_path(path, &allowed_root)?;
1295
1296    fs::read_to_string(safe_path)
1297        .ok()
1298        .map(|content| content.trim().to_string())
1299        .filter(|content| !content.is_empty())
1300}
1301
1302fn resolve_scalar_expression(
1303    expression: &str,
1304    base_dir: Option<&Path>,
1305    contexts: &[String],
1306) -> Option<String> {
1307    let expression = if let Some(pos) = expression.find(" #") {
1308        expression[..pos].trim()
1309    } else {
1310        expression.trim()
1311    };
1312
1313    let file_read_re = Regex::new(r#"^File\.read\((.+)\)(?:\.strip)?(?:\.freeze)?$"#).ok()?;
1314    if let Some(caps) = file_read_re.captures(expression) {
1315        return caps
1316            .get(1)
1317            .and_then(|m| resolve_file_read_argument(m.as_str(), base_dir));
1318    }
1319
1320    if let Some(joined) = resolve_joined_constant_string(expression, contexts) {
1321        return Some(joined);
1322    }
1323
1324    if let Some(value) = extract_first_ruby_value(expression) {
1325        return Some(interpolate_ruby_constant_string(&value, contexts));
1326    }
1327
1328    let cleaned = clean_gemspec_value(expression);
1329    if looks_like_constant_reference(&cleaned) {
1330        return resolve_variable_version(&cleaned, contexts).or(Some(cleaned));
1331    }
1332
1333    None
1334}
1335
1336fn resolve_joined_constant_string(expression: &str, contexts: &[String]) -> Option<String> {
1337    let expression = strip_freeze_suffix(expression.trim());
1338    if !expression.starts_with('[') {
1339        return None;
1340    }
1341    let join_index = expression.find("].join(")?;
1342    let body = &expression[1..join_index];
1343    let separator_expr = expression[join_index + 7..].strip_suffix(')')?.trim();
1344    let separator = extract_first_ruby_value(separator_expr)?;
1345
1346    let mut parts = Vec::new();
1347    for item in body.split(',').capped("gemspec join expression parts") {
1348        let resolved = resolve_scalar_expression(item.trim(), None, contexts)?;
1349        parts.push(resolved);
1350    }
1351
1352    Some(parts.join(&separator))
1353}
1354
1355fn interpolate_ruby_constant_string(value: &str, contexts: &[String]) -> String {
1356    if !value.contains("#{") {
1357        return value.to_string();
1358    }
1359
1360    let Ok(interpolation_re) = Regex::new(r#"#\{([^}]+)\}"#) else {
1361        return value.to_string();
1362    };
1363    interpolation_re
1364        .replace_all(value, |captures: &regex::Captures<'_>| {
1365            let reference = captures
1366                .get(1)
1367                .map(|m| m.as_str().trim())
1368                .unwrap_or_default();
1369            resolve_variable_version(reference, contexts).unwrap_or_else(|| {
1370                captures
1371                    .get(0)
1372                    .map(|value| value.as_str().to_string())
1373                    .unwrap_or_default()
1374            })
1375        })
1376        .into_owned()
1377}
1378
1379fn resolve_local_variable_value(
1380    var_name: &str,
1381    content: &str,
1382    base_dir: Option<&Path>,
1383    contexts: &[String],
1384) -> Option<String> {
1385    let escaped = regex::escape(var_name.trim());
1386    let pattern = format!(r#"(?m)^\s*{}\s*=\s*(.+)$"#, escaped);
1387    let re = Regex::new(&pattern).ok()?;
1388
1389    re.captures_iter(content).find_map(|caps| {
1390        caps.get(1)
1391            .and_then(|m| resolve_scalar_expression(m.as_str(), base_dir, contexts))
1392    })
1393}
1394
1395fn resolve_gemspec_scalar_value(
1396    raw_value: &str,
1397    content: &str,
1398    base_dir: Option<&Path>,
1399    contexts: &[String],
1400) -> Option<String> {
1401    let cleaned = truncate_field(clean_gemspec_value(raw_value));
1402    if cleaned.is_empty() {
1403        return None;
1404    }
1405
1406    if looks_like_constant_reference(&cleaned) {
1407        return resolve_variable_version(&cleaned, contexts)
1408            .map(truncate_field)
1409            .or(Some(cleaned));
1410    }
1411
1412    if looks_like_local_variable_reference(&cleaned) {
1413        return resolve_local_variable_value(&cleaned, content, base_dir, contexts)
1414            .map(truncate_field)
1415            .or(Some(cleaned));
1416    }
1417
1418    Some(cleaned)
1419}
1420
1421fn load_required_ruby_contexts(content: &str, base_dir: Option<&Path>) -> Vec<String> {
1422    let mut contexts = vec![content.to_string()];
1423    let Some(base_dir) = base_dir else {
1424        return contexts;
1425    };
1426    let allowed_root = resolve_ruby_read_root(Some(base_dir));
1427
1428    let require_re = match Regex::new(r#"(?m)^\s*require(?:_relative)?\s+["']([^"']+)["']"#) {
1429        Ok(re) => re,
1430        Err(_) => return contexts,
1431    };
1432
1433    for caps in require_re.captures_iter(content) {
1434        let Some(required) = caps.get(1).map(|m| m.as_str()) else {
1435            continue;
1436        };
1437        for candidate in candidate_require_paths(base_dir, required) {
1438            let Some(safe_candidate) = allowed_root
1439                .as_deref()
1440                .and_then(|root| resolve_ruby_read_path(candidate, root))
1441            else {
1442                continue;
1443            };
1444            if let Ok(required_content) = read_file_to_string(&safe_candidate, None) {
1445                contexts.push(required_content);
1446                break;
1447            }
1448        }
1449    }
1450
1451    contexts
1452}
1453
1454fn candidate_require_paths(base_dir: &Path, required: &str) -> Vec<PathBuf> {
1455    let relative = required.replace("::", "/");
1456    let filename = if relative.ends_with(".rb") {
1457        relative
1458    } else {
1459        format!("{}.rb", relative)
1460    };
1461
1462    vec![
1463        base_dir.join(&filename),
1464        base_dir.join("lib").join(&filename),
1465    ]
1466}
1467
1468fn looks_like_constant_reference(s: &str) -> bool {
1469    s.contains("::") || s.chars().next().is_some_and(|c| c.is_ascii_uppercase())
1470}
1471
1472/// Parses a .gemspec file content and returns PackageData.
1473#[cfg(test)]
1474fn parse_gemspec(content: &str) -> PackageData {
1475    parse_gemspec_with_context(content, None)
1476}
1477
1478fn parse_gemspec_with_context(content: &str, base_dir: Option<&Path>) -> PackageData {
1479    let contexts = load_required_ruby_contexts(content, base_dir);
1480
1481    // Regex for spec.name = "value" or s.name = "value"
1482    // The spec variable name varies: spec, s, gem, etc.
1483    let field_re = match Regex::new(
1484        r#"(?m)^\s*\w+\.(name|version|summary|description|homepage|license)\s*=\s*(.+)$"#,
1485    ) {
1486        Ok(r) => r,
1487        Err(e) => {
1488            warn!("Failed to compile gemspec field regex: {}", e);
1489            return default_package_data_with_datasource(DatasourceId::Gemspec);
1490        }
1491    };
1492
1493    let licenses_re = match Regex::new(r#"(?m)^\s*\w+\.licenses\s*=\s*(.+)$"#) {
1494        Ok(r) => r,
1495        Err(e) => {
1496            warn!("Failed to compile licenses regex: {}", e);
1497            return default_package_data_with_datasource(DatasourceId::Gemspec);
1498        }
1499    };
1500
1501    let authors_re = match Regex::new(r#"(?m)^\s*\w+\.(?:authors|author)\s*=\s*(.+)$"#) {
1502        Ok(r) => r,
1503        Err(e) => {
1504            warn!("Failed to compile authors regex: {}", e);
1505            return default_package_data_with_datasource(DatasourceId::Gemspec);
1506        }
1507    };
1508
1509    let email_re = match Regex::new(r#"(?m)^\s*\w+\.email\s*=\s*(.+)$"#) {
1510        Ok(r) => r,
1511        Err(e) => {
1512            warn!("Failed to compile email regex: {}", e);
1513            return default_package_data_with_datasource(DatasourceId::Gemspec);
1514        }
1515    };
1516
1517    let dependency_call_re = match Regex::new(
1518        r#"(?m)^\s*\w+\.(add_(?:development_|runtime_)?dependency)\s*\(?(.+?)\)?\s*$"#,
1519    ) {
1520        Ok(r) => r,
1521        Err(e) => {
1522            warn!("Failed to compile gemspec dependency regex: {}", e);
1523            return default_package_data_with_datasource(DatasourceId::Gemspec);
1524        }
1525    };
1526
1527    let mut name: Option<String> = None;
1528    let mut version: Option<String> = None;
1529    let mut summary: Option<String> = None;
1530    let mut description: Option<String> = None;
1531    let mut homepage: Option<String> = None;
1532    let mut license: Option<String> = None;
1533    let mut licenses: Vec<String> = Vec::new();
1534    let mut authors: Vec<String> = Vec::new();
1535    let mut emails: Vec<String> = Vec::new();
1536    let mut dependencies: Vec<Dependency> = Vec::new();
1537
1538    // Extract basic fields
1539    for caps in field_re.captures_iter(content).capped("gemspec fields") {
1540        let field_name = match caps.get(1) {
1541            Some(m) => m.as_str(),
1542            None => continue,
1543        };
1544        let raw_value = match caps.get(2) {
1545            Some(m) => m.as_str().trim(),
1546            None => continue,
1547        };
1548
1549        match field_name {
1550            "name" => name = resolve_gemspec_scalar_value(raw_value, content, base_dir, &contexts),
1551            "version" => {
1552                version = resolve_gemspec_scalar_value(raw_value, content, base_dir, &contexts);
1553            }
1554            "summary" => {
1555                summary = resolve_gemspec_scalar_value(raw_value, content, base_dir, &contexts)
1556            }
1557            "description" => description = Some(truncate_field(clean_gemspec_value(raw_value))),
1558            "homepage" => {
1559                homepage = resolve_gemspec_scalar_value(raw_value, content, base_dir, &contexts)
1560            }
1561            "license" => license = Some(truncate_field(clean_gemspec_value(raw_value))),
1562            _ => {}
1563        }
1564    }
1565
1566    // Extract licenses (plural)
1567    for caps in licenses_re
1568        .captures_iter(content)
1569        .capped("gemspec licenses")
1570    {
1571        if let Some(raw) = caps.get(1) {
1572            licenses = extract_ruby_array(raw.as_str());
1573        }
1574    }
1575
1576    // Extract authors
1577    for caps in authors_re.captures_iter(content).capped("gemspec authors") {
1578        if let Some(raw) = caps.get(1) {
1579            let raw_str = raw.as_str().trim();
1580            if raw_str.starts_with('[') {
1581                authors = extract_ruby_array(raw_str);
1582            } else if looks_like_constant_reference(raw_str) {
1583                authors = resolve_variable_array(raw_str, &contexts)
1584                    .unwrap_or_else(|| vec![clean_gemspec_value(raw_str)]);
1585            } else {
1586                authors.push(clean_gemspec_value(raw_str));
1587            }
1588        }
1589    }
1590
1591    // Extract emails
1592    for caps in email_re.captures_iter(content).capped("gemspec emails") {
1593        if let Some(raw) = caps.get(1) {
1594            let raw_str = raw.as_str().trim();
1595            if raw_str.starts_with('[') {
1596                emails = extract_ruby_array(raw_str);
1597            } else if looks_like_constant_reference(raw_str) {
1598                emails = resolve_variable_array(raw_str, &contexts)
1599                    .unwrap_or_else(|| vec![clean_gemspec_value(raw_str)]);
1600            } else {
1601                emails.push(clean_gemspec_value(raw_str));
1602            }
1603        }
1604    }
1605
1606    // Build parties from authors and emails
1607    let mut parties: Vec<Party> = Vec::new();
1608
1609    if authors.len() == 1 && emails.len() == 1 {
1610        let email_str = emails.first().map(String::as_str);
1611        let (parsed_email_name, parsed_email) = match email_str {
1612            Some(e) => split_name_email(e),
1613            None => (None, None),
1614        };
1615
1616        parties.push(Party {
1617            r#type: Some(PartyType::Person),
1618            role: Some("author".to_string()),
1619            name: authors.first().cloned().or(parsed_email_name),
1620            email: parsed_email.or_else(|| {
1621                email_str
1622                    .filter(|e| e.contains('@') && !e.contains('<'))
1623                    .map(|e| e.to_string())
1624            }),
1625            url: None,
1626            organization: None,
1627            organization_url: None,
1628            timezone: None,
1629        });
1630    } else {
1631        for author_name in authors {
1632            parties.push(Party {
1633                r#type: Some(PartyType::Person),
1634                role: Some("author".to_string()),
1635                name: Some(author_name),
1636                email: None,
1637                url: None,
1638                organization: None,
1639                organization_url: None,
1640                timezone: None,
1641            });
1642        }
1643
1644        for email_str in emails {
1645            let (parsed_email_name, parsed_email) = if email_str.contains('<') {
1646                split_name_email(&email_str)
1647            } else {
1648                (None, None)
1649            };
1650            parties.push(Party {
1651                r#type: Some(PartyType::Person),
1652                role: Some("author".to_string()),
1653                name: parsed_email_name,
1654                email: parsed_email.or_else(|| email_str.contains('@').then_some(email_str)),
1655                url: None,
1656                organization: None,
1657                organization_url: None,
1658                timezone: None,
1659            });
1660        }
1661    }
1662
1663    for caps in dependency_call_re
1664        .captures_iter(content)
1665        .capped("gemspec dependency calls")
1666    {
1667        let method = match caps.get(1) {
1668            Some(m) => m.as_str(),
1669            None => continue,
1670        };
1671        let args = match caps.get(2) {
1672            Some(m) => m.as_str(),
1673            None => continue,
1674        };
1675
1676        let Some(dep_name) = extract_first_ruby_value(args).map(truncate_field) else {
1677            continue;
1678        };
1679        let version_parts = extract_all_ruby_values(after_first_argument(args));
1680        let extracted_requirement = if version_parts.is_empty() {
1681            None
1682        } else {
1683            Some(version_parts.join(", "))
1684        };
1685        let purl = create_gem_purl(&dep_name, None);
1686        let is_development = method == "add_development_dependency";
1687        let scope = if is_development {
1688            "development"
1689        } else {
1690            "runtime"
1691        };
1692
1693        dependencies.push(Dependency {
1694            purl,
1695            extracted_requirement,
1696            scope: Some(scope.to_string()),
1697            is_runtime: Some(!is_development),
1698            is_optional: Some(is_development),
1699            is_pinned: None,
1700            is_direct: Some(true),
1701            resolved_package: None,
1702            extra_data: None,
1703        });
1704    }
1705
1706    // Extract license statement only - detection happens in separate engine
1707    let extracted_license_statement = if !licenses.is_empty() {
1708        Some(licenses.join(" AND "))
1709    } else {
1710        license
1711    };
1712
1713    let (declared_license_expression, declared_license_expression_spdx, license_detections) =
1714        normalize_spdx_declared_license(extracted_license_statement.as_deref());
1715
1716    // Prefer description over summary
1717    let final_description = description.or(summary);
1718
1719    // Build PURL
1720    let purl = name
1721        .as_deref()
1722        .map(|n| create_gem_purl(n, version.as_deref()))
1723        .unwrap_or(None);
1724
1725    let (repository_homepage_url, repository_download_url, api_data_url, download_url) =
1726        if let Some(n) = name.as_deref() {
1727            get_rubygems_urls(n, version.as_deref(), None)
1728        } else {
1729            (None, None, None, None)
1730        };
1731
1732    PackageData {
1733        package_type: Some(PACKAGE_TYPE),
1734        name,
1735        version,
1736        primary_language: Some("Ruby".to_string()),
1737        description: final_description,
1738        homepage_url: homepage,
1739        download_url,
1740        declared_license_expression,
1741        declared_license_expression_spdx,
1742        license_detections,
1743        extracted_license_statement,
1744        parties,
1745        dependencies,
1746        repository_homepage_url,
1747        repository_download_url,
1748        api_data_url,
1749        datasource_id: Some(DatasourceId::Gemspec),
1750        purl,
1751        ..default_package_data()
1752    }
1753}
1754
1755// =============================================================================
1756// .gem Archive Parser (Wave 3)
1757// =============================================================================
1758
1759const MAX_ARCHIVE_SIZE: u64 = 100 * 1024 * 1024; // 100MB
1760const MAX_FILE_SIZE: u64 = 50 * 1024 * 1024; // 50MB per file
1761const MAX_COMPRESSION_RATIO: f64 = 100.0; // 100:1 ratio
1762
1763/// Parser for .gem archive files.
1764///
1765/// Extracts metadata from Ruby .gem packages, which are tar archives
1766/// containing a gzip-compressed YAML metadata file (`metadata.gz`).
1767///
1768/// Includes safety checks against zip bombs and oversized archives.
1769pub struct GemArchiveParser;
1770
1771impl PackageParser for GemArchiveParser {
1772    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
1773
1774    fn metadata() -> Vec<ParserMetadata> {
1775        vec![ParserMetadata {
1776            description: "Ruby .gem archive",
1777            file_patterns: &["**/*.gem"],
1778            package_type: "gem",
1779            primary_language: "Ruby",
1780            documentation_url: Some("https://guides.rubygems.org/specification-reference/"),
1781        }]
1782    }
1783
1784    fn extract_packages(path: &Path) -> Vec<PackageData> {
1785        vec![match extract_gem_archive(path) {
1786            Ok(data) => data,
1787            Err(e) => {
1788                warn!("Failed to extract .gem archive at {:?}: {}", path, e);
1789                default_package_data_with_datasource(DatasourceId::GemArchive)
1790            }
1791        }]
1792    }
1793
1794    fn is_match(path: &Path) -> bool {
1795        path.extension()
1796            .and_then(|ext| ext.to_str())
1797            .is_some_and(|ext| ext == "gem")
1798    }
1799}
1800
1801fn extract_gem_archive(path: &Path) -> Result<PackageData, String> {
1802    let file_metadata =
1803        fs::metadata(path).map_err(|e| format!("Failed to read file metadata: {}", e))?;
1804    let archive_size = file_metadata.len();
1805
1806    if archive_size > MAX_ARCHIVE_SIZE {
1807        return Err(format!(
1808            "Archive too large: {} bytes (limit: {} bytes)",
1809            archive_size, MAX_ARCHIVE_SIZE
1810        ));
1811    }
1812
1813    let file = File::open(path).map_err(|e| format!("Failed to open archive: {}", e))?;
1814    let mut archive = Archive::new(file);
1815
1816    let mut entry_count: usize = 0;
1817    for entry_result in archive
1818        .entries()
1819        .map_err(|e| format!("Failed to read tar entries: {}", e))?
1820    {
1821        entry_count += 1;
1822        if entry_count > MAX_ITERATION_COUNT {
1823            warn!(
1824                "Exceeded max tar entry count ({}) in .gem archive, stopping iteration",
1825                MAX_ITERATION_COUNT
1826            );
1827            break;
1828        }
1829
1830        let entry = entry_result.map_err(|e| format!("Failed to read tar entry: {}", e))?;
1831        let entry_path = entry
1832            .path()
1833            .map_err(|e| format!("Failed to get entry path: {}", e))?;
1834        let entry_str = entry_path.to_string_lossy();
1835        if entry_str.contains("..") {
1836            warn!("Skipping tar entry with path traversal: {}", entry_str);
1837            continue;
1838        }
1839
1840        if entry_path.to_str() == Some("metadata.gz") {
1841            let entry_size = entry.size();
1842            if entry_size > MAX_FILE_SIZE {
1843                return Err(format!(
1844                    "metadata.gz too large: {} bytes (limit: {} bytes)",
1845                    entry_size, MAX_FILE_SIZE
1846                ));
1847            }
1848
1849            let mut decoder = GzDecoder::new(entry);
1850            let mut content = Vec::new();
1851            let mut limited = std::io::Read::take(&mut decoder, MAX_FILE_SIZE + 1);
1852            limited
1853                .read_to_end(&mut content)
1854                .map_err(|e| format!("Failed to decompress metadata.gz: {}", e))?;
1855
1856            if content.len() > MAX_FILE_SIZE as usize {
1857                return Err(format!(
1858                    "Decompressed metadata too large: exceeds {} byte limit",
1859                    MAX_FILE_SIZE
1860                ));
1861            }
1862
1863            let content = match String::from_utf8(content) {
1864                Ok(s) => s,
1865                Err(err) => {
1866                    let bytes = err.into_bytes();
1867                    warn!("Invalid UTF-8 in gem metadata; using lossy conversion");
1868                    String::from_utf8_lossy(&bytes).into_owned()
1869                }
1870            };
1871
1872            let uncompressed_size = content.len() as u64;
1873            if entry_size > 0 {
1874                let ratio = uncompressed_size as f64 / entry_size as f64;
1875                if ratio > MAX_COMPRESSION_RATIO {
1876                    return Err(format!(
1877                        "Suspicious compression ratio: {:.2}:1 (limit: {:.0}:1)",
1878                        ratio, MAX_COMPRESSION_RATIO
1879                    ));
1880                }
1881            }
1882
1883            return parse_gem_metadata_yaml(&content, DatasourceId::GemArchive);
1884        }
1885    }
1886
1887    Err("metadata.gz not found in .gem archive".to_string())
1888}
1889
1890fn parse_gem_metadata_yaml(
1891    content: &str,
1892    datasource_id: DatasourceId,
1893) -> Result<PackageData, String> {
1894    // Ruby YAML tagged types need to be handled:
1895    // --- !ruby/object:Gem::Specification
1896    // We strip Ruby-specific YAML tags since yaml_serde can't handle them
1897    let cleaned = clean_ruby_yaml_tags(content);
1898
1899    let yaml: yaml_serde::Value =
1900        yaml_serde::from_str(&cleaned).map_err(|e| format!("Failed to parse YAML: {}", e))?;
1901
1902    let name = yaml_string(&yaml, "name").map(truncate_field);
1903    let version = yaml.get("version").and_then(|v| {
1904        if v.is_string() {
1905            v.as_str().map(|s| truncate_field(s.to_string()))
1906        } else {
1907            yaml_string(v, "version").map(truncate_field)
1908        }
1909    });
1910    let description = yaml_string(&yaml, "description")
1911        .or_else(|| yaml_string(&yaml, "summary"))
1912        .map(truncate_field);
1913    let homepage = yaml_string(&yaml, "homepage").map(truncate_field);
1914    let summary = yaml_string(&yaml, "summary").map(truncate_field);
1915
1916    // Licenses
1917    let licenses: Vec<String> = yaml
1918        .get("licenses")
1919        .and_then(|v| v.as_sequence())
1920        .map(|seq| {
1921            seq.iter()
1922                .filter_map(|item| item.as_str().map(|s| truncate_field(s.to_string())))
1923                .collect()
1924        })
1925        .unwrap_or_default();
1926
1927    // Extract license statement only - detection happens in separate engine
1928    let extracted_license_statement = if !licenses.is_empty() {
1929        Some(licenses.join(" AND "))
1930    } else {
1931        None
1932    };
1933
1934    let (license_expression, license_expression_spdx, license_detections) =
1935        normalize_spdx_declared_license(extracted_license_statement.as_deref());
1936
1937    // Authors
1938    let authors: Vec<String> = yaml
1939        .get("authors")
1940        .and_then(|v| v.as_sequence())
1941        .map(|seq| {
1942            seq.iter()
1943                .filter_map(|item| item.as_str().map(|s| truncate_field(s.to_string())))
1944                .collect()
1945        })
1946        .unwrap_or_default();
1947
1948    let emails: Vec<String> = yaml
1949        .get("email")
1950        .map(|v| {
1951            if let Some(seq) = v.as_sequence() {
1952                seq.iter()
1953                    .filter_map(|item| item.as_str().map(|s| truncate_field(s.to_string())))
1954                    .collect()
1955            } else if let Some(s) = v.as_str() {
1956                vec![truncate_field(s.to_string())]
1957            } else {
1958                Vec::new()
1959            }
1960        })
1961        .unwrap_or_default();
1962
1963    // Build parties
1964    let mut parties: Vec<Party> = Vec::new();
1965    let max_len = authors.len().max(emails.len());
1966    for i in 0..max_len {
1967        let author_name = authors.get(i).map(|s| s.as_str());
1968        let email_str = emails.get(i).map(|s| s.as_str());
1969
1970        let (parsed_email_name, parsed_email) = match email_str {
1971            Some(e) if e.contains('<') => split_name_email(e),
1972            None => (None, None),
1973            _ => (None, None),
1974        };
1975
1976        let party_name = author_name.map(|s| s.to_string()).or(parsed_email_name);
1977
1978        parties.push(Party {
1979            r#type: Some(PartyType::Person),
1980            role: Some("author".to_string()),
1981            name: party_name,
1982            email: parsed_email.or_else(|| {
1983                email_str
1984                    .filter(|e| e.contains('@') && !e.contains('<'))
1985                    .map(|e| e.to_string())
1986            }),
1987            url: None,
1988            organization: None,
1989            organization_url: None,
1990            timezone: None,
1991        });
1992    }
1993
1994    // Dependencies
1995    let dependencies = parse_gem_yaml_dependencies(&yaml);
1996
1997    let metadata = yaml.get("metadata");
1998
1999    let bug_tracking_url = metadata
2000        .and_then(|m| yaml_string(m, "bug_tracking_uri"))
2001        .map(truncate_field);
2002
2003    let code_view_url = metadata
2004        .and_then(|m| yaml_string(m, "source_code_uri"))
2005        .map(truncate_field);
2006
2007    let vcs_url = code_view_url.clone().or_else(|| {
2008        metadata
2009            .and_then(|m| yaml_string(m, "homepage_uri"))
2010            .map(truncate_field)
2011    });
2012
2013    let file_references = metadata
2014        .and_then(|m| m.get("files"))
2015        .and_then(|f| f.as_sequence())
2016        .map(|seq| {
2017            seq.iter()
2018                .filter_map(|v| v.as_str())
2019                .map(|s| crate::models::FileReference {
2020                    path: s.to_string(),
2021                    size: None,
2022                    sha1: None,
2023                    md5: None,
2024                    sha256: None,
2025                    sha512: None,
2026                    extra_data: None,
2027                })
2028                .collect::<Vec<_>>()
2029        })
2030        .unwrap_or_default();
2031
2032    let release_date = yaml_string(&yaml, "date").and_then(|d| {
2033        if d.len() >= 10 {
2034            Some(d[..10].to_string())
2035        } else {
2036            None
2037        }
2038    });
2039
2040    let purl = name
2041        .as_deref()
2042        .map(|n| create_gem_purl(n, version.as_deref()))
2043        .unwrap_or(None);
2044
2045    let platform = yaml_string(&yaml, "platform").map(truncate_field);
2046    let (repository_homepage_url, repository_download_url, api_data_url, download_url) =
2047        if let Some(n) = name.as_deref() {
2048            get_rubygems_urls(n, version.as_deref(), platform.as_deref())
2049        } else {
2050            (None, None, None, None)
2051        };
2052
2053    let qualifiers = if let Some(ref p) = platform {
2054        if p != "ruby" {
2055            let mut q = HashMap::new();
2056            q.insert("platform".to_string(), p.clone());
2057            Some(q)
2058        } else {
2059            None
2060        }
2061    } else {
2062        None
2063    };
2064
2065    Ok(PackageData {
2066        package_type: Some(PACKAGE_TYPE),
2067        name,
2068        version,
2069        qualifiers,
2070        primary_language: Some("Ruby".to_string()),
2071        description: description.or(summary),
2072        release_date,
2073        homepage_url: homepage,
2074        download_url,
2075        bug_tracking_url,
2076        code_view_url,
2077        declared_license_expression: license_expression,
2078        declared_license_expression_spdx: license_expression_spdx,
2079        license_detections,
2080        extracted_license_statement,
2081        file_references,
2082        parties,
2083        dependencies,
2084        repository_homepage_url,
2085        repository_download_url,
2086        api_data_url,
2087        datasource_id: Some(datasource_id),
2088        purl,
2089        vcs_url,
2090        ..default_package_data()
2091    })
2092}
2093
2094/// Strips Ruby-specific YAML tags that yaml_serde cannot handle.
2095fn clean_ruby_yaml_tags(content: &str) -> String {
2096    let tag_re = match Regex::new(r"!ruby/\S+") {
2097        Ok(r) => r,
2098        Err(_) => return content.to_string(),
2099    };
2100    tag_re.replace_all(content, "").to_string()
2101}
2102
2103fn yaml_string(yaml: &yaml_serde::Value, key: &str) -> Option<String> {
2104    yaml.get(key)
2105        .and_then(|v| v.as_str())
2106        .filter(|s| !s.is_empty())
2107        .map(|s| s.to_string())
2108}
2109
2110fn parse_gem_yaml_dependencies(yaml: &yaml_serde::Value) -> Vec<Dependency> {
2111    let mut dependencies = Vec::new();
2112
2113    let deps_seq = match yaml.get("dependencies").and_then(|v| v.as_sequence()) {
2114        Some(seq) => seq,
2115        None => return dependencies,
2116    };
2117
2118    let limit = capped_iteration_limit(deps_seq.len(), "gem metadata YAML dependencies");
2119    for dep_value in deps_seq.iter().take(limit) {
2120        let dep_name = match yaml_string(dep_value, "name").map(truncate_field) {
2121            Some(n) => n,
2122            None => continue,
2123        };
2124
2125        let dep_type = yaml_string(dep_value, "type");
2126        let is_development = dep_type.as_deref() == Some(":development");
2127
2128        // Extract version requirements from the nested structure
2129        let requirements = dep_value
2130            .get("requirement")
2131            .or_else(|| dep_value.get("version_requirements"))
2132            .and_then(|req| req.get("requirements"))
2133            .and_then(|reqs| reqs.as_sequence());
2134
2135        let extracted_requirement = requirements.map(|reqs| {
2136            let parts: Vec<String> = reqs
2137                .iter()
2138                .filter_map(|req| {
2139                    let seq = req.as_sequence()?;
2140                    if seq.len() >= 2 {
2141                        let op = seq[0].as_str().unwrap_or("");
2142                        let ver = seq[1].get("version").and_then(|v| v.as_str()).unwrap_or("");
2143                        if op == ">=" && ver == "0" {
2144                            // ">= 0" means "any version" - skip
2145                            None
2146                        } else if op.is_empty() || ver.is_empty() {
2147                            None
2148                        } else {
2149                            Some(format!("{} {}", op, ver))
2150                        }
2151                    } else {
2152                        None
2153                    }
2154                })
2155                .collect();
2156            parts.join(", ")
2157        });
2158
2159        let extracted_requirement = extracted_requirement
2160            .filter(|s| !s.is_empty())
2161            .or_else(|| Some(String::new()));
2162
2163        let (scope, is_runtime, is_optional) = if is_development {
2164            (Some("development".to_string()), false, true)
2165        } else {
2166            (Some("runtime".to_string()), true, false)
2167        };
2168
2169        let purl = create_gem_purl(&dep_name, None);
2170
2171        dependencies.push(Dependency {
2172            purl,
2173            extracted_requirement,
2174            scope,
2175            is_runtime: Some(is_runtime),
2176            is_optional: Some(is_optional),
2177            is_pinned: None,
2178            is_direct: Some(true),
2179            resolved_package: None,
2180            extra_data: None,
2181        });
2182    }
2183
2184    dependencies
2185}
2186
2187// =============================================================================
2188// Gem Metadata Extracted Parser (metadata.gz-extract files)
2189// =============================================================================
2190
2191pub struct GemMetadataExtractedParser;
2192
2193impl PackageParser for GemMetadataExtractedParser {
2194    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
2195
2196    fn metadata() -> Vec<ParserMetadata> {
2197        vec![ParserMetadata {
2198            description: "Ruby gem metadata (extracted)",
2199            file_patterns: &["**/metadata.gz-extract"],
2200            package_type: "gem",
2201            primary_language: "Ruby",
2202            documentation_url: Some("https://guides.rubygems.org/specification-reference/"),
2203        }]
2204    }
2205
2206    fn extract_packages(path: &Path) -> Vec<PackageData> {
2207        vec![match extract_gem_metadata_extracted(path) {
2208            Ok(data) => data,
2209            Err(e) => {
2210                warn!("Failed to extract gem metadata from {:?}: {}", path, e);
2211                default_package_data_with_datasource(DatasourceId::GemArchiveExtracted)
2212            }
2213        }]
2214    }
2215
2216    fn is_match(path: &Path) -> bool {
2217        path.to_str()
2218            .is_some_and(|p| p.contains("metadata.gz-extract"))
2219    }
2220}
2221
2222fn extract_gem_metadata_extracted(path: &Path) -> Result<PackageData, String> {
2223    let content = read_file_to_string(path, None)
2224        .map_err(|e| format!("Failed to read metadata.gz-extract file: {}", e))?;
2225
2226    parse_gem_metadata_yaml(&content, DatasourceId::GemArchiveExtracted)
2227}
2228
2229#[cfg(test)]
2230mod tests {
2231    use super::parse_gemspec;
2232
2233    #[test]
2234    fn test_clean_gemspec_value_handles_unterminated_percent_q() {
2235        assert_eq!(
2236            super::clean_gemspec_value("%q{Arel is a SQL AST manager for Ruby. It"),
2237            "Arel is a SQL AST manager for Ruby. It"
2238        );
2239    }
2240
2241    #[test]
2242    fn test_parse_gemspec_runtime_dependency_scope() {
2243        let content = r#"
2244Gem::Specification.new do |spec|
2245  spec.name = "demo"
2246  spec.version = "1.0.0"
2247  spec.add_runtime_dependency "rack", "~> 3.0"
2248  spec.add_dependency "thor", ">= 1.0"
2249end
2250"#;
2251
2252        let package_data = parse_gemspec(content);
2253        assert_eq!(package_data.dependencies.len(), 2);
2254        assert_eq!(
2255            package_data.dependencies[0].scope,
2256            Some("runtime".to_string())
2257        );
2258        assert_eq!(
2259            package_data.dependencies[0].extracted_requirement,
2260            Some("~> 3.0".to_string())
2261        );
2262        assert_eq!(
2263            package_data.dependencies[1].scope,
2264            Some("runtime".to_string())
2265        );
2266        assert_eq!(
2267            package_data.dependencies[1].extracted_requirement,
2268            Some(">= 1.0".to_string())
2269        );
2270    }
2271}