degem 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []