1use deps_core::error::Result;
6use deps_core::lockfile::{
7 LockFileProvider, ResolvedPackage, ResolvedPackages, ResolvedSource,
8 locate_lockfile_for_manifest, read_lockfile_content,
9};
10use regex::Regex;
11use std::path::{Path, PathBuf};
12use std::sync::LazyLock;
13use tower_lsp_server::ls_types::Uri;
14
15pub struct GemfileLockParser;
17
18impl GemfileLockParser {
19 const LOCKFILE_NAMES: &'static [&'static str] = &["Gemfile.lock"];
20}
21
22static GEM_SPEC_PATTERN: LazyLock<Regex> =
24 LazyLock::new(|| Regex::new(r"^\s{4}([a-zA-Z0-9_-]+)\s+\(([^)]+)\)").expect("Invalid regex"));
25
26#[derive(Debug, Clone, Copy, PartialEq)]
27enum Section {
28 None,
29 Gem,
30 Git,
31 Path,
32 Platforms,
33 Dependencies,
34 BundledWith,
35 RubyVersion,
36}
37
38impl LockFileProvider for GemfileLockParser {
39 fn locate_lockfile(&self, manifest_uri: &Uri) -> Option<PathBuf> {
40 locate_lockfile_for_manifest(manifest_uri, Self::LOCKFILE_NAMES)
41 }
42
43 fn parse_lockfile<'a>(
44 &'a self,
45 lockfile_path: &'a Path,
46 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ResolvedPackages>> + Send + 'a>>
47 {
48 Box::pin(async move {
49 tracing::debug!("Parsing Gemfile.lock: {}", lockfile_path.display());
50
51 let content = read_lockfile_content(lockfile_path, "Gemfile.lock").await?;
52
53 parse_gemfile_lock(&content)
54 })
55 }
56}
57
58pub fn parse_gemfile_lock(content: &str) -> Result<ResolvedPackages> {
60 let mut packages = ResolvedPackages::new();
61 let mut current_section = Section::None;
62 let mut current_source = ResolvedSource::Registry {
63 url: "https://rubygems.org".to_string(),
64 checksum: String::new(),
65 };
66 let mut in_specs = false;
67
68 for line in content.lines() {
69 if let Some(section) = detect_section(line) {
71 current_section = section;
72 in_specs = false;
73
74 current_source = match section {
76 Section::Gem => ResolvedSource::Registry {
77 url: "https://rubygems.org".to_string(),
78 checksum: String::new(),
79 },
80 Section::Git => ResolvedSource::Git {
81 url: String::new(),
82 rev: String::new(),
83 },
84 Section::Path => ResolvedSource::Path {
85 path: String::new(),
86 },
87 _ => current_source.clone(),
88 };
89 continue;
90 }
91
92 if line.trim() == "specs:" {
94 in_specs = true;
95 continue;
96 }
97
98 if line.starts_with(" remote:") {
100 let url = line.trim_start_matches(" remote:").trim().to_string();
101 current_source = match current_section {
102 Section::Gem => ResolvedSource::Registry {
103 url,
104 checksum: String::new(),
105 },
106 Section::Git => ResolvedSource::Git {
107 url,
108 rev: String::new(),
109 },
110 Section::Path => ResolvedSource::Path { path: url },
111 _ => current_source.clone(),
112 };
113 continue;
114 }
115
116 if line.starts_with(" revision:") {
118 if let ResolvedSource::Git { url, .. } = ¤t_source {
119 let rev = line.trim_start_matches(" revision:").trim().to_string();
120 current_source = ResolvedSource::Git {
121 url: url.clone(),
122 rev,
123 };
124 }
125 continue;
126 }
127
128 if in_specs
130 && matches!(current_section, Section::Gem | Section::Git | Section::Path)
131 && let Some(caps) = GEM_SPEC_PATTERN.captures(line)
132 {
133 let name = caps[1].to_string();
134 let version = caps[2].to_string();
135
136 packages.insert(ResolvedPackage {
137 name,
138 version,
139 source: current_source.clone(),
140 dependencies: vec![],
141 });
142 }
143 }
144
145 tracing::info!("Parsed Gemfile.lock: {} packages", packages.len());
146
147 Ok(packages)
148}
149
150fn detect_section(line: &str) -> Option<Section> {
151 match line.trim() {
152 "GEM" => Some(Section::Gem),
153 "GIT" => Some(Section::Git),
154 "PATH" => Some(Section::Path),
155 "PLATFORMS" => Some(Section::Platforms),
156 "DEPENDENCIES" => Some(Section::Dependencies),
157 "BUNDLED WITH" => Some(Section::BundledWith),
158 "RUBY VERSION" => Some(Section::RubyVersion),
159 _ => None,
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn test_parse_simple_gemfile_lock() {
169 let lockfile = r"GEM
170 remote: https://rubygems.org/
171 specs:
172 rails (7.0.8)
173 pg (1.5.4)
174 puma (6.4.0)
175
176PLATFORMS
177 ruby
178 x86_64-linux
179
180DEPENDENCIES
181 pg (>= 1.1)
182 puma (~> 6.0)
183 rails (~> 7.0)
184
185BUNDLED WITH
186 2.5.3
187";
188
189 let packages = parse_gemfile_lock(lockfile).unwrap();
190 assert_eq!(packages.len(), 3);
191 assert_eq!(packages.get_version("rails"), Some("7.0.8"));
192 assert_eq!(packages.get_version("pg"), Some("1.5.4"));
193 assert_eq!(packages.get_version("puma"), Some("6.4.0"));
194 }
195
196 #[test]
197 fn test_parse_git_source() {
198 let lockfile = r"GIT
199 remote: https://github.com/rails/rails.git
200 revision: abc123
201 specs:
202 rails (7.1.0.alpha)
203
204GEM
205 remote: https://rubygems.org/
206 specs:
207 pg (1.5.4)
208
209DEPENDENCIES
210 rails!
211 pg
212
213BUNDLED WITH
214 2.5.3
215";
216
217 let packages = parse_gemfile_lock(lockfile).unwrap();
218 assert_eq!(packages.len(), 2);
219 assert_eq!(packages.get_version("rails"), Some("7.1.0.alpha"));
220
221 let rails = packages.get("rails").unwrap();
222 match &rails.source {
223 ResolvedSource::Git { url, rev } => {
224 assert_eq!(url, "https://github.com/rails/rails.git");
225 assert_eq!(rev, "abc123");
226 }
227 _ => panic!("Expected Git source"),
228 }
229 }
230
231 #[test]
232 fn test_parse_path_source() {
233 let lockfile = r"PATH
234 remote: ../my_gem
235 specs:
236 my_gem (0.1.0)
237
238GEM
239 remote: https://rubygems.org/
240 specs:
241 pg (1.5.4)
242
243DEPENDENCIES
244 my_gem!
245 pg
246
247BUNDLED WITH
248 2.5.3
249";
250
251 let packages = parse_gemfile_lock(lockfile).unwrap();
252 assert_eq!(packages.len(), 2);
253
254 let my_gem = packages.get("my_gem").unwrap();
255 match &my_gem.source {
256 ResolvedSource::Path { path } => {
257 assert_eq!(path, "../my_gem");
258 }
259 _ => panic!("Expected Path source"),
260 }
261 }
262
263 #[test]
264 fn test_parse_empty_lockfile() {
265 let lockfile = "";
266 let packages = parse_gemfile_lock(lockfile).unwrap();
267 assert!(packages.is_empty());
268 }
269
270 #[test]
271 fn test_locate_lockfile_same_directory() {
272 let temp_dir = tempfile::tempdir().unwrap();
273 let manifest_path = temp_dir.path().join("Gemfile");
274 let lock_path = temp_dir.path().join("Gemfile.lock");
275
276 std::fs::write(&manifest_path, "source 'https://rubygems.org'").unwrap();
277 std::fs::write(&lock_path, "GEM\n specs:\n").unwrap();
278
279 let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
280 let parser = GemfileLockParser;
281
282 let located = parser.locate_lockfile(&manifest_uri);
283 assert!(located.is_some());
284 assert_eq!(located.unwrap(), lock_path);
285 }
286
287 #[test]
288 fn test_locate_lockfile_not_found() {
289 let temp_dir = tempfile::tempdir().unwrap();
290 let manifest_path = temp_dir.path().join("Gemfile");
291 std::fs::write(&manifest_path, "source 'https://rubygems.org'").unwrap();
292
293 let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
294 let parser = GemfileLockParser;
295
296 let located = parser.locate_lockfile(&manifest_uri);
297 assert!(located.is_none());
298 }
299
300 #[tokio::test]
301 async fn test_parse_lockfile_file() {
302 let temp_dir = tempfile::tempdir().unwrap();
303 let lockfile_path = temp_dir.path().join("Gemfile.lock");
304
305 let content = r"GEM
306 remote: https://rubygems.org/
307 specs:
308 rails (7.0.8)
309
310DEPENDENCIES
311 rails
312
313BUNDLED WITH
314 2.5.3
315";
316 std::fs::write(&lockfile_path, content).unwrap();
317
318 let parser = GemfileLockParser;
319 let packages = parser.parse_lockfile(&lockfile_path).await.unwrap();
320
321 assert_eq!(packages.len(), 1);
322 assert_eq!(packages.get_version("rails"), Some("7.0.8"));
323 }
324
325 #[test]
326 fn test_is_lockfile_stale_not_modified() {
327 let temp_dir = tempfile::tempdir().unwrap();
328 let lockfile_path = temp_dir.path().join("Gemfile.lock");
329 std::fs::write(&lockfile_path, "GEM\n specs:\n").unwrap();
330
331 let mtime = std::fs::metadata(&lockfile_path)
332 .unwrap()
333 .modified()
334 .unwrap();
335 let parser = GemfileLockParser;
336
337 assert!(
338 !parser.is_lockfile_stale(&lockfile_path, mtime),
339 "Lock file should not be stale when mtime matches"
340 );
341 }
342
343 #[test]
344 fn test_is_lockfile_stale_modified() {
345 let temp_dir = tempfile::tempdir().unwrap();
346 let lockfile_path = temp_dir.path().join("Gemfile.lock");
347 std::fs::write(&lockfile_path, "GEM\n specs:\n").unwrap();
348
349 let old_time = std::time::UNIX_EPOCH;
350 let parser = GemfileLockParser;
351
352 assert!(
353 parser.is_lockfile_stale(&lockfile_path, old_time),
354 "Lock file should be stale when last_modified is old"
355 );
356 }
357}