normalize_manifest/
gemfile.rs1use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
10
11pub 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 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 let rest = line.trim_start_matches("gem").trim();
67
68 let mut quoted: Vec<String> = Vec::new();
70 let mut kind = DepKind::Normal;
71
72 if rest.contains("group:")
74 && (rest.contains(":development") || rest.contains(":dev") || rest.contains(":test"))
75 {
76 kind = DepKind::Dev;
77 }
78
79 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 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}