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::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/// Result of parsing a Gemfile.
15#[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
23// Regex patterns for Gemfile parsing
24static 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
60/// Parses a Gemfile and extracts all dependencies with positions.
61pub 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        // Check for source declaration
74        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        // Check for ruby version
82        if let Some(caps) = RUBY_VERSION_PATTERN.captures(line) {
83            ruby_version = Some(caps[1].to_string());
84            continue;
85        }
86
87        // Check for group block start
88        if let Some(caps) = GROUP_BLOCK_START.captures(line) {
89            current_group = Some(parse_group_symbols(&caps[1]));
90            continue;
91        }
92
93        // Check for group block end
94        if GROUP_BLOCK_END.is_match(line) {
95            current_group = None;
96            continue;
97        }
98
99        // Check for gem declaration
100        if let Some(caps) = GEM_PATTERN.captures(line) {
101            let name = caps[1].to_string();
102
103            // Find name position in line
104            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            // Extract version if present
114            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            // Extract group from inline option or current block
123            let group = extract_group(rest_of_line)
124                .unwrap_or_else(|| current_group.clone().unwrap_or(DependencyGroup::Default));
125
126            // Extract source
127            let source = extract_source(rest_of_line, source_url.as_deref());
128
129            // Extract platforms
130            let platforms = extract_platforms(rest_of_line);
131
132            // Extract require option
133            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
201/// Bundler's implicit default gem source when a Gemfile declares no `source` line.
202const DEFAULT_RUBYGEMS_SOURCE: &str = "https://rubygems.org";
203
204/// Classifies a gem's dependency source.
205///
206/// `gemfile_source_url` is the Gemfile-level `source "..."` declaration (if any),
207/// captured once per file in [`parse_gemfile`] and threaded through here so a gem
208/// with no per-line `git:`/`github:`/`path:` option is classified against the
209/// source that actually resolves it, rather than defaulting to the public
210/// registry. A non-default URL (anything but rubygems.org) has no client this LSP
211/// can query, so it becomes `CustomRegistry` — mirroring Cargo's `registry = "..."`
212/// handling (#248).
213fn 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            // Parse array: [:mingw, :mswin]
247            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            // Single symbol: :ruby
255            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
275/// Parser for Gemfile manifests.
276pub 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        // rspec and pry should be in development group (development is checked first)
356        assert_matches!(result.dependencies[0].group, DependencyGroup::Development);
357        assert_matches!(result.dependencies[1].group, DependencyGroup::Development);
358
359        // rails should be default group
360        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        // Name should be on line 1 (0-indexed)
448        assert_eq!(dep.name_range.start.line, 1);
449        // Version should also be on line 1
450        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        // First source should be kept
559        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        // When array contains both :test and :development, development is checked first
656        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}