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