Skip to content

Commit a120bcc

Browse files
authored
Merge pull request #17 from contriboss/feat/hash-rocket
feat: ✨ support hash 🚀 syntax in gemfiles and gemspecs
2 parents ce110dc + 5a483eb commit a120bcc

File tree

3 files changed

+298
-118
lines changed

3 files changed

+298
-118
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## [Unreleased]
4+
5+
### Bug Fixes
6+
7+
* support hash rocket syntax for gem and gemspec options
8+
* improve group parsing to handle comments and trailing tokens correctly
9+
* improve version constraint extraction to correctly handle hash rocket and symbolized hash options
10+
311
## [0.8.0](https://github.com/contriboss/gemfile-go/compare/v0.7.3...v0.8.0) (2026-01-22)
412

513

gemfile/parser.go

Lines changed: 98 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -389,14 +389,37 @@ func (p *GemfileParser) parseSource(line string) (Source, bool, error) {
389389
// parseGroups parses group declarations
390390
// Examples: group :development, :test do
391391
func (p *GemfileParser) parseGroups(line string) []string {
392-
// Extract group names using regex
393-
re := regexp.MustCompile(`:(\w+)`)
392+
// Strip inline comments first
393+
if idx := strings.Index(line, "#"); idx != -1 {
394+
line = line[:idx]
395+
}
396+
397+
// Remove the 'group' keyword
398+
line = strings.TrimSpace(line)
399+
line = strings.TrimPrefix(line, "group ")
400+
401+
// Trim everything after the 'do' token (not just suffix)
402+
if idx := strings.Index(line, " do"); idx != -1 {
403+
line = line[:idx]
404+
} else if idx := strings.Index(line, "do"); idx != -1 {
405+
// Handle cases like 'group(:dev)do' - although rare in Gemfiles
406+
// Check if 'do' is a separate word or at least not part of a symbol/string
407+
// For simplicity, we can check if it's preceded by space or closing paren/quote
408+
line = line[:idx]
409+
}
410+
411+
// Extract group names using a more precise regex
412+
// Supports symbols like :test or strings like "test"
413+
// match :(\w+) and ['"](\w+)['"]
414+
re := regexp.MustCompile(`:(\w+)|['"](\w+)['"]`)
394415
matches := re.FindAllStringSubmatch(line, -1)
395416

396417
groups := make([]string, 0, len(matches))
397418
for _, match := range matches {
398-
if len(match) > 1 {
419+
if len(match) > 1 && match[1] != "" {
399420
groups = append(groups, match[1])
421+
} else if len(match) > 2 && match[2] != "" {
422+
groups = append(groups, match[2])
400423
}
401424
}
402425

@@ -461,35 +484,16 @@ func (p *GemfileParser) extractVersionConstraints(line string) []string {
461484
nameRe := regexp.MustCompile(`gem\s+['"][^'"]+['"],?\s*`)
462485
remaining := nameRe.ReplaceAllString(line, "")
463486

464-
// Pattern to match version strings (not including options like require:, github:, etc.)
465-
// Stop at first option keyword
466-
optionKeys := []string{
467-
"require:",
468-
"github:",
469-
"git:",
470-
"path:",
471-
"groups:",
472-
"group:",
473-
"platforms:",
474-
"platform:",
475-
"source:",
476-
}
477-
478-
optionsStart := -1
479-
for _, key := range optionKeys {
480-
if idx := strings.Index(remaining, key); idx != -1 && (optionsStart == -1 || idx < optionsStart) {
481-
optionsStart = idx
482-
}
483-
}
484-
485-
versionPart := remaining
486-
if optionsStart != -1 {
487-
versionPart = remaining[:optionsStart]
487+
// Stop at first option keyword or hash rocket
488+
// Supports both symbolized hashes and hash rockets
489+
optionRe := regexp.MustCompile(`(?::\w+\s*=>|[\w:]+:)`)
490+
if loc := optionRe.FindStringIndex(remaining); loc != nil {
491+
remaining = remaining[:loc[0]]
488492
}
489493

490494
// Extract all quoted strings from the version part
491495
re := regexp.MustCompile(`['"]([^'"]+)['"]`)
492-
matches := re.FindAllStringSubmatch(versionPart, -1)
496+
matches := re.FindAllStringSubmatch(remaining, -1)
493497

494498
constraints := make([]string, 0, len(matches))
495499
for _, match := range matches {
@@ -503,8 +507,8 @@ func (p *GemfileParser) extractVersionConstraints(line string) []string {
503507

504508
// extractSource extracts git/path source information
505509
func (p *GemfileParser) extractSource(line string) *Source {
506-
// Check for github source: github: 'user/repo'
507-
if githubRe := regexp.MustCompile(`github:\s*['"]([^'"]+)['"]`); githubRe.MatchString(line) {
510+
// Check for github source: github: 'user/repo' or :github => 'user/repo'
511+
if githubRe := regexp.MustCompile(`(?::github\s*=>|github:)\s*['"]([^'"]+)['"]`); githubRe.MatchString(line) {
508512
matches := githubRe.FindStringSubmatch(line)
509513
if len(matches) > 1 {
510514
source := &Source{
@@ -513,7 +517,7 @@ func (p *GemfileParser) extractSource(line string) *Source {
513517
}
514518

515519
// Extract branch/tag/ref
516-
if branchRe := regexp.MustCompile(`branch:\s*['"]([^'"]+)['"]`); branchRe.MatchString(line) {
520+
if branchRe := regexp.MustCompile(`(?::branch\s*=>|branch:)\s*['"]([^'"]+)['"]`); branchRe.MatchString(line) {
517521
branchMatches := branchRe.FindStringSubmatch(line)
518522
if len(branchMatches) > 1 {
519523
source.Branch = branchMatches[1]
@@ -524,19 +528,29 @@ func (p *GemfileParser) extractSource(line string) *Source {
524528
}
525529
}
526530

527-
// Check for git source: git: 'https://...'
528-
if gitRe := regexp.MustCompile(`git:\s*['"]([^'"]+)['"]`); gitRe.MatchString(line) {
531+
// Check for git source: git: 'https://...' or :git => 'https://...'
532+
if gitRe := regexp.MustCompile(`(?::git\s*=>|git:)\s*['"]([^'"]+)['"]`); gitRe.MatchString(line) {
529533
matches := gitRe.FindStringSubmatch(line)
530534
if len(matches) > 1 {
531-
return &Source{
535+
source := &Source{
532536
Type: "git",
533537
URL: matches[1],
534538
}
539+
540+
// Extract branch/tag/ref for generic git source too
541+
if branchRe := regexp.MustCompile(`(?::branch\s*=>|branch:)\s*['"]([^'"]+)['"]`); branchRe.MatchString(line) {
542+
branchMatches := branchRe.FindStringSubmatch(line)
543+
if len(branchMatches) > 1 {
544+
source.Branch = branchMatches[1]
545+
}
546+
}
547+
548+
return source
535549
}
536550
}
537551

538-
// Check for path source: path: 'local/path'
539-
if pathRe := regexp.MustCompile(`path:\s*['"]([^'"]+)['"]`); pathRe.MatchString(line) {
552+
// Check for path source: path: 'local/path' or :path => 'local/path'
553+
if pathRe := regexp.MustCompile(`(?::path\s*=>|path:)\s*['"]([^'"]+)['"]`); pathRe.MatchString(line) {
540554
matches := pathRe.FindStringSubmatch(line)
541555
if len(matches) > 1 {
542556
return &Source{
@@ -546,8 +560,8 @@ func (p *GemfileParser) extractSource(line string) *Source {
546560
}
547561
}
548562

549-
// Check for inline rubygems source: source: 'https://...'
550-
if sourceRe := regexp.MustCompile(`source:\s*['"]([^'"]+)['"]`); sourceRe.MatchString(line) {
563+
// Check for inline rubygems source: source: 'https://...' or :source => 'https://...'
564+
if sourceRe := regexp.MustCompile(`(?::source\s*=>|source:)\s*['"]([^'"]+)['"]`); sourceRe.MatchString(line) {
551565
matches := sourceRe.FindStringSubmatch(line)
552566
if len(matches) > 1 {
553567
return &Source{
@@ -562,8 +576,8 @@ func (p *GemfileParser) extractSource(line string) *Source {
562576

563577
// extractRequire extracts require option
564578
func (p *GemfileParser) extractRequire(line string) *string {
565-
// require: false
566-
if requireRe := regexp.MustCompile(`require:\s*(false|['"][^'"]*['"])`); requireRe.MatchString(line) {
579+
// require: false or :require => false or require: 'foo' or :require => 'foo'
580+
if requireRe := regexp.MustCompile(`(?::require\s*=>|require:)\s*(false|['"][^'"]*['"])`); requireRe.MatchString(line) {
567581
matches := requireRe.FindStringSubmatch(line)
568582
if len(matches) > 1 {
569583
require := matches[1]
@@ -582,55 +596,38 @@ func (p *GemfileParser) extractRequire(line string) *string {
582596

583597
// extractGroupOverrides extracts group overrides from gem line
584598
func (p *GemfileParser) extractGroupOverrides(line string) []string {
585-
// groups: [:development, :test]
586-
if groupsRe := regexp.MustCompile(`groups?:\s*\[([^\]]+)\]`); groupsRe.MatchString(line) {
587-
matches := groupsRe.FindStringSubmatch(line)
588-
if len(matches) > 1 {
589-
groupStr := matches[1]
590-
groupRe := regexp.MustCompile(`:(\w+)`)
591-
groupMatches := groupRe.FindAllStringSubmatch(groupStr, -1)
592-
593-
groups := make([]string, 0, len(groupMatches))
594-
for _, match := range groupMatches {
595-
if len(match) > 1 {
596-
groups = append(groups, match[1])
597-
}
598-
}
599-
return groups
600-
}
601-
}
602-
603-
return nil
599+
// groups: [:development, :test] or :groups => [:development, :test]
600+
pattern := `(?::groups?\s*=>|groups?:\s*)\s*\[([^\]]+)\]`
601+
return p.extractArrayFromOption(line, pattern)
604602
}
605603

606604
// extractPlatforms extracts platform restrictions from gem line
607605
func (p *GemfileParser) extractPlatforms(line string) []string {
608-
// platforms: [:windows_31, :jruby]
609-
if platformsRe := regexp.MustCompile(`platforms?:\s*\[([^\]]+)\]`); platformsRe.MatchString(line) {
610-
matches := platformsRe.FindStringSubmatch(line)
611-
if len(matches) > 1 {
612-
platformStr := matches[1]
613-
platformRe := regexp.MustCompile(`:(\w+)`)
614-
platformMatches := platformRe.FindAllStringSubmatch(platformStr, -1)
615-
616-
platforms := make([]string, 0, len(platformMatches))
617-
for _, match := range platformMatches {
618-
if len(match) > 1 {
619-
platforms = append(platforms, match[1])
620-
}
621-
}
622-
return platforms
623-
}
624-
}
606+
// platforms: [:windows_31, :jruby] or :platforms => [:windows_31, :jruby]
607+
pattern := `(?::platforms?\s*=>|platforms?:\s*)\s*\[([^\]]+)\]`
608+
return p.extractArrayFromOption(line, pattern)
609+
}
625610

626-
// platforms: :jruby (single platform)
627-
if platformRe := regexp.MustCompile(`platforms?:\s*:(\w+)`); platformRe.MatchString(line) {
628-
matches := platformRe.FindStringSubmatch(line)
629-
if len(matches) > 1 {
630-
return []string{matches[1]}
611+
// extractArrayFromOption extracts an array of symbols/strings from a line using the given pattern
612+
func (p *GemfileParser) extractArrayFromOption(line, optionPattern string) []string {
613+
re := regexp.MustCompile(optionPattern)
614+
matches := re.FindStringSubmatch(line)
615+
if len(matches) > 1 {
616+
content := matches[1]
617+
// Match :symbol or "string" or 'string'
618+
itemRe := regexp.MustCompile(`:(\w+)|['"](\w+)['"]`)
619+
itemMatches := itemRe.FindAllStringSubmatch(content, -1)
620+
621+
items := make([]string, 0, len(itemMatches))
622+
for _, match := range itemMatches {
623+
if len(match) > 1 && match[1] != "" {
624+
items = append(items, match[1])
625+
} else if len(match) > 2 && match[2] != "" {
626+
items = append(items, match[2])
627+
}
631628
}
629+
return items
632630
}
633-
634631
return nil
635632
}
636633

@@ -645,13 +642,6 @@ func (p *GemfileParser) parseRubyVersion(line string) string {
645642
}
646643

647644
// parseGemspecDirective parses gemspec directive
648-
// Examples:
649-
//
650-
// gemspec
651-
// gemspec path: "components/payment"
652-
// gemspec name: "payment_core"
653-
// gemspec development_group: :ci
654-
// gemspec path: ".", name: "my_gem", development_group: :test
655645
func (p *GemfileParser) parseGemspecDirective(line string) *GemspecReference {
656646
gemspecRef := &GemspecReference{
657647
Path: ".",
@@ -664,39 +654,29 @@ func (p *GemfileParser) parseGemspecDirective(line string) *GemspecReference {
664654
return gemspecRef
665655
}
666656

667-
// Parse path option
668-
if pathRe := regexp.MustCompile(`path:\s*['"]([^'"]+)['"]`); pathRe.MatchString(line) {
669-
matches := pathRe.FindStringSubmatch(line)
670-
if len(matches) > 1 {
671-
gemspecRef.Path = matches[1]
672-
}
673-
}
657+
// Parse various options
658+
p.parseGemspecOption(line, "path", &gemspecRef.Path)
659+
p.parseGemspecOption(line, "name", &gemspecRef.Name)
660+
p.parseGemspecOption(line, "development_group", &gemspecRef.DevelopmentGroup)
661+
p.parseGemspecOption(line, "glob", &gemspecRef.Glob)
674662

675-
// Parse name option
676-
if nameRe := regexp.MustCompile(`name:\s*['"]([^'"]+)['"]`); nameRe.MatchString(line) {
677-
matches := nameRe.FindStringSubmatch(line)
678-
if len(matches) > 1 {
679-
gemspecRef.Name = matches[1]
680-
}
681-
}
682-
683-
// Parse development_group option
684-
if devGroupRe := regexp.MustCompile(`development_group:\s*:(\w+)`); devGroupRe.MatchString(line) {
685-
matches := devGroupRe.FindStringSubmatch(line)
686-
if len(matches) > 1 {
687-
gemspecRef.DevelopmentGroup = matches[1]
688-
}
689-
}
663+
return gemspecRef
664+
}
690665

691-
// Parse glob option
692-
if globRe := regexp.MustCompile(`glob:\s*['"]([^'"]+)['"]`); globRe.MatchString(line) {
693-
matches := globRe.FindStringSubmatch(line)
694-
if len(matches) > 1 {
695-
gemspecRef.Glob = matches[1]
666+
// parseGemspecOption parses a single option from a gemspec directive
667+
func (p *GemfileParser) parseGemspecOption(line, optionName string, target *string) {
668+
// Pattern for both symbol and keyword syntax:
669+
// :option => "value", :option => :value, option: "value", option: :value
670+
pattern := fmt.Sprintf(`(?::%s\s*=>|%s:)\s*(?:['"]([^'"]+)['"]|:?(\w+))`, optionName, optionName)
671+
re := regexp.MustCompile(pattern)
672+
matches := re.FindStringSubmatch(line)
673+
if len(matches) > 1 {
674+
if matches[1] != "" {
675+
*target = matches[1]
676+
} else if len(matches) > 2 && matches[2] != "" {
677+
*target = matches[2]
696678
}
697679
}
698-
699-
return gemspecRef
700680
}
701681

702682
// parseVariable parses variable assignments like: rails_version = '~> 8.0.1'

0 commit comments

Comments
 (0)