1use crate::types::{BundlerDependency, DependencyGroup, DependencySource};
7use deps_core::Result;
8use deps_core::lsp_helpers::LineOffsetTable;
9use regex::Regex;
10use std::any::Any;
11use std::sync::LazyLock;
12use tower_lsp_server::ls_types::{Range, Uri};
13
14#[derive(Debug, Clone)]
16pub struct BundlerParseResult {
17 pub dependencies: Vec<BundlerDependency>,
18 pub ruby_version: Option<String>,
19 pub source_url: Option<String>,
20 pub uri: Uri,
21}
22
23static GEM_PATTERN: LazyLock<Regex> =
25 LazyLock::new(|| Regex::new(r#"^\s*gem\s+['"]([^'"]+)['"]"#).expect("Invalid regex"));
26
27static VERSION_PATTERN: LazyLock<Regex> =
28 LazyLock::new(|| Regex::new(r#"['"]([~>=<!\d][^'"]*)['"]\s*(?:,|$)"#).expect("Invalid regex"));
29
30static SOURCE_PATTERN: LazyLock<Regex> =
31 LazyLock::new(|| Regex::new(r#"^\s*source\s+['"]([^'"]+)['"]\s*$"#).expect("Invalid regex"));
32
33static RUBY_VERSION_PATTERN: LazyLock<Regex> =
34 LazyLock::new(|| Regex::new(r#"^\s*ruby\s+['"]([^'"]+)['"]\s*$"#).expect("Invalid regex"));
35
36static GROUP_BLOCK_START: LazyLock<Regex> =
37 LazyLock::new(|| Regex::new(r"^\s*group\s+(.+?)\s+do\s*$").expect("Invalid regex"));
38
39static GROUP_BLOCK_END: LazyLock<Regex> =
40 LazyLock::new(|| Regex::new(r"^\s*end\s*$").expect("Invalid regex"));
41
42static GROUP_OPTION: LazyLock<Regex> =
43 LazyLock::new(|| Regex::new(r"group:\s*(\[.+?\]|:\w+)").expect("Invalid regex"));
44
45static GIT_OPTION: LazyLock<Regex> =
46 LazyLock::new(|| Regex::new(r#"git:\s*['"]([^'"]+)['"]\s*"#).expect("Invalid regex"));
47
48static PATH_OPTION: LazyLock<Regex> =
49 LazyLock::new(|| Regex::new(r#"path:\s*['"]([^'"]+)['"]\s*"#).expect("Invalid regex"));
50
51static GITHUB_OPTION: LazyLock<Regex> =
52 LazyLock::new(|| Regex::new(r#"github:\s*['"]([^'"]+)['"]\s*"#).expect("Invalid regex"));
53
54static REQUIRE_OPTION: LazyLock<Regex> =
55 LazyLock::new(|| Regex::new(r#"require:\s*(false|['"][^'"]*['"]\s*)"#).expect("Invalid regex"));
56
57static PLATFORMS_OPTION: LazyLock<Regex> =
58 LazyLock::new(|| Regex::new(r"platforms:\s*(\[.+?\]|:\w+)").expect("Invalid regex"));
59
60pub fn parse_gemfile(content: &str, doc_uri: &Uri) -> Result<BundlerParseResult> {
62 let line_table = LineOffsetTable::new(content);
63 let mut dependencies = Vec::new();
64 let mut ruby_version = None;
65 let mut source_url = None;
66 let mut current_group: Option<DependencyGroup> = None;
67
68 for (line_idx, line) in content.lines().enumerate() {
69 let Some(line_start) = line_table.line_start(line_idx) else {
70 continue;
71 };
72
73 if let Some(caps) = SOURCE_PATTERN.captures(line) {
75 if source_url.is_none() {
76 source_url = Some(caps[1].to_string());
77 }
78 continue;
79 }
80
81 if let Some(caps) = RUBY_VERSION_PATTERN.captures(line) {
83 ruby_version = Some(caps[1].to_string());
84 continue;
85 }
86
87 if let Some(caps) = GROUP_BLOCK_START.captures(line) {
89 current_group = Some(parse_group_symbols(&caps[1]));
90 continue;
91 }
92
93 if GROUP_BLOCK_END.is_match(line) {
95 current_group = None;
96 continue;
97 }
98
99 if let Some(caps) = GEM_PATTERN.captures(line) {
101 let name = caps[1].to_string();
102
103 let name_match = caps.get(1).unwrap();
105 let name_start = line_start + name_match.start();
106 let name_end = line_start + name_match.end();
107
108 let name_range = Range::new(
109 line_table.byte_offset_to_position(content, name_start),
110 line_table.byte_offset_to_position(content, name_end),
111 );
112
113 let rest_of_line = &line[caps.get(0).unwrap().end()..];
115 let (version_req, version_range) = extract_version(
116 rest_of_line,
117 content,
118 &line_table,
119 line_start + caps.get(0).unwrap().end(),
120 );
121
122 let group = extract_group(rest_of_line)
124 .unwrap_or_else(|| current_group.clone().unwrap_or(DependencyGroup::Default));
125
126 let source = extract_source(rest_of_line, source_url.as_deref());
128
129 let platforms = extract_platforms(rest_of_line);
131
132 let require = extract_require(rest_of_line);
134
135 dependencies.push(BundlerDependency {
136 name: name.into(),
137 name_range,
138 version_req: version_req.map(Into::into),
139 version_range,
140 group,
141 source,
142 platforms,
143 require,
144 });
145 }
146 }
147
148 Ok(BundlerParseResult {
149 dependencies,
150 ruby_version,
151 source_url,
152 uri: doc_uri.clone(),
153 })
154}
155
156fn extract_version(
157 line: &str,
158 content: &str,
159 line_table: &LineOffsetTable,
160 base_offset: usize,
161) -> (Option<String>, Option<Range>) {
162 if let Some(caps) = VERSION_PATTERN.captures(line) {
163 let version = caps[1].to_string();
164 let version_match = caps.get(1).unwrap();
165 let version_start = base_offset + version_match.start();
166 let version_end = base_offset + version_match.end();
167
168 let version_range = Range::new(
169 line_table.byte_offset_to_position(content, version_start),
170 line_table.byte_offset_to_position(content, version_end),
171 );
172
173 (Some(version), Some(version_range))
174 } else {
175 (None, None)
176 }
177}
178
179fn extract_group(line: &str) -> Option<DependencyGroup> {
180 GROUP_OPTION
181 .captures(line)
182 .map(|caps| parse_group_symbols(&caps[1]))
183}
184
185fn parse_group_symbols(text: &str) -> DependencyGroup {
186 let text = text.trim();
187
188 if text.contains(":development") {
189 DependencyGroup::Development
190 } else if text.contains(":test") {
191 DependencyGroup::Test
192 } else if text.contains(":production") {
193 DependencyGroup::Production
194 } else if text.starts_with(':') {
195 DependencyGroup::Custom(text.trim_start_matches(':').to_string())
196 } else {
197 DependencyGroup::Default
198 }
199}
200
201const DEFAULT_RUBYGEMS_SOURCE: &str = "https://rubygems.org";
203
204fn extract_source(line: &str, gemfile_source_url: Option<&str>) -> DependencySource {
214 if let Some(caps) = GIT_OPTION.captures(line) {
215 return DependencySource::Git {
216 url: caps[1].to_string(),
217 rev: None,
218 };
219 }
220
221 if let Some(caps) = GITHUB_OPTION.captures(line) {
222 return DependencySource::Git {
223 url: format!("https://github.com/{}", &caps[1]),
224 rev: None,
225 };
226 }
227
228 if let Some(caps) = PATH_OPTION.captures(line) {
229 return DependencySource::Path {
230 path: caps[1].to_string(),
231 };
232 }
233
234 match gemfile_source_url {
235 Some(url) if url != DEFAULT_RUBYGEMS_SOURCE => DependencySource::CustomRegistry {
236 url: url.to_string(),
237 },
238 _ => DependencySource::Registry,
239 }
240}
241
242fn extract_platforms(line: &str) -> Vec<String> {
243 if let Some(caps) = PLATFORMS_OPTION.captures(line) {
244 let platforms_str = &caps[1];
245 if platforms_str.starts_with('[') {
246 platforms_str
248 .trim_matches(|c| c == '[' || c == ']')
249 .split(',')
250 .map(|s| s.trim().trim_start_matches(':').to_string())
251 .filter(|s| !s.is_empty())
252 .collect()
253 } else {
254 vec![platforms_str.trim_start_matches(':').to_string()]
256 }
257 } else {
258 vec![]
259 }
260}
261
262fn extract_require(line: &str) -> Option<String> {
263 if let Some(caps) = REQUIRE_OPTION.captures(line) {
264 let value = &caps[1];
265 if value == "false" {
266 Some("false".to_string())
267 } else {
268 Some(value.trim_matches(|c| c == '\'' || c == '"').to_string())
269 }
270 } else {
271 None
272 }
273}
274
275pub struct BundlerParser;
277
278impl deps_core::ParseResult for BundlerParseResult {
279 fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
280 self.dependencies
281 .iter()
282 .map(|d| d as &dyn deps_core::Dependency)
283 .collect()
284 }
285
286 fn workspace_root(&self) -> Option<&std::path::Path> {
287 None
288 }
289
290 fn uri(&self) -> &Uri {
291 &self.uri
292 }
293
294 fn as_any(&self) -> &dyn Any {
295 self
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 use std::assert_matches;
304
305 fn test_uri() -> Uri {
306 #[cfg(windows)]
307 let path = "C:/test/Gemfile";
308 #[cfg(not(windows))]
309 let path = "/test/Gemfile";
310 Uri::from_file_path(path).unwrap()
311 }
312
313 #[test]
314 fn test_parse_simple_gem() {
315 let gemfile = r"source 'https://rubygems.org'
316gem 'rails'";
317 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
318 assert_eq!(result.dependencies.len(), 1);
319 assert_eq!(result.dependencies[0].name, "rails");
320 assert_eq!(result.dependencies[0].version_req, None);
321 }
322
323 #[test]
324 fn test_parse_gem_with_version() {
325 let gemfile = r"source 'https://rubygems.org'
326gem 'rails', '~> 7.0'";
327 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
328 assert_eq!(result.dependencies.len(), 1);
329 assert_eq!(result.dependencies[0].name, "rails");
330 assert_eq!(result.dependencies[0].version_req, Some("~> 7.0".into()));
331 }
332
333 #[test]
334 fn test_parse_gem_with_group() {
335 let gemfile = r"source 'https://rubygems.org'
336gem 'rspec', group: :test";
337 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
338 assert_eq!(result.dependencies.len(), 1);
339 assert_matches!(result.dependencies[0].group, DependencyGroup::Test);
340 }
341
342 #[test]
343 fn test_parse_group_block() {
344 let gemfile = r"source 'https://rubygems.org'
345
346group :development, :test do
347 gem 'rspec'
348 gem 'pry'
349end
350
351gem 'rails'";
352 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
353 assert_eq!(result.dependencies.len(), 3);
354
355 assert_matches!(result.dependencies[0].group, DependencyGroup::Development);
357 assert_matches!(result.dependencies[1].group, DependencyGroup::Development);
358
359 assert_matches!(result.dependencies[2].group, DependencyGroup::Default);
361 }
362
363 #[test]
364 fn test_parse_git_source() {
365 let gemfile = r"source 'https://rubygems.org'
366gem 'rails', git: 'https://github.com/rails/rails.git'";
367 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
368 assert_matches!(result.dependencies[0].source, DependencySource::Git { .. });
369 }
370
371 #[test]
372 fn test_parse_github_source() {
373 let gemfile = r"source 'https://rubygems.org'
374gem 'rails', github: 'rails/rails'";
375 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
376 match &result.dependencies[0].source {
377 DependencySource::Git { url, .. } => {
378 assert!(url.contains("github.com/rails/rails"));
379 }
380 _ => panic!("Expected Git source"),
381 }
382 }
383
384 #[test]
385 fn test_parse_path_source() {
386 let gemfile = r"source 'https://rubygems.org'
387gem 'local_gem', path: '../local_gem'";
388 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
389 assert_matches!(result.dependencies[0].source, DependencySource::Path { .. });
390 }
391
392 #[test]
393 fn test_parse_ruby_version() {
394 let gemfile = r"source 'https://rubygems.org'
395ruby '3.2.2'
396gem 'rails'";
397 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
398 assert_eq!(result.ruby_version, Some("3.2.2".into()));
399 }
400
401 #[test]
402 fn test_parse_source_url() {
403 let gemfile = r"source 'https://rubygems.org'
404gem 'rails'";
405 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
406 assert_eq!(result.source_url, Some("https://rubygems.org".into()));
407 }
408
409 #[test]
410 fn test_default_rubygems_source_classified_as_registry() {
411 let gemfile = r"source 'https://rubygems.org'
412gem 'rails'";
413 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
414 assert_eq!(result.dependencies[0].source, DependencySource::Registry);
415 assert!(result.dependencies[0].source.is_version_resolvable());
416 }
417
418 #[test]
419 fn test_custom_gemfile_source_classified_as_custom_registry() {
420 let gemfile = r"source 'https://gems.mycorp.com'
421gem 'internal-gem'";
422 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
423 match &result.dependencies[0].source {
424 DependencySource::CustomRegistry { url } => {
425 assert_eq!(url, "https://gems.mycorp.com");
426 }
427 other => panic!("expected CustomRegistry, got {other:?}"),
428 }
429 assert!(!result.dependencies[0].source.is_version_resolvable());
430 }
431
432 #[test]
433 fn test_custom_gemfile_source_does_not_override_explicit_git_source() {
434 let gemfile = r"source 'https://gems.mycorp.com'
435gem 'rails', git: 'https://github.com/rails/rails.git'";
436 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
437 assert_matches!(result.dependencies[0].source, DependencySource::Git { .. });
438 }
439
440 #[test]
441 fn test_position_tracking() {
442 let gemfile = r"source 'https://rubygems.org'
443gem 'rails', '~> 7.0'";
444 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
445 let dep = &result.dependencies[0];
446
447 assert_eq!(dep.name_range.start.line, 1);
449 assert!(dep.version_range.is_some());
451 assert_eq!(dep.version_range.unwrap().start.line, 1);
452 }
453
454 #[test]
455 fn test_parse_platforms() {
456 let gemfile = r"source 'https://rubygems.org'
457gem 'tzinfo-data', platforms: [:mingw, :mswin]";
458 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
459 assert_eq!(result.dependencies[0].platforms, vec!["mingw", "mswin"]);
460 }
461
462 #[test]
463 fn test_parse_require_false() {
464 let gemfile = r"source 'https://rubygems.org'
465gem 'puma', require: false";
466 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
467 assert_eq!(result.dependencies[0].require, Some("false".into()));
468 }
469
470 #[test]
471 fn test_empty_gemfile() {
472 let gemfile = "";
473 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
474 assert_eq!(result.dependencies.len(), 0);
475 }
476
477 #[test]
478 fn test_gemfile_with_comments() {
479 let gemfile = r"source 'https://rubygems.org'
480# This is a comment
481gem 'rails'
482# gem 'disabled'";
483 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
484 assert_eq!(result.dependencies.len(), 1);
485 assert_eq!(result.dependencies[0].name, "rails");
486 }
487
488 #[test]
489 fn test_parse_production_group() {
490 let gemfile = r"source 'https://rubygems.org'
491gem 'unicorn', group: :production";
492 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
493 assert_matches!(result.dependencies[0].group, DependencyGroup::Production);
494 }
495
496 #[test]
497 fn test_parse_development_group() {
498 let gemfile = r"source 'https://rubygems.org'
499gem 'pry', group: :development";
500 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
501 assert_matches!(result.dependencies[0].group, DependencyGroup::Development);
502 }
503
504 #[test]
505 fn test_parse_custom_group() {
506 let gemfile = r"source 'https://rubygems.org'
507gem 'sidekiq', group: :staging";
508 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
509 if let DependencyGroup::Custom(name) = &result.dependencies[0].group {
510 assert_eq!(name, "staging");
511 } else {
512 panic!("Expected custom group");
513 }
514 }
515
516 #[test]
517 fn test_parse_group_block_test() {
518 let gemfile = r"source 'https://rubygems.org'
519group :test do
520 gem 'minitest'
521end";
522 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
523 assert_matches!(result.dependencies[0].group, DependencyGroup::Test);
524 }
525
526 #[test]
527 fn test_parse_group_block_production() {
528 let gemfile = r"source 'https://rubygems.org'
529group :production do
530 gem 'newrelic_rpm'
531end";
532 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
533 assert_matches!(result.dependencies[0].group, DependencyGroup::Production);
534 }
535
536 #[test]
537 fn test_parse_single_platform() {
538 let gemfile = r"source 'https://rubygems.org'
539gem 'wdm', platforms: :mswin";
540 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
541 assert_eq!(result.dependencies[0].platforms, vec!["mswin"]);
542 }
543
544 #[test]
545 fn test_parse_require_custom_path() {
546 let gemfile = r"source 'https://rubygems.org'
547gem 'my_gem', require: 'custom/path'";
548 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
549 assert_eq!(result.dependencies[0].require, Some("custom/path".into()));
550 }
551
552 #[test]
553 fn test_parse_multiple_sources() {
554 let gemfile = r"source 'https://rubygems.org'
555source 'https://gems.example.com'
556gem 'rails'";
557 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
558 assert_eq!(result.source_url, Some("https://rubygems.org".into()));
560 }
561
562 #[test]
563 fn test_parse_double_quoted_strings() {
564 let gemfile = r#"source "https://rubygems.org"
565gem "rails", "~> 7.0""#;
566 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
567 assert_eq!(result.dependencies.len(), 1);
568 assert_eq!(result.dependencies[0].name, "rails");
569 assert_eq!(result.source_url, Some("https://rubygems.org".into()));
570 }
571
572 #[test]
573 fn test_parse_gem_with_multiple_options() {
574 let gemfile = r"source 'https://rubygems.org'
575gem 'sidekiq', '~> 7.0', require: false, group: :production";
576 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
577 assert_eq!(result.dependencies[0].name, "sidekiq");
578 assert_eq!(result.dependencies[0].version_req, Some("~> 7.0".into()));
579 assert_eq!(result.dependencies[0].require, Some("false".into()));
580 assert_matches!(result.dependencies[0].group, DependencyGroup::Production);
581 }
582
583 #[test]
584 fn test_parse_nested_group_blocks() {
585 let gemfile = r"source 'https://rubygems.org'
586group :development do
587 gem 'pry'
588end
589group :test do
590 gem 'rspec'
591end
592gem 'rails'";
593 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
594 assert_eq!(result.dependencies.len(), 3);
595 assert_matches!(result.dependencies[0].group, DependencyGroup::Development);
596 assert_matches!(result.dependencies[1].group, DependencyGroup::Test);
597 assert_matches!(result.dependencies[2].group, DependencyGroup::Default);
598 }
599
600 #[test]
601 fn test_parse_result_trait() {
602 use deps_core::ParseResult;
603
604 let gemfile = r"source 'https://rubygems.org'
605gem 'rails', '~> 7.0'";
606 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
607
608 assert_eq!(result.dependencies().len(), 1);
609 assert!(result.workspace_root().is_none());
610 assert!(result.as_any().is::<BundlerParseResult>());
611 }
612
613 #[test]
614 fn test_parse_version_operators() {
615 let gemfile = r"source 'https://rubygems.org'
616gem 'gem1', '>= 1.0'
617gem 'gem2', '> 2.0'
618gem 'gem3', '<= 3.0'
619gem 'gem4', '< 4.0'
620gem 'gem5', '!= 5.0'";
621
622 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
623 assert_eq!(result.dependencies.len(), 5);
624 assert_eq!(result.dependencies[0].version_req, Some(">= 1.0".into()));
625 assert_eq!(result.dependencies[1].version_req, Some("> 2.0".into()));
626 assert_eq!(result.dependencies[2].version_req, Some("<= 3.0".into()));
627 assert_eq!(result.dependencies[3].version_req, Some("< 4.0".into()));
628 assert_eq!(result.dependencies[4].version_req, Some("!= 5.0".into()));
629 }
630
631 #[test]
632 fn test_parse_exact_version() {
633 let gemfile = r"source 'https://rubygems.org'
634gem 'rails', '7.0.8'";
635 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
636 assert_eq!(result.dependencies[0].version_req, Some("7.0.8".into()));
637 }
638
639 #[test]
640 fn test_parse_result_uri() {
641 use deps_core::ParseResult;
642
643 let uri = test_uri();
644 let gemfile = r"source 'https://rubygems.org'";
645 let result = parse_gemfile(gemfile, &uri).unwrap();
646
647 assert_eq!(result.uri(), &uri);
648 }
649
650 #[test]
651 fn test_group_array_syntax() {
652 let gemfile = r"source 'https://rubygems.org'
653gem 'rspec', group: [:test, :development]";
654 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
655 assert_matches!(result.dependencies[0].group, DependencyGroup::Development);
657 }
658
659 #[test]
660 fn test_whitespace_handling() {
661 let gemfile = "source 'https://rubygems.org'\n gem 'rails' ";
662 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
663 assert_eq!(result.dependencies.len(), 1);
664 assert_eq!(result.dependencies[0].name, "rails");
665 }
666
667 #[test]
668 fn test_gem_without_source() {
669 let gemfile = "gem 'rails'";
670 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
671 assert_eq!(result.dependencies.len(), 1);
672 assert!(result.source_url.is_none());
673 }
674
675 #[test]
676 fn test_unicode_in_content() {
677 let gemfile = "source 'https://rubygems.org'\n# UTF-8: \u{1F600}\ngem 'rails'";
678 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
679 assert_eq!(result.dependencies.len(), 1);
680 }
681}