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::{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
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 = 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
63/// Parses Gemfile.lock content and extracts resolved packages.
64pub 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        // Check for section headers
75        if let Some(section) = detect_section(line) {
76            current_section = section;
77            in_specs = false;
78
79            // Reset source based on section
80            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        // Check for "specs:" marker
98        if line.trim() == "specs:" {
99            in_specs = true;
100            continue;
101        }
102
103        // Update source URL for GIT/PATH sections
104        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        // Update revision for GIT section
122        if line.starts_with("  revision:") {
123            if let ResolvedSource::Git { url, .. } = &current_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        // Parse gem specs
134        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}