1use crate::error::Result;
7use crate::types::{BundlerDependency, DependencyGroup, DependencySource};
8use regex::Regex;
9use std::any::Any;
10use std::sync::LazyLock;
11use tower_lsp_server::ls_types::{Position, Range, Uri};
12
13#[derive(Debug, Clone)]
15pub struct BundlerParseResult {
16 pub dependencies: Vec<BundlerDependency>,
17 pub ruby_version: Option<String>,
18 pub source_url: Option<String>,
19 pub uri: Uri,
20}
21
22struct LineOffsetTable {
24 line_starts: Vec<usize>,
25}
26
27impl LineOffsetTable {
28 fn new(content: &str) -> Self {
29 let mut line_starts = vec![0];
30 for (i, c) in content.char_indices() {
31 if c == '\n' {
32 line_starts.push(i + 1);
33 }
34 }
35 Self { line_starts }
36 }
37
38 fn byte_offset_to_position(&self, content: &str, offset: usize) -> Position {
39 let line = self
40 .line_starts
41 .partition_point(|&start| start <= offset)
42 .saturating_sub(1);
43 let line_start = self.line_starts[line];
44
45 let character = content[line_start..offset]
46 .chars()
47 .map(|c| c.len_utf16() as u32)
48 .sum();
49
50 Position::new(line as u32, character)
51 }
52}
53
54static GEM_PATTERN: LazyLock<Regex> =
56 LazyLock::new(|| Regex::new(r#"^\s*gem\s+['"]([^'"]+)['"]"#).expect("Invalid regex"));
57
58static VERSION_PATTERN: LazyLock<Regex> =
59 LazyLock::new(|| Regex::new(r#"['"]([~>=<!\d][^'"]*)['"]\s*(?:,|$)"#).expect("Invalid regex"));
60
61static SOURCE_PATTERN: LazyLock<Regex> =
62 LazyLock::new(|| Regex::new(r#"^\s*source\s+['"]([^'"]+)['"]\s*$"#).expect("Invalid regex"));
63
64static RUBY_VERSION_PATTERN: LazyLock<Regex> =
65 LazyLock::new(|| Regex::new(r#"^\s*ruby\s+['"]([^'"]+)['"]\s*$"#).expect("Invalid regex"));
66
67static GROUP_BLOCK_START: LazyLock<Regex> =
68 LazyLock::new(|| Regex::new(r"^\s*group\s+(.+?)\s+do\s*$").expect("Invalid regex"));
69
70static GROUP_BLOCK_END: LazyLock<Regex> =
71 LazyLock::new(|| Regex::new(r"^\s*end\s*$").expect("Invalid regex"));
72
73static GROUP_OPTION: LazyLock<Regex> =
74 LazyLock::new(|| Regex::new(r"group:\s*(\[.+?\]|:\w+)").expect("Invalid regex"));
75
76static GIT_OPTION: LazyLock<Regex> =
77 LazyLock::new(|| Regex::new(r#"git:\s*['"]([^'"]+)['"]\s*"#).expect("Invalid regex"));
78
79static PATH_OPTION: LazyLock<Regex> =
80 LazyLock::new(|| Regex::new(r#"path:\s*['"]([^'"]+)['"]\s*"#).expect("Invalid regex"));
81
82static GITHUB_OPTION: LazyLock<Regex> =
83 LazyLock::new(|| Regex::new(r#"github:\s*['"]([^'"]+)['"]\s*"#).expect("Invalid regex"));
84
85static REQUIRE_OPTION: LazyLock<Regex> =
86 LazyLock::new(|| Regex::new(r#"require:\s*(false|['"][^'"]*['"]\s*)"#).expect("Invalid regex"));
87
88static PLATFORMS_OPTION: LazyLock<Regex> =
89 LazyLock::new(|| Regex::new(r"platforms:\s*(\[.+?\]|:\w+)").expect("Invalid regex"));
90
91pub fn parse_gemfile(content: &str, doc_uri: &Uri) -> Result<BundlerParseResult> {
93 let line_table = LineOffsetTable::new(content);
94 let mut dependencies = Vec::new();
95 let mut ruby_version = None;
96 let mut source_url = None;
97 let mut current_group: Option<DependencyGroup> = None;
98
99 for (line_idx, line) in content.lines().enumerate() {
100 let line_start = line_table.line_starts[line_idx];
101
102 if let Some(caps) = SOURCE_PATTERN.captures(line) {
104 if source_url.is_none() {
105 source_url = Some(caps[1].to_string());
106 }
107 continue;
108 }
109
110 if let Some(caps) = RUBY_VERSION_PATTERN.captures(line) {
112 ruby_version = Some(caps[1].to_string());
113 continue;
114 }
115
116 if let Some(caps) = GROUP_BLOCK_START.captures(line) {
118 current_group = Some(parse_group_symbols(&caps[1]));
119 continue;
120 }
121
122 if GROUP_BLOCK_END.is_match(line) {
124 current_group = None;
125 continue;
126 }
127
128 if let Some(caps) = GEM_PATTERN.captures(line) {
130 let name = caps[1].to_string();
131
132 let name_match = caps.get(1).unwrap();
134 let name_start = line_start + name_match.start();
135 let name_end = line_start + name_match.end();
136
137 let name_range = Range::new(
138 line_table.byte_offset_to_position(content, name_start),
139 line_table.byte_offset_to_position(content, name_end),
140 );
141
142 let rest_of_line = &line[caps.get(0).unwrap().end()..];
144 let (version_req, version_range) = extract_version(
145 rest_of_line,
146 content,
147 &line_table,
148 line_start + caps.get(0).unwrap().end(),
149 );
150
151 let group = extract_group(rest_of_line)
153 .unwrap_or_else(|| current_group.clone().unwrap_or(DependencyGroup::Default));
154
155 let source = extract_source(rest_of_line);
157
158 let platforms = extract_platforms(rest_of_line);
160
161 let require = extract_require(rest_of_line);
163
164 dependencies.push(BundlerDependency {
165 name,
166 name_range,
167 version_req,
168 version_range,
169 group,
170 source,
171 platforms,
172 require,
173 });
174 }
175 }
176
177 Ok(BundlerParseResult {
178 dependencies,
179 ruby_version,
180 source_url,
181 uri: doc_uri.clone(),
182 })
183}
184
185fn extract_version(
186 line: &str,
187 content: &str,
188 line_table: &LineOffsetTable,
189 base_offset: usize,
190) -> (Option<String>, Option<Range>) {
191 if let Some(caps) = VERSION_PATTERN.captures(line) {
192 let version = caps[1].to_string();
193 let version_match = caps.get(1).unwrap();
194 let version_start = base_offset + version_match.start();
195 let version_end = base_offset + version_match.end();
196
197 let version_range = Range::new(
198 line_table.byte_offset_to_position(content, version_start),
199 line_table.byte_offset_to_position(content, version_end),
200 );
201
202 (Some(version), Some(version_range))
203 } else {
204 (None, None)
205 }
206}
207
208fn extract_group(line: &str) -> Option<DependencyGroup> {
209 GROUP_OPTION
210 .captures(line)
211 .map(|caps| parse_group_symbols(&caps[1]))
212}
213
214fn parse_group_symbols(text: &str) -> DependencyGroup {
215 let text = text.trim();
216
217 if text.contains(":development") {
218 DependencyGroup::Development
219 } else if text.contains(":test") {
220 DependencyGroup::Test
221 } else if text.contains(":production") {
222 DependencyGroup::Production
223 } else if text.starts_with(':') {
224 DependencyGroup::Custom(text.trim_start_matches(':').to_string())
225 } else {
226 DependencyGroup::Default
227 }
228}
229
230fn extract_source(line: &str) -> DependencySource {
231 if let Some(caps) = GIT_OPTION.captures(line) {
232 return DependencySource::Git {
233 url: caps[1].to_string(),
234 rev: None,
235 };
236 }
237
238 if let Some(caps) = GITHUB_OPTION.captures(line) {
239 return DependencySource::Git {
240 url: format!("https://github.com/{}", &caps[1]),
241 rev: None,
242 };
243 }
244
245 if let Some(caps) = PATH_OPTION.captures(line) {
246 return DependencySource::Path {
247 path: caps[1].to_string(),
248 };
249 }
250
251 DependencySource::Registry
252}
253
254fn extract_platforms(line: &str) -> Vec<String> {
255 if let Some(caps) = PLATFORMS_OPTION.captures(line) {
256 let platforms_str = &caps[1];
257 if platforms_str.starts_with('[') {
258 platforms_str
260 .trim_matches(|c| c == '[' || c == ']')
261 .split(',')
262 .map(|s| s.trim().trim_start_matches(':').to_string())
263 .filter(|s| !s.is_empty())
264 .collect()
265 } else {
266 vec![platforms_str.trim_start_matches(':').to_string()]
268 }
269 } else {
270 vec![]
271 }
272}
273
274fn extract_require(line: &str) -> Option<String> {
275 if let Some(caps) = REQUIRE_OPTION.captures(line) {
276 let value = &caps[1];
277 if value == "false" {
278 Some("false".to_string())
279 } else {
280 Some(value.trim_matches(|c| c == '\'' || c == '"').to_string())
281 }
282 } else {
283 None
284 }
285}
286
287pub struct BundlerParser;
289
290impl deps_core::ManifestParser for BundlerParser {
291 type Dependency = BundlerDependency;
292 type ParseResult = BundlerParseResult;
293
294 fn parse(&self, content: &str, doc_uri: &Uri) -> deps_core::Result<Self::ParseResult> {
295 parse_gemfile(content, doc_uri).map_err(Into::into)
296 }
297}
298
299impl deps_core::ParseResultInfo for BundlerParseResult {
300 type Dependency = BundlerDependency;
301
302 fn dependencies(&self) -> &[Self::Dependency] {
303 &self.dependencies
304 }
305
306 fn workspace_root(&self) -> Option<&std::path::Path> {
307 None
308 }
309}
310
311impl deps_core::ParseResult for BundlerParseResult {
312 fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
313 self.dependencies
314 .iter()
315 .map(|d| d as &dyn deps_core::Dependency)
316 .collect()
317 }
318
319 fn workspace_root(&self) -> Option<&std::path::Path> {
320 None
321 }
322
323 fn uri(&self) -> &Uri {
324 &self.uri
325 }
326
327 fn as_any(&self) -> &dyn Any {
328 self
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 fn test_uri() -> Uri {
337 #[cfg(windows)]
338 let path = "C:/test/Gemfile";
339 #[cfg(not(windows))]
340 let path = "/test/Gemfile";
341 Uri::from_file_path(path).unwrap()
342 }
343
344 #[test]
345 fn test_parse_simple_gem() {
346 let gemfile = r"source 'https://rubygems.org'
347gem 'rails'";
348 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
349 assert_eq!(result.dependencies.len(), 1);
350 assert_eq!(result.dependencies[0].name, "rails");
351 assert_eq!(result.dependencies[0].version_req, None);
352 }
353
354 #[test]
355 fn test_parse_gem_with_version() {
356 let gemfile = r"source 'https://rubygems.org'
357gem 'rails', '~> 7.0'";
358 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
359 assert_eq!(result.dependencies.len(), 1);
360 assert_eq!(result.dependencies[0].name, "rails");
361 assert_eq!(result.dependencies[0].version_req, Some("~> 7.0".into()));
362 }
363
364 #[test]
365 fn test_parse_gem_with_group() {
366 let gemfile = r"source 'https://rubygems.org'
367gem 'rspec', group: :test";
368 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
369 assert_eq!(result.dependencies.len(), 1);
370 assert!(matches!(
371 result.dependencies[0].group,
372 DependencyGroup::Test
373 ));
374 }
375
376 #[test]
377 fn test_parse_group_block() {
378 let gemfile = r"source 'https://rubygems.org'
379
380group :development, :test do
381 gem 'rspec'
382 gem 'pry'
383end
384
385gem 'rails'";
386 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
387 assert_eq!(result.dependencies.len(), 3);
388
389 assert!(matches!(
391 result.dependencies[0].group,
392 DependencyGroup::Development
393 ));
394 assert!(matches!(
395 result.dependencies[1].group,
396 DependencyGroup::Development
397 ));
398
399 assert!(matches!(
401 result.dependencies[2].group,
402 DependencyGroup::Default
403 ));
404 }
405
406 #[test]
407 fn test_parse_git_source() {
408 let gemfile = r"source 'https://rubygems.org'
409gem 'rails', git: 'https://github.com/rails/rails.git'";
410 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
411 assert!(matches!(
412 result.dependencies[0].source,
413 DependencySource::Git { .. }
414 ));
415 }
416
417 #[test]
418 fn test_parse_github_source() {
419 let gemfile = r"source 'https://rubygems.org'
420gem 'rails', github: 'rails/rails'";
421 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
422 match &result.dependencies[0].source {
423 DependencySource::Git { url, .. } => {
424 assert!(url.contains("github.com/rails/rails"));
425 }
426 _ => panic!("Expected Git source"),
427 }
428 }
429
430 #[test]
431 fn test_parse_path_source() {
432 let gemfile = r"source 'https://rubygems.org'
433gem 'local_gem', path: '../local_gem'";
434 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
435 assert!(matches!(
436 result.dependencies[0].source,
437 DependencySource::Path { .. }
438 ));
439 }
440
441 #[test]
442 fn test_parse_ruby_version() {
443 let gemfile = r"source 'https://rubygems.org'
444ruby '3.2.2'
445gem 'rails'";
446 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
447 assert_eq!(result.ruby_version, Some("3.2.2".into()));
448 }
449
450 #[test]
451 fn test_parse_source_url() {
452 let gemfile = r"source 'https://rubygems.org'
453gem 'rails'";
454 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
455 assert_eq!(result.source_url, Some("https://rubygems.org".into()));
456 }
457
458 #[test]
459 fn test_position_tracking() {
460 let gemfile = r"source 'https://rubygems.org'
461gem 'rails', '~> 7.0'";
462 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
463 let dep = &result.dependencies[0];
464
465 assert_eq!(dep.name_range.start.line, 1);
467 assert!(dep.version_range.is_some());
469 assert_eq!(dep.version_range.unwrap().start.line, 1);
470 }
471
472 #[test]
473 fn test_parse_platforms() {
474 let gemfile = r"source 'https://rubygems.org'
475gem 'tzinfo-data', platforms: [:mingw, :mswin]";
476 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
477 assert_eq!(result.dependencies[0].platforms, vec!["mingw", "mswin"]);
478 }
479
480 #[test]
481 fn test_parse_require_false() {
482 let gemfile = r"source 'https://rubygems.org'
483gem 'puma', require: false";
484 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
485 assert_eq!(result.dependencies[0].require, Some("false".into()));
486 }
487
488 #[test]
489 fn test_empty_gemfile() {
490 let gemfile = "";
491 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
492 assert_eq!(result.dependencies.len(), 0);
493 }
494
495 #[test]
496 fn test_gemfile_with_comments() {
497 let gemfile = r"source 'https://rubygems.org'
498# This is a comment
499gem 'rails'
500# gem 'disabled'";
501 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
502 assert_eq!(result.dependencies.len(), 1);
503 assert_eq!(result.dependencies[0].name, "rails");
504 }
505
506 #[test]
507 fn test_line_offset_table() {
508 let content = "abc\ndef";
509 let table = LineOffsetTable::new(content);
510 let pos = table.byte_offset_to_position(content, 4);
511 assert_eq!(pos.line, 1);
512 assert_eq!(pos.character, 0);
513 }
514
515 #[test]
516 fn test_parse_production_group() {
517 let gemfile = r"source 'https://rubygems.org'
518gem 'unicorn', group: :production";
519 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
520 assert!(matches!(
521 result.dependencies[0].group,
522 DependencyGroup::Production
523 ));
524 }
525
526 #[test]
527 fn test_parse_development_group() {
528 let gemfile = r"source 'https://rubygems.org'
529gem 'pry', group: :development";
530 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
531 assert!(matches!(
532 result.dependencies[0].group,
533 DependencyGroup::Development
534 ));
535 }
536
537 #[test]
538 fn test_parse_custom_group() {
539 let gemfile = r"source 'https://rubygems.org'
540gem 'sidekiq', group: :staging";
541 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
542 if let DependencyGroup::Custom(name) = &result.dependencies[0].group {
543 assert_eq!(name, "staging");
544 } else {
545 panic!("Expected custom group");
546 }
547 }
548
549 #[test]
550 fn test_parse_group_block_test() {
551 let gemfile = r"source 'https://rubygems.org'
552group :test do
553 gem 'minitest'
554end";
555 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
556 assert!(matches!(
557 result.dependencies[0].group,
558 DependencyGroup::Test
559 ));
560 }
561
562 #[test]
563 fn test_parse_group_block_production() {
564 let gemfile = r"source 'https://rubygems.org'
565group :production do
566 gem 'newrelic_rpm'
567end";
568 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
569 assert!(matches!(
570 result.dependencies[0].group,
571 DependencyGroup::Production
572 ));
573 }
574
575 #[test]
576 fn test_parse_single_platform() {
577 let gemfile = r"source 'https://rubygems.org'
578gem 'wdm', platforms: :mswin";
579 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
580 assert_eq!(result.dependencies[0].platforms, vec!["mswin"]);
581 }
582
583 #[test]
584 fn test_parse_require_custom_path() {
585 let gemfile = r"source 'https://rubygems.org'
586gem 'my_gem', require: 'custom/path'";
587 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
588 assert_eq!(result.dependencies[0].require, Some("custom/path".into()));
589 }
590
591 #[test]
592 fn test_parse_multiple_sources() {
593 let gemfile = r"source 'https://rubygems.org'
594source 'https://gems.example.com'
595gem 'rails'";
596 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
597 assert_eq!(result.source_url, Some("https://rubygems.org".into()));
599 }
600
601 #[test]
602 fn test_parse_double_quoted_strings() {
603 let gemfile = r#"source "https://rubygems.org"
604gem "rails", "~> 7.0""#;
605 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
606 assert_eq!(result.dependencies.len(), 1);
607 assert_eq!(result.dependencies[0].name, "rails");
608 assert_eq!(result.source_url, Some("https://rubygems.org".into()));
609 }
610
611 #[test]
612 fn test_parse_gem_with_multiple_options() {
613 let gemfile = r"source 'https://rubygems.org'
614gem 'sidekiq', '~> 7.0', require: false, group: :production";
615 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
616 assert_eq!(result.dependencies[0].name, "sidekiq");
617 assert_eq!(result.dependencies[0].version_req, Some("~> 7.0".into()));
618 assert_eq!(result.dependencies[0].require, Some("false".into()));
619 assert!(matches!(
620 result.dependencies[0].group,
621 DependencyGroup::Production
622 ));
623 }
624
625 #[test]
626 fn test_parse_nested_group_blocks() {
627 let gemfile = r"source 'https://rubygems.org'
628group :development do
629 gem 'pry'
630end
631group :test do
632 gem 'rspec'
633end
634gem 'rails'";
635 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
636 assert_eq!(result.dependencies.len(), 3);
637 assert!(matches!(
638 result.dependencies[0].group,
639 DependencyGroup::Development
640 ));
641 assert!(matches!(
642 result.dependencies[1].group,
643 DependencyGroup::Test
644 ));
645 assert!(matches!(
646 result.dependencies[2].group,
647 DependencyGroup::Default
648 ));
649 }
650
651 #[test]
652 fn test_line_offset_table_empty() {
653 let content = "";
654 let table = LineOffsetTable::new(content);
655 assert_eq!(table.line_starts.len(), 1);
656 assert_eq!(table.line_starts[0], 0);
657 }
658
659 #[test]
660 fn test_line_offset_table_single_line() {
661 let content = "hello world";
662 let table = LineOffsetTable::new(content);
663 assert_eq!(table.line_starts.len(), 1);
664 let pos = table.byte_offset_to_position(content, 6);
665 assert_eq!(pos.line, 0);
666 assert_eq!(pos.character, 6);
667 }
668
669 #[test]
670 fn test_line_offset_table_multiple_lines() {
671 let content = "line1\nline2\nline3";
672 let table = LineOffsetTable::new(content);
673 assert_eq!(table.line_starts.len(), 3);
674
675 let pos = table.byte_offset_to_position(content, 6);
677 assert_eq!(pos.line, 1);
678 assert_eq!(pos.character, 0);
679
680 let pos = table.byte_offset_to_position(content, 12);
682 assert_eq!(pos.line, 2);
683 assert_eq!(pos.character, 0);
684 }
685
686 #[test]
687 fn test_parse_result_trait() {
688 use deps_core::ParseResult;
689
690 let gemfile = r"source 'https://rubygems.org'
691gem 'rails', '~> 7.0'";
692 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
693
694 assert_eq!(result.dependencies().len(), 1);
695 assert!(result.workspace_root().is_none());
696 assert!(result.as_any().is::<BundlerParseResult>());
697 }
698
699 #[test]
700 fn test_parse_result_info_trait() {
701 use deps_core::ParseResultInfo;
702
703 let gemfile = r"source 'https://rubygems.org'
704gem 'rails'";
705 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
706
707 assert_eq!(result.dependencies().len(), 1);
708 assert!(result.workspace_root().is_none());
709 }
710
711 #[test]
712 fn test_bundler_parser_trait() {
713 use deps_core::ManifestParser;
714
715 let parser = BundlerParser;
716 let gemfile = r"source 'https://rubygems.org'
717gem 'rails'";
718 let result = parser.parse(gemfile, &test_uri()).unwrap();
719 assert_eq!(result.dependencies.len(), 1);
720 }
721
722 #[test]
723 fn test_parse_version_operators() {
724 let gemfile = r"source 'https://rubygems.org'
725gem 'gem1', '>= 1.0'
726gem 'gem2', '> 2.0'
727gem 'gem3', '<= 3.0'
728gem 'gem4', '< 4.0'
729gem 'gem5', '!= 5.0'";
730
731 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
732 assert_eq!(result.dependencies.len(), 5);
733 assert_eq!(result.dependencies[0].version_req, Some(">= 1.0".into()));
734 assert_eq!(result.dependencies[1].version_req, Some("> 2.0".into()));
735 assert_eq!(result.dependencies[2].version_req, Some("<= 3.0".into()));
736 assert_eq!(result.dependencies[3].version_req, Some("< 4.0".into()));
737 assert_eq!(result.dependencies[4].version_req, Some("!= 5.0".into()));
738 }
739
740 #[test]
741 fn test_parse_exact_version() {
742 let gemfile = r"source 'https://rubygems.org'
743gem 'rails', '7.0.8'";
744 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
745 assert_eq!(result.dependencies[0].version_req, Some("7.0.8".into()));
746 }
747
748 #[test]
749 fn test_parse_result_uri() {
750 use deps_core::ParseResult;
751
752 let uri = test_uri();
753 let gemfile = r"source 'https://rubygems.org'";
754 let result = parse_gemfile(gemfile, &uri).unwrap();
755
756 assert_eq!(result.uri(), &uri);
757 }
758
759 #[test]
760 fn test_group_array_syntax() {
761 let gemfile = r"source 'https://rubygems.org'
762gem 'rspec', group: [:test, :development]";
763 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
764 assert!(matches!(
766 result.dependencies[0].group,
767 DependencyGroup::Development
768 ));
769 }
770
771 #[test]
772 fn test_whitespace_handling() {
773 let gemfile = "source 'https://rubygems.org'\n gem 'rails' ";
774 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
775 assert_eq!(result.dependencies.len(), 1);
776 assert_eq!(result.dependencies[0].name, "rails");
777 }
778
779 #[test]
780 fn test_gem_without_source() {
781 let gemfile = "gem 'rails'";
782 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
783 assert_eq!(result.dependencies.len(), 1);
784 assert!(result.source_url.is_none());
785 }
786
787 #[test]
788 fn test_unicode_in_content() {
789 let gemfile = "source 'https://rubygems.org'\n# UTF-8: \u{1F600}\ngem 'rails'";
790 let result = parse_gemfile(gemfile, &test_uri()).unwrap();
791 assert_eq!(result.dependencies.len(), 1);
792 }
793
794 #[test]
795 fn test_line_offset_unicode() {
796 let content = "abc\n\u{1F600}def";
797 let table = LineOffsetTable::new(content);
798 let pos = table.byte_offset_to_position(content, 4);
800 assert_eq!(pos.line, 1);
801 assert_eq!(pos.character, 0);
802 }
803}