Skip to main content

normalize_manifest/
gemfile.rs

1//! Parser for `Gemfile` files (Ruby/Bundler).
2//!
3//! Extracts `gem` declarations:
4//! - `gem "name"`
5//! - `gem "name", "~> 1.0"`
6//! - `gem "name", ">= 1.0", "< 2.0"` (multiple constraints joined)
7//! - `gem "name", group: :development` → DepKind::Dev
8
9use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
10
11/// Parser for `Gemfile` files.
12pub struct GemfileParser;
13
14impl ManifestParser for GemfileParser {
15    fn filename(&self) -> &'static str {
16        "Gemfile"
17    }
18
19    fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
20        let mut deps = Vec::new();
21        let mut current_group: Option<DepKind> = None;
22
23        for line in content.lines() {
24            let trimmed = line.trim();
25
26            if trimmed.is_empty() || trimmed.starts_with('#') {
27                continue;
28            }
29
30            // group :development, :test do ... end
31            if trimmed.starts_with("group") && trimmed.ends_with("do") {
32                current_group = Some(gemfile_group_kind(trimmed));
33                continue;
34            }
35            if trimmed == "end" {
36                current_group = None;
37                continue;
38            }
39
40            if (trimmed.starts_with("gem ") || trimmed.starts_with("gem\t"))
41                && let Some(dep) = parse_gem_line(trimmed, current_group)
42            {
43                deps.push(dep);
44            }
45        }
46
47        Ok(ParsedManifest {
48            ecosystem: "bundler",
49            name: None,
50            version: None,
51            dependencies: deps,
52        })
53    }
54}
55
56fn gemfile_group_kind(line: &str) -> DepKind {
57    if line.contains(":development") || line.contains(":dev") || line.contains(":test") {
58        DepKind::Dev
59    } else {
60        DepKind::Optional
61    }
62}
63
64fn parse_gem_line(line: &str, group_override: Option<DepKind>) -> Option<DeclaredDep> {
65    // Strip leading `gem`
66    let rest = line.trim_start_matches("gem").trim();
67
68    // Collect all quoted tokens and keyword args
69    let mut quoted: Vec<String> = Vec::new();
70    let mut kind = DepKind::Normal;
71
72    // Detect inline group: keyword arg  `group: :development`
73    if rest.contains("group:")
74        && (rest.contains(":development") || rest.contains(":dev") || rest.contains(":test"))
75    {
76        kind = DepKind::Dev;
77    }
78
79    // Extract all single- or double-quoted strings
80    let mut chars = rest.chars().peekable();
81    while let Some(ch) = chars.next() {
82        if ch == '"' || ch == '\'' {
83            let mut s = String::new();
84            for inner in chars.by_ref() {
85                if inner == ch {
86                    break;
87                }
88                s.push(inner);
89            }
90            quoted.push(s);
91        }
92    }
93
94    if quoted.is_empty() {
95        return None;
96    }
97
98    let name = quoted[0].clone();
99    if name.is_empty() {
100        return None;
101    }
102
103    // Remaining quoted strings that look like version constraints
104    let version_parts: Vec<&str> = quoted[1..]
105        .iter()
106        .filter(|s| {
107            s.starts_with('~')
108                || s.starts_with('>')
109                || s.starts_with('<')
110                || s.starts_with('=')
111                || s.chars().next().is_some_and(|c| c.is_ascii_digit())
112        })
113        .map(|s| s.as_str())
114        .collect();
115
116    let version_req = if version_parts.is_empty() {
117        None
118    } else {
119        Some(version_parts.join(", "))
120    };
121
122    let final_kind = group_override.unwrap_or(kind);
123
124    Some(DeclaredDep {
125        name,
126        version_req,
127        kind: final_kind,
128    })
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::ManifestParser;
135
136    #[test]
137    fn test_parse_gemfile() {
138        let content = r#"source "https://rubygems.org"
139
140gem "rails", "~> 7.0"
141gem "pg", ">= 0.18", "< 2.0"
142gem "puma"
143
144group :development, :test do
145  gem "rspec-rails"
146  gem "factory_bot_rails"
147end
148
149gem "capistrano", group: :development
150"#;
151        let m = GemfileParser.parse(content).unwrap();
152        assert_eq!(m.ecosystem, "bundler");
153
154        let rails = m.dependencies.iter().find(|d| d.name == "rails").unwrap();
155        assert_eq!(rails.version_req.as_deref(), Some("~> 7.0"));
156        assert_eq!(rails.kind, DepKind::Normal);
157
158        let pg = m.dependencies.iter().find(|d| d.name == "pg").unwrap();
159        assert_eq!(pg.version_req.as_deref(), Some(">= 0.18, < 2.0"));
160
161        let puma = m.dependencies.iter().find(|d| d.name == "puma").unwrap();
162        assert!(puma.version_req.is_none());
163
164        let rspec = m
165            .dependencies
166            .iter()
167            .find(|d| d.name == "rspec-rails")
168            .unwrap();
169        assert_eq!(rspec.kind, DepKind::Dev);
170
171        let cap = m
172            .dependencies
173            .iter()
174            .find(|d| d.name == "capistrano")
175            .unwrap();
176        assert_eq!(cap.kind, DepKind::Dev);
177    }
178}