Skip to main content

provenant/parsers/
ruby.rs

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