rebundler 0.3.1 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3a699f86063d76f1777e967eb5aa993fe2d65c355812dbd50ec4a599653bac37
4
- data.tar.gz: b1724cdc2493ef5745b23acac611c4a820fce04afaff0b0e7983085a291639e1
3
+ metadata.gz: f39f0bba7935159299bb736312beb6b0452d8997bb1f6c5bf574747b08d05af3
4
+ data.tar.gz: f8920192f0a6bf48ba31f21f67f8cba06d1a682e00196a89750aed089bb392bb
5
5
  SHA512:
6
- metadata.gz: a8be976be4c69cba77b1d4a031adfefb52917637693f6ebd2f64307df602d397904f1c1f38321ac0b7c1c8225374550da3e79b8242b37ac9311b09334da3daf6
7
- data.tar.gz: f7eb7094cabef8b94a49a82e5ad7d295da07e8d66a91d6b77bd669c6f04176003acf3594acb058b80258a5632aece21492c88283cb186668b10aa1131c49a316
6
+ metadata.gz: 8a1144be379a959ed76e26635daf82a9e535dc8e8ac416f2509206c95f9b6673b349f62ed80260f7703e83781cf657d20ef7e0e5ba3b6ef732e7404c6e4cb543
7
+ data.tar.gz: fa7d2a0423b94382e778ac048defebfe6301d994d860f1040f1cac509e9f617903234ea2eea1f2c5400f8d020183d14bd370a8617287fe0c1203a474f039d024
data/.rubocop.yml CHANGED
@@ -23,3 +23,6 @@ Style/StringConcatenation:
23
23
 
24
24
  Layout/LineLength:
25
25
  Enabled: false
26
+
27
+ Lint/ItWithoutArgumentsInBlock:
28
+ Enabled: false
data/CHANGELOG.md CHANGED
@@ -1,4 +1,15 @@
1
- ## [Unreleased]
1
+ ## [0.5.0] - 2026-03-13
2
+
3
+ - Major internal refactor and clean up.
4
+
5
+ You can now instantiate the parser via `Rebundler::Parser.from_file(path)` or directly
6
+ from a string via `Rebundler::Parser.from_string(content)`.
7
+
8
+ ## [0.4.0] - 2025-12-21
9
+
10
+ - **Breaking** Rebundler now leaves existing trailing comments intact.
11
+
12
+ You can still overwrite them (the previous behavior) by using the `--force` flag.
2
13
 
3
14
  ## [0.3.1] - 2025-12-19
4
15
 
data/README.md CHANGED
@@ -18,7 +18,6 @@ This is a real life example from my own project. That looks a lot better, doesn'
18
18
 
19
19
  ## Known limitations
20
20
 
21
- - **Existing comments will be lost.** At this moment Rebundler does not persist existing comments.
22
21
  - **Probably does not work with all possible Gemfile configurations.** It is designed to work with the most common setups right now. If you encounter an issue, please open an issue on GitHub. I strive to support each sensible configuration.
23
22
 
24
23
  ## Installation
@@ -57,6 +56,8 @@ This does **not** load the plugin and means you have to run rebundler yourself b
57
56
 
58
57
  ```sh
59
58
  $ bundle exec rebundle
59
+ Reordering and annotating Gemfile...
60
+ ✓ Gemfile has been reordered and annotated
60
61
  ```
61
62
 
62
63
  ### 3. CI mode
@@ -68,6 +69,47 @@ If there are differences, rebundler will exit with a non-zero status code.
68
69
 
69
70
  This does not write to the Gemfile.
70
71
 
72
+ ### Options
73
+
74
+ ### `--force`
75
+
76
+ By default, Rebundler preserves existing trailing comments on `gem` defining lines. If you want to overwrite them anyway, use the `--force` flag:
77
+
78
+ ```sh
79
+ $ bundle exec rebundle --force
80
+ ```
81
+
82
+ ## Interface
83
+
84
+ You can also use Rebundler directly in Ruby rather than via the CLI.
85
+
86
+ ### `Parser.from_file(path)`
87
+
88
+ Parses a Gemfile at the given path and returns a `Parser` instance.
89
+
90
+ ```ruby
91
+ parser = Rebundler::Parser.from_file("/path/to/Gemfile")
92
+ ```
93
+
94
+ ### `Parser.from_string(content)`
95
+
96
+ Parses a Gemfile from a string and returns a `Parser` instance.
97
+
98
+ ```ruby
99
+ content = File.read("/path/to/Gemfile")
100
+ parser = Rebundler::Parser.from_string(content)
101
+ ```
102
+
103
+ ### `parser.format(overwrite_comments: false)`
104
+
105
+ Returns the formatted Gemfile content as a string. Does not write to disk.
106
+
107
+ Pass `overwrite_comments: true` to replace existing trailing comments on `gem` lines (equivalent to the `--force` CLI flag):
108
+
109
+ ```ruby
110
+ new_content = parser.format(overwrite_comments: true)
111
+ ```
112
+
71
113
  ## Development
72
114
 
73
115
  After checking out the repo, run `bundle install` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
data/exe/rebundle CHANGED
@@ -5,33 +5,35 @@ require_relative "../lib/rebundler"
5
5
 
6
6
  # Parse command line arguments
7
7
  ci_mode = ARGV.include?("--ci")
8
+ overwrite_comments = ARGV.include?("--force")
8
9
  file = ARGV.reject { |arg| arg.start_with?("--") }[0] || (Dir.pwd + "/Gemfile")
9
10
 
10
- puts "Reordering and annotating Gemfile..."
11
-
12
11
  begin
13
12
  # Load all gems in the Gemfile so we can use them to grab the summary instead of
14
13
  # having to hit rubygems.org
15
14
  Bundler.setup
16
- parser = Rebundler::Parser.new(file)
17
- new_content = parser.parse_and_write!
15
+ parser = Rebundler::Parser.from_file(file)
16
+ new_content = parser.format(overwrite_comments:)
18
17
 
19
18
  if ci_mode
20
19
  # In CI mode, compare the new content with the existing file
20
+ puts "Checking if Gemfile is properly formatted..."
21
21
  current_content = File.read(file)
22
22
 
23
23
  if new_content == current_content
24
- puts "✓ Gemfile is already properly formatted"
24
+ puts "\e[32m✓ Gemfile is properly formatted\e[0m"
25
25
  exit 0
26
26
  else
27
- puts "✗ Gemfile is not properly formatted"
28
- puts "Run 'rebundle' to fix formatting issues"
27
+ puts "\e[31m✗ Gemfile is not properly formatted\e[0m"
28
+ puts "Run `bundle exec rebundle` to fix formatting issues"
29
29
  exit 1
30
30
  end
31
31
  else
32
32
  # In normal mode, write the new content to the file
33
+ puts "Reordering and annotating Gemfile..."
33
34
  File.write(file, new_content)
34
- puts "✓ Gemfile has been reordered and annotated"
35
+ puts "\e[32m✓ Gemfile has been reordered and annotated\e[0m"
36
+
35
37
  end
36
38
  rescue StandardError => e
37
39
  puts "Error parsing Gemfile: #{e.message}"
@@ -25,8 +25,8 @@ module Bundler
25
25
  # having to hit rubygems.org
26
26
  Bundler.setup
27
27
 
28
- parser = ::Rebundler::Parser.new(file)
29
- content = parser.parse_and_write!
28
+ parser = ::Rebundler::Parser.from_file(file)
29
+ content = parser.format
30
30
 
31
31
  # Use atomic write to prevent corruption if write fails
32
32
  Tempfile.create("Gemfile", Bundler.root.to_s) do |tmp|
@@ -3,31 +3,36 @@
3
3
  require "forwardable"
4
4
 
5
5
  module Rebundler
6
- class Writer
6
+ class Formatter
7
7
  extend Forwardable
8
8
 
9
- def_delegators :@parser, :preamble_nodes, :gem_sets, :frozen_string_literal
9
+ def_delegators :@parser, :directives, :gem_sets, :frozen_string_literal, :comments
10
10
 
11
11
  def initialize(parser)
12
12
  @parser = parser
13
13
  end
14
14
 
15
- def write!
15
+ def format(overwrite_comments: false)
16
16
  buffer = []
17
17
 
18
18
  buffer << "# frozen_string_literal: true" if frozen_string_literal
19
19
 
20
- preamble_nodes.each do |node|
20
+ directives.each do |node|
21
21
  buffer << Serializer.node_to_s(node)
22
22
  end
23
23
 
24
24
  gem_sets.sort.each do |set|
25
25
  gem_content = [set.plugins, set.gems].map do |nodes|
26
- sorted = nodes.sort_by { |node| node[:name].tr("-_", "").downcase }
27
-
28
- sorted.map do |gem|
29
- line = +Serializer.node_to_s(gem[:node])
30
- line << " # #{gem[:summary]}" if gem[:summary]
26
+ nodes.sort.map do |gem|
27
+ existing_comment = Serializer.extract_comment(gem.node, comments)
28
+ comment = if existing_comment && !overwrite_comments
29
+ existing_comment
30
+ else
31
+ gem.summary
32
+ end
33
+
34
+ line = +Serializer.node_to_s(gem.node)
35
+ line << " # #{comment}" if comment
31
36
  line
32
37
  end.join("\n")
33
38
  end.reject(&:empty?).join("\n\n")
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rebundler
4
+ GemDeclaration = Data.define(:name, :summary, :node) do
5
+ def <=>(other)
6
+ raise ArgumentError, "comparison of GemDeclaration with #{other.class} failed" unless other.is_a?(GemDeclaration)
7
+
8
+ normalized_name <=> other.normalized_name
9
+ end
10
+
11
+ def normalized_name
12
+ name.tr("-_", "").downcase
13
+ end
14
+ end
15
+ end
@@ -16,14 +16,14 @@ module Rebundler
16
16
  end
17
17
 
18
18
  def <=>(other)
19
- return nil unless other.is_a?(GemSet)
19
+ raise ArgumentError, "comparison of GemSet with #{other.class} failed" unless other.is_a?(GemSet)
20
20
 
21
21
  # Default first, then sort by name
22
- [default ? 0 : 1, name] <=> [other.default ? 0 : 1, other.name]
22
+ [default ? 0 : 1, name.to_s] <=> [other.default ? 0 : 1, other.name.to_s]
23
23
  end
24
24
 
25
25
  def ==(other)
26
- other.is_a?(GemSet) && other.name == name
26
+ other.is_a?(GemSet) && other.name == name && other.default == default
27
27
  end
28
28
  end
29
29
  end
@@ -4,41 +4,44 @@ require "prism"
4
4
 
5
5
  module Rebundler
6
6
  class Parser
7
- attr_reader :file, :preamble_nodes, :gem_sets, :frozen_string_literal
7
+ attr_reader :content, :frozen_string_literal, :directives, :gem_sets, :comments
8
8
 
9
- def initialize(file)
10
- @file = file
11
- @frozen_string_literal = false
12
- @preamble_nodes = []
13
- @gem_sets = [GemSet.new(default: true)] # all gems outside a specific block (group, source, etc) will end up here
14
- end
9
+ def self.from_file(path)
10
+ raise Rebundler::Error, "File not found: #{path}" unless File.exist?(path)
15
11
 
16
- def build_set(name:, node: nil)
17
- GemSet.new(name:, node:).tap do |set|
18
- @gem_sets << set
19
- end
12
+ new(File.read(path))
20
13
  end
21
14
 
22
- def parse!
23
- return if @parsed
24
-
25
- parsed = Prism.parse(File.read(file))
15
+ def self.from_string(content)
16
+ new(content)
17
+ end
26
18
 
27
- @frozen_string_literal = parsed.magic_comments.map(&:key).include?("frozen_string_literal")
19
+ def format(overwrite_comments: false)
20
+ Formatter.new(self).format(overwrite_comments:)
21
+ end
28
22
 
29
- visitor = Visitor.new(self)
30
- parsed.value.accept(visitor)
23
+ private_class_method :new
31
24
 
32
- @parsed = true
33
- end
25
+ def initialize(content)
26
+ @content = content
27
+ @frozen_string_literal = false
28
+ @directives = []
29
+ @gem_sets = [GemSet.new(default: true)]
30
+ @comments = []
34
31
 
35
- def parse_and_write!
36
32
  parse!
37
- Writer.new(self).write!
38
33
  end
39
34
 
40
- def find_or_build_set(name:, node: nil)
41
- gem_sets.find { |set| set.name == name } || build_set(name:, node:)
35
+ private
36
+
37
+ def parse!
38
+ parsed = Prism.parse(@content)
39
+
40
+ @frozen_string_literal = parsed.magic_comments.map(&:key).include?("frozen_string_literal")
41
+ @comments = parsed.comments
42
+
43
+ visitor = Visitor.new(self)
44
+ parsed.value.accept(visitor)
42
45
  end
43
46
  end
44
47
  end
@@ -2,6 +2,22 @@
2
2
 
3
3
  module Rebundler
4
4
  class Serializer
5
+ def self.extract_comment(node, comments)
6
+ # Find comments that are on the same line as the node
7
+ node_line = node.location.end_line
8
+
9
+ trailing_comment = comments.find do |comment|
10
+ comment.location.start_line == node_line &&
11
+ comment.location.start_offset > node.location.end_offset
12
+ end
13
+
14
+ return nil unless trailing_comment
15
+
16
+ # Get the comment text (without the # prefix)
17
+ comment_text = trailing_comment.location.slice.sub(/^#\s*/, "").strip
18
+ comment_text.empty? ? nil : comment_text
19
+ end
20
+
5
21
  def self.node_to_s(node)
6
22
  lines = node.location.slice.lines
7
23
 
@@ -24,8 +40,10 @@ module Rebundler
24
40
  end
25
41
 
26
42
  def self.find_spacing_character(node)
27
- # Assume it's formatted with tabs if there is ANY tab in the whole block of code.
28
- node.location.slice.include?("\t") ? "\t" : " "
43
+ # Assume it's formatted with tabs if ANY line starts with a tab.
44
+ any_line_starts_with_tab = node.location.slice.split("\n").any? { it.start_with?("\t") }
45
+
46
+ any_line_starts_with_tab ? "\t" : " "
29
47
  end
30
48
  end
31
49
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module Rebundler
6
+ class Summarizer
7
+ class << self
8
+ def summarize(name)
9
+ find_loaded_gem_summary(name) || find_external_gem_summary(name)
10
+ end
11
+
12
+ private
13
+
14
+ def find_loaded_gem_summary(name)
15
+ gem = Gem::Specification.find_by_name(name)
16
+
17
+ gem.summary
18
+ rescue Gem::MissingSpecError
19
+ nil
20
+ end
21
+
22
+ def find_external_gem_summary(name)
23
+ spec = Gem::SpecFetcher.fetcher.spec_for_dependency Gem::Dependency.new(name)
24
+
25
+ return if spec.nil? || spec.first.empty?
26
+
27
+ spec.first.first.first.summary
28
+ rescue SocketError, Errno::ECONNREFUSED, Errno::ETIMEDOUT, Timeout::Error => e
29
+ raise Rebundler::Error, "Network error while fetching gem info for '#{name}': #{e.message}"
30
+ end
31
+ end
32
+ end
33
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rebundler
4
- VERSION = "0.3.1"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -11,26 +11,43 @@ module Rebundler
11
11
  def visit_call_node(node)
12
12
  case node.name
13
13
  when :plugin
14
- parsed_gem = GemFetcher.parse_gem(node)
15
- @current_set.plugins << parsed_gem if parsed_gem
14
+ @current_set.plugins << parse_gem_with_comment(node)
16
15
  when :gem
17
- parsed_gem = GemFetcher.parse_gem(node)
18
- @current_set.gems << parsed_gem if parsed_gem
16
+ @current_set.gems << parse_gem_with_comment(node)
19
17
  when :git_source
20
- @parser.preamble_nodes << node
21
- when *BLOCK_NODES
18
+ @parser.directives << node
19
+ when *DIRECTIVE_AND_BLOCK_NODES
22
20
  if node.block
23
21
  # Use the full block declaration (e.g., "group :development do") as the unique name
24
22
  name = node.location.slice.lines.first.strip
25
23
 
26
24
  previous_set = @current_set
27
- @current_set = @parser.find_or_build_set(name:, node:)
28
- node.block.body&.accept(self)
25
+ @current_set = find_or_build_set(name:, node:)
26
+ node.block.body.accept(self)
29
27
  @current_set = previous_set
30
28
  else
31
- @parser.preamble_nodes << node
29
+ @parser.directives << node
32
30
  end
33
31
  end
34
32
  end
33
+
34
+ private
35
+
36
+ def find_or_build_set(name:, node: nil)
37
+ @parser.gem_sets.find { |set| set.name == name } || build_set(name:, node:)
38
+ end
39
+
40
+ def build_set(name:, node: nil)
41
+ GemSet.new(name:, node:).tap do |set|
42
+ @parser.gem_sets << set
43
+ end
44
+ end
45
+
46
+ def parse_gem_with_comment(node)
47
+ name = node.arguments.child_nodes[0].content
48
+ summary = Summarizer.summarize(name)
49
+
50
+ GemDeclaration.new(name:, summary:, node:)
51
+ end
35
52
  end
36
53
  end
data/lib/rebundler.rb CHANGED
@@ -9,5 +9,5 @@ module Rebundler
9
9
  class Error < StandardError; end
10
10
 
11
11
  SORTABLE_NODES = %i[plugin gem].freeze
12
- BLOCK_NODES = %i[gemspec git group path platforms ruby source].freeze
12
+ DIRECTIVE_AND_BLOCK_NODES = %i[gemspec git group path platforms ruby source].freeze
13
13
  end
data/mise.toml ADDED
@@ -0,0 +1,2 @@
1
+ [tools]
2
+ ruby = "4.0"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rebundler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dennis Paagman
@@ -25,13 +25,15 @@ files:
25
25
  - lib/bundler/rebundle.rb
26
26
  - lib/rebundler.rb
27
27
  - lib/rebundler/Rakefile
28
- - lib/rebundler/gem_fetcher.rb
28
+ - lib/rebundler/formatter.rb
29
+ - lib/rebundler/gem_declaration.rb
29
30
  - lib/rebundler/gem_set.rb
30
31
  - lib/rebundler/parser.rb
31
32
  - lib/rebundler/serializer.rb
33
+ - lib/rebundler/summarizer.rb
32
34
  - lib/rebundler/version.rb
33
35
  - lib/rebundler/visitor.rb
34
- - lib/rebundler/writer.rb
36
+ - mise.toml
35
37
  - plugins.rb
36
38
  homepage: https://github.com/dennispaagman/rebundler
37
39
  licenses:
@@ -55,7 +57,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
55
57
  - !ruby/object:Gem::Version
56
58
  version: '0'
57
59
  requirements: []
58
- rubygems_version: 3.6.9
60
+ rubygems_version: 4.0.3
59
61
  specification_version: 4
60
62
  summary: Rebundler makes your Gemfile look good.
61
63
  test_files: []
@@ -1,41 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Rebundler
4
- class GemFetcher
5
- class << self
6
- def parse_gem(node)
7
- return unless node.type == :call_node && SORTABLE_NODES.include?(node.name)
8
-
9
- gem_name = node.arguments.child_nodes[0].content
10
-
11
- find_gem(gem_name).merge(node:)
12
- end
13
-
14
- private
15
-
16
- def find_gem(name)
17
- summary = find_loaded_gem_summary(name) || find_external_gem_summary(name)
18
-
19
- { name:, summary: }
20
- end
21
-
22
- def find_loaded_gem_summary(name)
23
- gem = Gem::Specification.find_by_name(name)
24
-
25
- gem.summary
26
- rescue Gem::MissingSpecError
27
- nil
28
- end
29
-
30
- def find_external_gem_summary(name)
31
- spec = Gem::SpecFetcher.fetcher.spec_for_dependency Gem::Dependency.new(name)
32
-
33
- return unless spec
34
- # SpecFetcher returns [[], []] when gem exists but has no versions available
35
- return if spec == [[], []]
36
-
37
- spec.first.first.first.summary
38
- end
39
- end
40
- end
41
- end