degem 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: 99c4e8766d6d3f8e6f710e2e13c0e0007190617fff3f83d198b469f0aec2ffb9
4
+ data.tar.gz: 4df2c08f44fc1eb1c694a85ae55d36421f4610bb717f3789eb8b5feb93c726e3
5
+ SHA512:
6
+ metadata.gz: 26145f7a46f9b9a3152092ba4fa7065261630b66c5edf443d7e90d1eff1c88264814edefc3048d73c39b24cb7a7e9709114baf5959133de351b841967f17fba0
7
+ data.tar.gz: 5f3d721a7bf24c8541f93d5a67672ca71f8bfa7550a11822af4e214d6e338d905b9a4f8a3324457593de276c88cbb18eaf10af910faa8470a9abca9096787eca
data/exe/degem ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "degem"
5
+ Degem::Cli.call
data/lib/degem/cli.rb ADDED
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Degem
4
+ class Cli
5
+ GEMFILE = "Gemfile"
6
+
7
+ def self.call
8
+ exit new($stderr).call
9
+ end
10
+
11
+ def initialize(stderr)
12
+ @stderr = stderr
13
+ end
14
+
15
+ def call
16
+ unless gemfile_exists?
17
+ @stderr.puts "Gemfile not found in the current directory"
18
+ return 1
19
+ end
20
+
21
+ unused = find_unused.call
22
+ decorated = decorate_rubygems.call(unused)
23
+ Report.new(@stderr).call(decorated)
24
+ 0
25
+ end
26
+
27
+ private
28
+
29
+ def find_unused
30
+ FindUnused.new(
31
+ gemfile_path: GEMFILE,
32
+ gem_specification: Gem::Specification,
33
+ grep: Grep.new(@stderr)
34
+ )
35
+ end
36
+
37
+ def decorate_rubygems
38
+ DecorateRubygems.new(
39
+ gem_specification: Gem::Specification,
40
+ git_adapter: GitAdapter.new
41
+ )
42
+ end
43
+
44
+ def gemfile_exists?
45
+ File.file?(GEMFILE)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ Commit = Data.define(:hash, :date, :title, :url)
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Degem
4
+ class DecorateRubygems
5
+ def initialize(gem_specification:, git_adapter:)
6
+ @gem_specification = gem_specification
7
+ @git_adapter = git_adapter
8
+ end
9
+
10
+ def call(rubygems)
11
+ rubygems.map do |rubygem|
12
+ gemspec = @gem_specification.find_by_name(rubygem.name)
13
+ git = @git_adapter.call(rubygem.name)
14
+ Rubygem.new(rubygem, gemspec, git)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Degem
4
+ class Rubygem < MultiDelegator
5
+ attr_reader :commits
6
+
7
+ def initialize(_, _, commits)
8
+ super
9
+ @commits = commits
10
+ end
11
+
12
+ def source_code_uri
13
+ metadata["source_code_uri"] || homepage
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Degem
4
+ class FindUnused
5
+ def initialize(gemfile_path:, gem_specification:, grep: Grep.new, bundle_paths: GitLsFiles.new)
6
+ @gemfile_path = gemfile_path
7
+ @gem_specification = gem_specification
8
+ @grep = grep
9
+ @bundle_paths = bundle_paths.call(File.dirname(gemfile_path))
10
+ end
11
+
12
+ def call
13
+ rubygems = gemfile.rubygems.reject { _1.name == "degem" }
14
+ rubygems = reject_railties(rubygems) if rails?
15
+ reject_used(rubygems)
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :gemfile_path
21
+
22
+ def reject_railties(rubygems)
23
+ rubygems
24
+ .reject { _1.name == "rails" }
25
+ .reject do |rubygem|
26
+ gem_path = @gem_specification.find_by_name(rubygem.name).full_gem_path
27
+ @grep.match?(/(Rails::Railtie|Rails::Engine)/, gem_path)
28
+ end
29
+ end
30
+
31
+ def reject_used(rubygems)
32
+ candidates = rubygems.map { Matcher.new(rubygem: _1, matchers: matchers) }
33
+ @grep.inverse_many(candidates, @bundle_paths).map(&:rubygem)
34
+ end
35
+
36
+ def matchers
37
+ [
38
+ method(:based_on_top_module),
39
+ method(:based_on_top_composite_module_dash),
40
+ method(:based_on_top_composite_module_underscore),
41
+ method(:based_on_top_call),
42
+ method(:based_on_top_composite_call_dash),
43
+ method(:based_on_top_composite_call_underscore),
44
+ method(:based_on_require),
45
+ method(:based_on_require_prefix_path),
46
+ method(:based_on_require_path)
47
+ ].compact
48
+ end
49
+
50
+ def gemfile
51
+ @gemfile ||= ParseGemfile.new.call(gemfile_path)
52
+ end
53
+
54
+ def rails?
55
+ @rails ||= gemfile.rails?
56
+ end
57
+
58
+ # gem foo -> Foo:: (but not XFoo:: or X::Foo)
59
+ def based_on_top_module(rubygem, line)
60
+ return false if rubygem.name.include?("-")
61
+
62
+ regex = %r{
63
+ (?<!\w::) # Do not match if :: before
64
+ (?<!\w) # Do not match if \w before
65
+ #{rubygem.name.capitalize}
66
+ ::
67
+ }x
68
+ regex.match?(line)
69
+ end
70
+
71
+ # gem foo_bar -> FooBar (but not XFooBar or X::FooBar)
72
+ def based_on_top_composite_module_underscore(rubygem, line)
73
+ return false unless rubygem.name.include?("_")
74
+
75
+ regex = %r{
76
+ (?<!\w::) # Do not match if :: before
77
+ (?<!\w) # Do not match if \w before
78
+ #{rubygem.name.split("_").map(&:capitalize).join}
79
+ ::
80
+ }x
81
+ regex.match?(line)
82
+ end
83
+
84
+ # gem foo-bar -> Foo::Bar (but not XFoo::Bar or X::Foo::Bar)
85
+ def based_on_top_composite_module_dash(rubygem, line)
86
+ return false unless rubygem.name.include?("-")
87
+
88
+ regex = %r{
89
+ (?<!\w::) # Do not match if :: before
90
+ (?<!\w) # Do not match if \w before
91
+ #{rubygem.name.split("-").map(&:capitalize).join("::")}
92
+ }x
93
+ regex.match?(line)
94
+ end
95
+
96
+ # gem foo -> Foo. (but not X::Foo. or XBar.)
97
+ def based_on_top_call(rubygem, line)
98
+ return false if rubygem.name.include?("-")
99
+
100
+ regex = %r{
101
+ (?<!\w::) # Do not match if :: before
102
+ (?<!\w) # Do not match if \w before
103
+ #{rubygem.name.capitalize}
104
+ \.
105
+ }x
106
+ regex.match?(line)
107
+ end
108
+
109
+ # gem foo-bar -> FooBar. (but not X::FooBar. or XFooBar.)
110
+ def based_on_top_composite_call_dash(rubygem, line)
111
+ return false unless rubygem.name.include?("-")
112
+
113
+ regex = %r{
114
+ (?<!\w::) # Do not match if :: before
115
+ (?<!\w) # Do not match if \w before
116
+ #{rubygem.name.split("-").map(&:capitalize).join}
117
+ \.
118
+ }x
119
+ regex.match?(line)
120
+ end
121
+
122
+ # gem foo_bar -> FooBar. (but not X::FooBar. or XFooBar.)
123
+ def based_on_top_composite_call_underscore(rubygem, line)
124
+ return false unless rubygem.name.include?("_")
125
+
126
+ regex = %r{
127
+ (?<!\w::) # Do not match if :: before
128
+ (?<!\w) # Do not match if \w before
129
+ #{rubygem.name.split("_").map(&:capitalize).join}
130
+ \.
131
+ }x
132
+ regex.match?(line)
133
+ end
134
+
135
+ # gem foo-bar -> require 'foo-bar'
136
+ def based_on_require(rubygem, line)
137
+ regex = %r{
138
+ ^
139
+ \s*
140
+ require
141
+ \s+
142
+ ['"]
143
+ #{rubygem.name}
144
+ ['"]
145
+ }x
146
+ regex.match?(line)
147
+ end
148
+
149
+ # gem foo-bar -> require 'foo/bar'
150
+ def based_on_require_path(rubygem, line)
151
+ return false unless rubygem.name.include?("-")
152
+
153
+ regex = %r{
154
+ ^
155
+ \s*
156
+ require
157
+ \s+
158
+ ['"]
159
+ #{rubygem.name.tr("-", "/")} # match foo/bar when rubygem is foo-bar
160
+ ['"]
161
+ }x
162
+ regex.match?(line)
163
+ end
164
+
165
+ # gem foo -> require 'foo/'
166
+ def based_on_require_prefix_path(rubygem, line)
167
+ return false if rubygem.name.include?("-")
168
+
169
+ regex = %r{
170
+ ^
171
+ \s*
172
+ require
173
+ \s+
174
+ ['"]
175
+ #{rubygem.name}
176
+ /
177
+ }x
178
+ regex.match?(line)
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Degem
4
+ class Gemfile
5
+ def initialize(dsl)
6
+ @dsl = dsl
7
+ end
8
+
9
+ def rubygems
10
+ @rubygems ||= (gemfile_dependencies + gemspec_dependencies).uniq
11
+ end
12
+
13
+ def rails?
14
+ @rails ||= rubygems.map(&:name).include?("rails")
15
+ end
16
+
17
+ private
18
+
19
+ def gemfile_dependencies
20
+ @dsl.dependencies.select(&:should_include?)
21
+ end
22
+
23
+ def gemspec_dependencies
24
+ @dsl.gemspecs.flat_map(&:dependencies)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ostruct"
4
+ require "open3"
5
+
6
+ module Degem
7
+ class GitAdapter
8
+ def call(gem_name)
9
+ out, _err, status = git_log(gem_name)
10
+ return [] unless status.zero?
11
+
12
+ out.split("\n").map do |commit|
13
+ hash, date, title = commit.split("\t")
14
+ Commit.new(hash:, date:, title:, url: to_commit_url(hash))
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def git_remote_origin_url
21
+ out, err, status = Open3.capture3("git remote get-url origin")
22
+ [out, err, status.exitstatus]
23
+ end
24
+
25
+ def git_log(gem_name)
26
+ out1, err1, status1 = git_log_gemfile(gem_name)
27
+ out2, err2, status2 = git_log_gemspec(gem_name)
28
+
29
+ [
30
+ [out1.to_s, out2.to_s].join,
31
+ [err1.to_s, err2.to_s].join,
32
+ (status1 + status2) < 2 ? 0 : 1
33
+ ]
34
+ end
35
+
36
+ def git_log_gemfile(gem_name)
37
+ out, err, status = Open3.capture3([
38
+ "git log",
39
+ "--pretty=format:'%H%x09%cs%x09%s'",
40
+ "--pickaxe-regex",
41
+ "--follow",
42
+ "-S \"gem\\s*['\\\"]#{gem_name}['\\\"]\"",
43
+ "--",
44
+ "Gemfile",
45
+ "|",
46
+ "cat"
47
+ ].join(" "))
48
+
49
+ [out, err, status.exitstatus]
50
+ end
51
+
52
+ def git_log_gemspec(gem_name)
53
+ out, err, status = Open3.capture3([
54
+ "git log",
55
+ "--pretty=format:'%H%x09%cs%x09%s'",
56
+ "--pickaxe-regex",
57
+ "--follow",
58
+ "-S \"spec\\.add_(development_)?dependency\\s*['\\\"]#{gem_name}['\\\"]\"",
59
+ "--",
60
+ "*.gemspec",
61
+ "|",
62
+ "cat"
63
+ ].join(" "))
64
+
65
+ [out, err, status.exitstatus]
66
+ end
67
+
68
+ def to_commit_url(commit_hash)
69
+ remote, _, status = git_remote_origin_url
70
+ return "" unless status.zero?
71
+
72
+ repository = (remote.match(%r{github\.com[:/](.+?)(\.git)}) || [])[1]
73
+ return "" if repository.nil?
74
+
75
+ "https://github.com/#{repository}/commit/#{commit_hash}"
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Degem
6
+ class GitLsFiles
7
+ def call(fallback)
8
+ out, _err, status = git_ls
9
+ return fallback unless status.zero?
10
+
11
+ out.split("\x0").select { _1.end_with?(".rb") }.map { File.expand_path(_1) }
12
+ end
13
+
14
+ private
15
+
16
+ def git_ls
17
+ out, err, status = Open3.capture3("git ls-files -z")
18
+ [out, err, status.exitstatus]
19
+ end
20
+ end
21
+ end
data/lib/degem/grep.rb ADDED
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "find"
4
+
5
+ module Degem
6
+ class Grep
7
+ def initialize(stderr = StringIO.new)
8
+ @stderr = stderr
9
+ end
10
+
11
+ def match?(matcher, dir)
12
+ Find.find(File.expand_path(dir)) do |path|
13
+ next unless File.file?(path)
14
+ next if File.extname(path) != ".rb"
15
+
16
+ @stderr.putc "."
17
+ File.foreach(path) do |line|
18
+ next unless matcher.match?(line)
19
+
20
+ return true
21
+ end
22
+ end
23
+
24
+ false
25
+ end
26
+
27
+ def inverse_many(matchers, paths)
28
+ Find.find(*paths) do |path|
29
+ next unless File.file?(path)
30
+
31
+ @stderr.putc "."
32
+ File.foreach(path) do |line|
33
+ matchers = matchers.reject do |matcher|
34
+ matcher.match?(line)
35
+ end
36
+ end
37
+ end
38
+
39
+ matchers
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Degem
4
+ class Matcher
5
+ attr_reader :rubygem
6
+
7
+ def initialize(rubygem:, matchers:)
8
+ @rubygem = rubygem
9
+ @matchers = matchers
10
+ end
11
+
12
+ def match?(string)
13
+ @matchers.any? { _1.call(@rubygem, string) }
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Degem
4
+ class MultiDelegator
5
+ def initialize(*delegates)
6
+ @delegates = delegates
7
+ end
8
+
9
+ def method_missing(method, *args)
10
+ delegate = @delegates.find { _1.respond_to?(method) }
11
+ return delegate.public_send(method, *args) if delegate
12
+
13
+ super
14
+ end
15
+
16
+ def respond_to_missing?(method, include_private = false)
17
+ @delegates.any? { _1.respond_to?(method, include_private) } || super
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Degem
4
+ class ParseGemfile
5
+ def call(gemfile_path)
6
+ dsl = Bundler::Dsl.new
7
+ dsl.eval_gemfile(gemfile_path)
8
+ Gemfile.new(dsl)
9
+ end
10
+
11
+ private
12
+
13
+ def definition(gemfile_path)
14
+ Bundler::Dsl.evaluate(gemfile_path, nil, {})
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Degem
4
+ class Report
5
+ def initialize(stderr)
6
+ @stderr = stderr
7
+ end
8
+
9
+ def call(rubygems)
10
+ @stderr.puts
11
+ @stderr.puts
12
+ @stderr.puts "The following gems may be unused:"
13
+ @stderr.puts
14
+
15
+ rubygems.each do |rubygem|
16
+ gem_name(rubygem)
17
+ @stderr.puts
18
+ commits(rubygem)
19
+ @stderr.puts
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def gem_name(rubygem)
26
+ heading =
27
+ if rubygem.source_code_uri.nil?
28
+ rubygem.name
29
+ else
30
+ "#{rubygem.name}: #{rubygem.source_code_uri}"
31
+ end
32
+
33
+ @stderr.puts(heading)
34
+ @stderr.puts("=" * heading.size)
35
+ end
36
+
37
+ def commits(rubygem)
38
+ rubygem.commits.each.with_index do |commit, i|
39
+ @stderr.puts("#{commit.hash[0..6]} (#{commit.date}) #{commit.title}")
40
+ @stderr.puts(commit.url)
41
+ @stderr.puts if i + 1 == rubygem.commits.size
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Degem
4
+ class Rubygem < MultiDelegator
5
+ attr_reader :commits
6
+
7
+ def initialize(_, _, commits)
8
+ super
9
+ @commits = commits
10
+ end
11
+
12
+ def source_code_uri
13
+ metadata["source_code_uri"] || homepage
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Degem
4
+ VERSION = "0.1.0"
5
+ end
data/lib/degem.rb ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "degem/version"
4
+ require_relative "degem/gemfile"
5
+ require_relative "degem/parse_gemfile"
6
+ require_relative "degem/grep"
7
+ require_relative "degem/git_ls_files"
8
+ require_relative "degem/matcher"
9
+ require_relative "degem/find_unused"
10
+ require_relative "degem/multi_delegator"
11
+ require_relative "degem/rubygem"
12
+ require_relative "degem/decorate_rubygems"
13
+ require_relative "degem/commit"
14
+ require_relative "degem/git_adapter"
15
+ require_relative "degem/report"
16
+ require_relative "degem/cli"
17
+
18
+ module Degem
19
+ end
metadata ADDED
@@ -0,0 +1,66 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: degem
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - 3v0k4
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-12-05 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Degem finds unused gems in the Ruby bundle (ie, an app with a `Gemfile`
14
+ or a gem with both a `Gemfile` and a gemspec).
15
+ email:
16
+ - riccardo.odone@gmail.com
17
+ executables:
18
+ - degem
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - exe/degem
23
+ - lib/degem.rb
24
+ - lib/degem/cli.rb
25
+ - lib/degem/commit.rb
26
+ - lib/degem/decorate_rubygems.rb
27
+ - lib/degem/decorated.rb
28
+ - lib/degem/find_unused.rb
29
+ - lib/degem/gemfile.rb
30
+ - lib/degem/git_adapter.rb
31
+ - lib/degem/git_ls_files.rb
32
+ - lib/degem/grep.rb
33
+ - lib/degem/matcher.rb
34
+ - lib/degem/multi_delegator.rb
35
+ - lib/degem/parse_gemfile.rb
36
+ - lib/degem/report.rb
37
+ - lib/degem/rubygem.rb
38
+ - lib/degem/version.rb
39
+ homepage: https://github.com/3v0k4/degem
40
+ licenses:
41
+ - MIT
42
+ metadata:
43
+ homepage_uri: https://github.com/3v0k4/degem
44
+ source_code_uri: https://github.com/3v0k4/degem
45
+ changelog_uri: https://github.com/3v0k4/degem/blob/main/CHANGELOG.md
46
+ rubygems_mfa_required: 'true'
47
+ post_install_message:
48
+ rdoc_options: []
49
+ require_paths:
50
+ - lib
51
+ required_ruby_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: 3.1.0
56
+ required_rubygems_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ requirements: []
62
+ rubygems_version: 3.5.23
63
+ signing_key:
64
+ specification_version: 4
65
+ summary: Find unused gems in the Ruby bundle
66
+ test_files: []