Skip to main content

deps_bundler/
lockfile.rs

1//! Gemfile.lock file parsing.
2//!
3//! Parses Gemfile.lock files to extract resolved dependency versions.
4
5use 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
15/// Gemfile.lock parser.
16pub struct GemfileLockParser;
17
18impl GemfileLockParser {
19    const LOCKFILE_NAMES: &'static [&'static str] = &["Gemfile.lock"];
20}
21
22// Regex for parsing gem specs: "    gemname (version)"
23static 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
58/// Parses Gemfile.lock content and extracts resolved packages.
59pub 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        // Check for section headers
70        if let Some(section) = detect_section(line) {
71            current_section = section;
72            in_specs = false;
73
74            // Reset source based on section
75            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        // Check for "specs:" marker
93        if line.trim() == "specs:" {
94            in_specs = true;
95            continue;
96        }
97
98        // Update source URL for GIT/PATH sections
99        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        // Update revision for GIT section
117        if line.starts_with("  revision:") {
118            if let ResolvedSource::Git { url, .. } = &current_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        // Parse gem specs
129        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}