bundle_alphabetically 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 78e0866723d9bd83432fbb9477da3181b8159e1d72c57e128b3d1d2d208bc0d4
4
+ data.tar.gz: 5d2b084f5b292ed3fccf27313171ba00a1b57faa908c1ef617b83049ec0f0a7a
5
+ SHA512:
6
+ metadata.gz: 0bb63059900621a58af5939cb6a97cd3960e5c66b574978ff25763fa8ecc983a75b502eda3640aad95c19296e3170f6247084566f5c4d35793f1c5cb207a63c9
7
+ data.tar.gz: 80fe2cc000aec12bcfde0cff162184a8e0a16e2e1db6f3ee27f0dd7c75e4ea9a02f7ac9fddbc57e4bca5d318f5c163d9d720a97f7d2deb289954f9cd92fbf114
data/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2025-12-11
9
+
10
+ ### Added
11
+ - Initial release
12
+ - Automatic sorting after `bundle add`/`bundle install`
13
+ - Manual `bundle sort_gemfile` command
14
+ - `--check` flag for CI verification
15
+ - Sorts gems alphabetically within each group
16
+ - Preserves group structure, comments, and formatting
data/README.md ADDED
@@ -0,0 +1,95 @@
1
+ ## bundle_alphabetically
2
+
3
+ **bundle_alphabetically** is a Bundler plugin that keeps your `Gemfile` organized by alphabetizing `gem` declarations **within each group**.
4
+
5
+ - **Automatic mode**: after `bundle add` / `bundle install`, it rewrites your `Gemfile` so gems are sorted alphabetically inside each group.
6
+ - **Manual mode**: run `bundle sort_gemfile` whenever you want to clean things up (with an optional `--check` for CI).
7
+
8
+ ### Installation
9
+
10
+ Install the plugin from RubyGems:
11
+
12
+ ```bash
13
+ bundle plugin install bundle_alphabetically
14
+ ```
15
+
16
+ Or add it to your `Gemfile` and install:
17
+
18
+ ```ruby
19
+ plugin "bundle_alphabetically"
20
+ ```
21
+
22
+ ```bash
23
+ bundle install
24
+ ```
25
+
26
+ For development, you can install from a local checkout:
27
+
28
+ ```bash
29
+ cd /path/to/bundle_alphabetically
30
+ bundle plugin install bundle_alphabetically --path .
31
+ ```
32
+
33
+ Once installed, Bundler will load `plugins.rb` and register the command and hooks.
34
+
35
+ ### What it does
36
+
37
+ - Sorts `gem` entries alphabetically by name **within each context**:
38
+ - top-level (no group)
39
+ - each `group :name do ... end` block, separately
40
+ - Preserves:
41
+ - non-gem lines like `source`, `ruby`, `plugin`, `path`, etc.
42
+ - group block structure and indentation
43
+ - comments and most surrounding blank lines
44
+ - multi-line `gem` declarations (e.g. options hashes split across lines)
45
+
46
+ It operates on the current `Bundler.default_gemfile` and rewrites it in place.
47
+
48
+ ### Automatic sorting (hook)
49
+
50
+ The plugin registers an `after-install-all` hook:
51
+
52
+ - After a successful install (including `bundle add`), Bundler calls into `bundle_alphabetically`.
53
+ - The plugin sorts the `Gemfile` and prints a short message like:
54
+ - `Gemfile gems alphabetized by bundle_alphabetically`
55
+
56
+ If the `Gemfile` is already sorted, it does nothing.
57
+
58
+ If it encounters an error parsing the `Gemfile`, it raises a `Bundler::BundlerError` and reports a message via `Bundler.ui.error`, but leaves the file unchanged.
59
+
60
+ ### Manual command
61
+
62
+ The plugin adds a `bundle sort_gemfile` command via `Bundler::Plugin::API`:
63
+
64
+ ```bash
65
+ bundle sort_gemfile
66
+ ```
67
+
68
+ - Sorts the current `Gemfile` in place using the same rules as the hook.
69
+ - Prints `Gemfile already sorted` if no changes were needed.
70
+
71
+ For CI, you can use `--check` (or `-c`) to verify sorting without modifying the file:
72
+
73
+ ```bash
74
+ bundle sort_gemfile --check
75
+ ```
76
+
77
+ In `--check` mode:
78
+
79
+ - Exit successfully if the `Gemfile` is already sorted.
80
+ - Raise `Bundler::BundlerError` (non-zero exit) if changes would be required.
81
+
82
+ ### Limitations
83
+
84
+ - Designed for conventional `Gemfile`s:
85
+ - top-level `gem` calls and `group ... do` blocks
86
+ - straightforward multi-line `gem` entries
87
+ - It does **not** attempt to fully evaluate arbitrary Ruby or heavy metaprogramming inside the `Gemfile`.
88
+
89
+ If you have an unusually dynamic `Gemfile` and hit issues, you can temporarily uninstall the plugin with:
90
+
91
+ ```bash
92
+ bundler plugin uninstall bundle_alphabetically
93
+ ```
94
+
95
+
@@ -0,0 +1,71 @@
1
+ module BundleAlphabetically
2
+ module Common
3
+ def collect_gem_entry(lines, start_index, body_end_index = nil)
4
+ body_end_index ||= lines.length - 1
5
+ formatting_lines = []
6
+
7
+ while start_index <= body_end_index && start_index < lines.length && blank_or_comment?(lines[start_index])
8
+ formatting_lines << lines[start_index]
9
+ start_index += 1
10
+ end
11
+
12
+ if start_index > body_end_index || start_index >= lines.length
13
+ return [formatting_lines, [], start_index]
14
+ end
15
+
16
+ entry_lines = [lines[start_index]]
17
+ base_indent = indent_of(lines[start_index])
18
+ i = start_index + 1
19
+
20
+ while i <= body_end_index && i < lines.length
21
+ line = lines[i]
22
+ stripped = line.lstrip
23
+ indent = indent_of(line)
24
+
25
+ break if stripped.empty?
26
+
27
+ if blank_or_comment?(line) && indent <= base_indent
28
+ break
29
+ end
30
+
31
+ if indent <= base_indent && starter_keyword?(stripped)
32
+ break
33
+ end
34
+
35
+ entry_lines << line
36
+ i += 1
37
+ end
38
+
39
+ [formatting_lines, entry_lines, i]
40
+ end
41
+
42
+ def blank_or_comment?(line)
43
+ line.strip.empty? || line.strip.start_with?("#")
44
+ end
45
+
46
+ def indent_of(line)
47
+ line[/\A[ \t]*/].size
48
+ end
49
+
50
+ def starter_keyword?(stripped)
51
+ stripped.start_with?(
52
+ "gem ", "gem(", "group ", "group(", "source ", "source(", "ruby ",
53
+ "ruby(", "path ", "path(", "plugin ", "plugin(", "platforms ",
54
+ "platforms(", "end", "gemspec", "git ", "git(", "if ", "else",
55
+ "local_gemfile", "instance_eval", "rack_version"
56
+ )
57
+ end
58
+
59
+ def gem_start?(line)
60
+ return false if blank_or_comment?(line)
61
+ line.lstrip.start_with?("gem ") || line.lstrip.start_with?("gem(")
62
+ end
63
+
64
+ def extract_gem_name(line)
65
+ stripped = line.lstrip
66
+ match = stripped.match(/\Agem\s+["']([^"']+)["']/)
67
+ match && match[1]
68
+ end
69
+ end
70
+ end
71
+
@@ -0,0 +1,174 @@
1
+ require "bundler"
2
+ require "set"
3
+
4
+ require_relative "group"
5
+ require_relative "common"
6
+
7
+ module BundleAlphabetically
8
+ class GemfileSorter
9
+ extend Common
10
+
11
+ class CheckFailed < Bundler::BundlerError
12
+ def status_code
13
+ 1
14
+ end
15
+ end
16
+
17
+ class << self
18
+ attr_accessor :groups, :lines
19
+
20
+ def run!(check: false, io: $stdout)
21
+ gemfile = Bundler.default_gemfile
22
+
23
+ unless gemfile && File.file?(gemfile.to_s)
24
+ raise Bundler::BundlerError, "Gemfile not found at #{gemfile}"
25
+ end
26
+
27
+ path = gemfile.to_s
28
+ original = File.read(path)
29
+ sorted = sort_contents(original)
30
+
31
+ if original == sorted
32
+ io.puts "Gemfile already sorted" unless check
33
+ return
34
+ end
35
+
36
+ if check
37
+ raise CheckFailed, "Gemfile is not alphabetically sorted"
38
+ else
39
+ File.write(path, sorted)
40
+ io.puts "Gemfile gems alphabetized by bundle_alphabetically"
41
+ end
42
+ end
43
+
44
+ def sort_contents(contents)
45
+ @groups = []
46
+ @lines = contents.lines
47
+ return contents if lines.empty?
48
+
49
+ collect_groups!
50
+ sort_groups!
51
+
52
+ lines.join
53
+ end
54
+
55
+ private
56
+
57
+ def sort_groups!
58
+ groups.each do |group|
59
+ group.sort!
60
+ lines[group.body_start_index..group.body_end_index] = group.body if group.body
61
+ end
62
+ end
63
+
64
+ def collect_groups!
65
+ i = 0
66
+ current_group_start = nil
67
+
68
+ while i < lines.size
69
+ # Peek ahead of blanks/comments to find next group
70
+ peek_index = i
71
+ while peek_index < lines.size && blank_or_comment?(lines[peek_index])
72
+ peek_index += 1
73
+ end
74
+
75
+ if peek_index < lines.size && group_header?(lines[peek_index])
76
+
77
+ # Close current group
78
+ if current_group_start
79
+ process_gem_block(current_group_start, peek_index - 1)
80
+ current_group_start = nil
81
+ end
82
+
83
+ i = process_group_block(peek_index)
84
+ next
85
+ end
86
+
87
+ # 3. Not a group. Consume the next "entry" (gem, source, ruby, etc.)
88
+ _, entry_lines, next_index = collect_gem_entry(lines, i)
89
+
90
+ if gem_entry?(entry_lines)
91
+ current_group_start ||= i
92
+ else
93
+ # We hit something that isn't a gem and isn't a group (e.g. source, ruby version, etc.)
94
+ process_gem_block(current_group_start, i - 1) if current_group_start
95
+ current_group_start = nil
96
+ end
97
+
98
+ i = next_index
99
+ end
100
+
101
+ # Finish any trailing gem block
102
+ process_gem_block(current_group_start, lines.size - 1) if current_group_start
103
+ end
104
+
105
+ def process_group_block(header_index)
106
+ end_index = find_matching_end(lines, header_index)
107
+
108
+ # If we can't find an end, just skip this line
109
+ return header_index + 1 unless end_index
110
+
111
+ @groups << Group.new(lines, {
112
+ header: header_index,
113
+ body_start: header_index + 1,
114
+ body_end: end_index - 1,
115
+ end: end_index
116
+ })
117
+
118
+ end_index + 1
119
+ end
120
+
121
+ def group_header?(line)
122
+ stripped = line.lstrip
123
+ stripped.start_with?("group ") || stripped.start_with?("group(")
124
+ end
125
+
126
+ def find_matching_end(lines, header_index)
127
+ header_indent = indent_of(lines[header_index])
128
+ i = header_index + 1
129
+
130
+ while i < lines.length
131
+ line = lines[i]
132
+ stripped = line.lstrip
133
+ indent = indent_of(line)
134
+
135
+ if group_header?(line) && indent > header_indent
136
+ nested_end = find_matching_end(lines, i)
137
+ return nil unless nested_end
138
+ i = nested_end + 1
139
+ next
140
+ end
141
+
142
+ if stripped.start_with?("end") && indent == header_indent
143
+ return i
144
+ end
145
+
146
+ i += 1
147
+ end
148
+
149
+ nil
150
+ end
151
+
152
+ def gem_entry?(entry_lines)
153
+ entry_lines.any? && gem_start?(entry_lines.first)
154
+ end
155
+
156
+ def process_gem_block(start_index, end_index)
157
+ return unless start_index
158
+
159
+ create_and_sort_group(lines, start_index, end_index)
160
+ end
161
+
162
+ def create_and_sort_group(lines, start_index, end_index)
163
+ return if start_index >= end_index
164
+
165
+ @groups << Group.new(lines, {
166
+ header: start_index,
167
+ body_start: start_index,
168
+ body_end: end_index,
169
+ end: end_index
170
+ })
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,148 @@
1
+ require_relative "common"
2
+
3
+ class BundleAlphabetically::Group
4
+ include BundleAlphabetically::Common
5
+
6
+ attr_accessor :header_index, :body_start_index, :body_end_index, :end_index, :unsortable, :entries, :lines, :trailing_lines, :leading_lines
7
+
8
+ def initialize(lines, indices = {})
9
+ @lines = lines
10
+ @header_index = indices[:header]
11
+ @body_start_index = indices[:body_start]
12
+ @body_end_index = indices[:body_end]
13
+ @end_index = indices[:end]
14
+ @entries = []
15
+ @unsortable = false
16
+ @trailing_lines = []
17
+ @leading_lines = []
18
+ end
19
+
20
+ def sort!
21
+ return unless valid?
22
+
23
+ find_entries
24
+ sort_entries!
25
+ end
26
+
27
+ def sort_entries!
28
+ return if unsortable || entries.empty?
29
+
30
+ collect_trailing_lines
31
+ collect_leading_lines
32
+ normalize_formatting_lines
33
+
34
+ sorted_entries = entries.sort_by { |e| e[:name].downcase }
35
+
36
+ self.body = rebuild_body(sorted_entries)
37
+ end
38
+
39
+ def rebuild_body(sorted_entries)
40
+ new_body = []
41
+
42
+ leading_lines.each { |line| new_body << line } if !leading_separator_exists?
43
+
44
+ sorted_entries.each_with_index do |entry, index|
45
+ # Always move leading blanks for the first entry
46
+ move_leading_blanks = entry[:formatting_lines].empty? || index.zero?
47
+
48
+ new_body = append_entry(entry, new_body, move_leading_blanks)
49
+ end
50
+
51
+ trailing_lines.each { |line| new_body << line } if trailing_lines.any?
52
+
53
+ new_body
54
+ end
55
+
56
+ # If an entry has no comments/formatting (just a plain gem), treat blanks as trailing
57
+ # separators by placing them after the entry. This keeps "section" spacing stable when
58
+ # gems reorder.
59
+ def append_entry(entry, new_body, move_leading_blanks)
60
+ leading_blanks = entry[:leading_blanks] || []
61
+
62
+ leading_blanks.each { |line| new_body << line } unless move_leading_blanks
63
+
64
+ entry[:formatting_lines].each { |line| new_body << line }
65
+ entry[:lines].each { |line| new_body << line }
66
+
67
+ leading_blanks.each { |line| new_body << line } if move_leading_blanks
68
+
69
+ new_body
70
+ end
71
+
72
+ def normalize_formatting_lines
73
+ entries.each do |entry|
74
+ entry[:leading_blanks] = []
75
+ next unless entry[:formatting_lines]&.any?
76
+
77
+ while entry[:formatting_lines].any? && entry[:formatting_lines].first.strip.empty?
78
+ entry[:leading_blanks] << entry[:formatting_lines].shift
79
+ end
80
+ end
81
+ end
82
+
83
+ # If the group starts with blank lines (e.g. spacing after `plugin` / `gemspec`),
84
+ # keep them at the start of the group rather than attaching them to a gem
85
+ # that might move during sorting.
86
+ def collect_leading_lines
87
+ return unless entries.first[:formatting_lines]&.any?
88
+
89
+ while entries.first[:formatting_lines].any? && entries.first[:formatting_lines].first.strip.empty?
90
+ @leading_lines << entries.first[:formatting_lines].shift
91
+ end
92
+ end
93
+
94
+ # Don't sort trailing comments and blank lines
95
+ def collect_trailing_lines
96
+ return unless entries.last[:name].empty? && entries.last[:lines].empty?
97
+
98
+ @trailing_lines = entries.pop[:formatting_lines]
99
+ end
100
+
101
+ # If we're starting from the top or the prior group ended with a blank line,
102
+ # we don't need to move leading blank lines before the first entry.
103
+ def leading_separator_exists?
104
+ !body_start_index.positive? || lines[body_start_index - 1].strip.empty?
105
+ end
106
+
107
+ def find_entries
108
+ index = body_start_index
109
+
110
+ while index <= body_end_index
111
+ formatting_lines, entry_lines, after_entry_index = collect_gem_entry(lines, index, body_end_index)
112
+
113
+ break if formatting_lines.empty? && entry_lines.empty?
114
+
115
+ # If we haven't advanced, force a break to avoid infinite loops
116
+ break if after_entry_index == index
117
+
118
+ if entry_lines.any?
119
+ name = extract_gem_name(entry_lines.first)
120
+
121
+ if name
122
+ entries << { name: name, lines: entry_lines, formatting_lines: formatting_lines }
123
+ else
124
+ @unsortable = true
125
+ entries << { name: "", lines: entry_lines, formatting_lines: formatting_lines }
126
+ end
127
+ else
128
+ # Trailing formatting lines
129
+ entries << { name: "", lines: [], formatting_lines: formatting_lines }
130
+ @unsortable = true unless formatting_lines.all? { |l| l.strip.empty? }
131
+ end
132
+
133
+ index = after_entry_index
134
+ end
135
+ end
136
+
137
+ def valid?
138
+ body_end_index > body_start_index
139
+ end
140
+
141
+ def body
142
+ @body ||= lines[body_start_index..body_end_index]
143
+ end
144
+
145
+ def body=(new_lines)
146
+ @body = new_lines
147
+ end
148
+ end
@@ -0,0 +1,5 @@
1
+ module BundleAlphabetically
2
+ VERSION = "0.1.0"
3
+ end
4
+
5
+
@@ -0,0 +1,25 @@
1
+ require "bundler"
2
+ require "bundler/plugin/api"
3
+
4
+ require_relative "bundle_alphabetically/version"
5
+ require_relative "bundle_alphabetically/gemfile_sorter"
6
+ require_relative "bundle_alphabetically/group"
7
+
8
+ module BundleAlphabetically
9
+ class SortGemfileCommand < Bundler::Plugin::API
10
+ command "sort_gemfile"
11
+
12
+ def exec(_command, args)
13
+ check = args.include?("--check") || args.include?("-c")
14
+ GemfileSorter.run!(check: check)
15
+ end
16
+ end
17
+ end
18
+
19
+ Bundler::Plugin.add_hook("after-install-all") do |_dependencies|
20
+ begin
21
+ BundleAlphabetically::GemfileSorter.run!
22
+ rescue Bundler::BundlerError => e
23
+ Bundler.ui.error("bundle_alphabetically: #{e.message}")
24
+ end
25
+ end
data/plugins.rb ADDED
@@ -0,0 +1,3 @@
1
+ require_relative "lib/bundle_alphabetically"
2
+
3
+
metadata ADDED
@@ -0,0 +1,77 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bundle_alphabetically
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Elijah Rogers
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: bundler
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '2.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '2.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rspec
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '3.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '3.0'
40
+ description: A Bundler plugin that keeps your Gemfile organized by alphabetizing gem
41
+ declarations within each group automatically.
42
+ executables: []
43
+ extensions: []
44
+ extra_rdoc_files: []
45
+ files:
46
+ - CHANGELOG.md
47
+ - README.md
48
+ - lib/bundle_alphabetically.rb
49
+ - lib/bundle_alphabetically/common.rb
50
+ - lib/bundle_alphabetically/gemfile_sorter.rb
51
+ - lib/bundle_alphabetically/group.rb
52
+ - lib/bundle_alphabetically/version.rb
53
+ - plugins.rb
54
+ homepage: https://github.com/elijahrogers/bundle_alphabetically
55
+ licenses:
56
+ - MIT
57
+ metadata:
58
+ source_code_uri: https://github.com/elijahrogers/bundle_alphabetically
59
+ changelog_uri: https://github.com/elijahrogers/bundle_alphabetically/blob/main/CHANGELOG.md
60
+ rdoc_options: []
61
+ require_paths:
62
+ - lib
63
+ required_ruby_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '2.7'
68
+ required_rubygems_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '0'
73
+ requirements: []
74
+ rubygems_version: 4.0.3
75
+ specification_version: 4
76
+ summary: Bundler plugin that alphabetizes gem entries
77
+ test_files: []