Skip to main content

deps_bundler/
parser.rs

1//! Gemfile DSL parser with position tracking.
2//!
3//! Parses Gemfile files using regex-based line parsing to extract dependencies
4//! with precise LSP positions.
5
6use 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/// Result of parsing a Gemfile.
14#[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
22/// Pre-computed line start byte offsets for O(1) position lookups.
23struct 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
54// Regex patterns for Gemfile parsing
55static 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
91/// Parses a Gemfile and extracts all dependencies with positions.
92pub 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        // Check for source declaration
103        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        // Check for ruby version
111        if let Some(caps) = RUBY_VERSION_PATTERN.captures(line) {
112            ruby_version = Some(caps[1].to_string());
113            continue;
114        }
115
116        // Check for group block start
117        if let Some(caps) = GROUP_BLOCK_START.captures(line) {
118            current_group = Some(parse_group_symbols(&caps[1]));
119            continue;
120        }
121
122        // Check for group block end
123        if GROUP_BLOCK_END.is_match(line) {
124            current_group = None;
125            continue;
126        }
127
128        // Check for gem declaration
129        if let Some(caps) = GEM_PATTERN.captures(line) {
130            let name = caps[1].to_string();
131
132            // Find name position in line
133            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            // Extract version if present
143            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            // Extract group from inline option or current block
152            let group = extract_group(rest_of_line)
153                .unwrap_or_else(|| current_group.clone().unwrap_or(DependencyGroup::Default));
154
155            // Extract source
156            let source = extract_source(rest_of_line);
157
158            // Extract platforms
159            let platforms = extract_platforms(rest_of_line);
160
161            // Extract require option
162            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            // Parse array: [:mingw, :mswin]
259            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            // Single symbol: :ruby
267            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
287/// Parser for Gemfile manifests.
288pub 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        // rspec and pry should be in development group (development is checked first)
390        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        // rails should be default group
400        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        // Name should be on line 1 (0-indexed)
466        assert_eq!(dep.name_range.start.line, 1);
467        // Version should also be on line 1
468        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        // First source should be kept
598        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        // First char of line 2
676        let pos = table.byte_offset_to_position(content, 6);
677        assert_eq!(pos.line, 1);
678        assert_eq!(pos.character, 0);
679
680        // First char of line 3
681        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        // When array contains both :test and :development, development is checked first
765        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        // The emoji takes 4 bytes in UTF-8
799        let pos = table.byte_offset_to_position(content, 4);
800        assert_eq!(pos.line, 1);
801        assert_eq!(pos.character, 0);
802    }
803}