whatthegem 0.0.1

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
+ SHA1:
3
+ metadata.gz: c9f1d103cc46e0222855e20a653a1f0d6035d706
4
+ data.tar.gz: 482bb2927a0e82b9125bd5d0805da02073536025
5
+ SHA512:
6
+ metadata.gz: c26011bb1bc4043411507d0faf444942de248b92af28c842e035aa316e443d2ef5ba2c142569034a41ebffdb9d8f3a9a132785c7add87bf7b8bacae49a05960b
7
+ data.tar.gz: ce913168e10295374f15a3ae09cd6f7d0a6423a6c42917af36c4ac9581c9b3d540f80f441aa093fd7487e9cfd3b25b687c85d72279d1367069198304438d5d62
data/README.md ADDED
@@ -0,0 +1,56 @@
1
+ [![Gem Version](https://badge.fury.io/rb/whatthegem.svg)](http://badge.fury.io/rb/whatthegem)
2
+
3
+ `whatthegem` is a small utility to answer some questions about Ruby gems you work with or planning to work with.
4
+
5
+ It tries to answer—**right in your terminal**—questions like the following:
6
+
7
+ * Colleague added `gemname` to our `Gemfile`, what is it?
8
+ * How outdated is my favorite `gemname` I am using locally, what's changed since then?
9
+ * What's that benchmarking `gemname`'s synopsis was, again?
10
+ * There is `gemname` advised on the internetz for my problem, is it still maintained? Is it widely used?
11
+
12
+ There are a lot of ways to answer those questions through Google and various sites, but if you are in a terminal currently, `whatthegem` is fastest, most focused and convenient.
13
+
14
+ Showcase:
15
+
16
+ `gem install whatthegem`
17
+
18
+ ## `whatthegem <gem> info`
19
+
20
+ ![](https://github.com/zverok/whatthegem/blob/master/screenshots/info.png?raw=true)
21
+
22
+ Just extracts gem's description and version from RubyGems.org and presents it to you alongside your local versions.
23
+
24
+ ## `whatthegem <gem> usage`
25
+
26
+ ![](https://github.com/zverok/whatthegem/blob/master/screenshots/usage.png?raw=true)
27
+
28
+ Tries to parse gem's GitHub README (or local README, if the gem is installed and includes it), and extract Ruby code blocks from there (except trivial, like "Add to your `Gemfile`: `gem 'gemname'`) and prints them. Typically, it is the main/most basic usage examples.
29
+
30
+ ## `whatthegem <gem> changes`
31
+
32
+ ![](https://github.com/zverok/whatthegem/blob/master/screenshots/changes.png?raw=true)
33
+
34
+ Parses gem's GitHub `Changelog.md`/`NEWS`/`History`, or GitHub releases description, and lists versions and their changes (up to your local version, if it is older than recent one).
35
+
36
+ ## `whatthegem <gem> stats`
37
+
38
+ ![](https://github.com/zverok/whatthegem/blob/master/screenshots/stats.png?raw=true)
39
+
40
+ Some statistics about gem's maintenance, freshness, and popularity (again, from RubyGems.org and GitHub). It doesn't _judge_, just provides helpful quick insights.
41
+
42
+ > Note that, for example, "not having a new version in last year or two" doesn't necessary means the gem is abandoned, it could be just "complete".
43
+
44
+ _This functionality is ported from my earlier gem [any_good](https://github.com/zverok/any_good)—after its creation, I eventually understood there are several things I'd like to be able to know about the gems, and so whatthegem was born._
45
+
46
+ ## Limitations
47
+
48
+ * As significant amount of information is taken from GitHub, `whatthegem` is less useful for gems that aren't on GitHub, or doesn't specify their repo URL in gemspec (available via RubyGems.org API); integrating of other source hosting is possible, but currently, in my experience, it seems like \~99% of gems are there;
49
+ * Also, GitHub usage/changes extraction for gems that aren't in the root of the repo (like [ActiveRecord](https://github.com/rails/rails/tree/v5.2.3/activerecord)) aren't supported yet;
50
+ * As `usage` and `changes` are extracted by heuristics, it doesn't always work well (though it **does** in a surprisingly large number of cases);
51
+ * For example, `usage` is typically informative for stand-alone libraries, but hardly so for Rails plugins (which have instructions like "run this generator, add that to config, insert this line in the model" in their README)
52
+ * For other, not all gems have their Changelog findable, or parseable.
53
+
54
+ ## Author
55
+
56
+ [@zverok](https://zverok.github.io)
data/exe/whatthegem ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env ruby
2
+ $LOAD_PATH.unshift File.expand_path '../lib', __dir__
3
+
4
+ require 'whatthegem'
5
+
6
+ name, cmd, *args = ARGV
7
+
8
+ # FIXME: Currently, there is no gem named `help`, but what if it would be?
9
+ # Maybe it is a reserved name?
10
+ if %w[help h --help -h].include?(name) || name.nil?
11
+ WhatTheGem::Help.call
12
+ exit
13
+ end
14
+
15
+ cmd ||= 'info'
16
+ gem = WhatTheGem::Gem.fetch(name)
17
+
18
+ WhatTheGem::Command.get(cmd)
19
+ .then { |command| command || WhatTheGem::Help }
20
+ .call(gem, *args)
@@ -0,0 +1,41 @@
1
+ module WhatTheGem
2
+ class Changes
3
+ class MarkdownParser < Parser
4
+ def versions
5
+ nodes = I::Kramdowns.elements(content)
6
+ level = detect_version_level(nodes) # find level of headers which contain version
7
+
8
+ sections = sections(nodes, level)
9
+ .select { |title,| title.match?(VERSION_LINE_REGEXP) }
10
+ .map(&method(:make_version))
11
+ .sort_by(&:number) # it is internally converted to Gem::Version, so "1.12" is correctly > "1.2"
12
+ end
13
+
14
+ private
15
+
16
+ def sections(nodes, level)
17
+ nodes
18
+ .chunk { |n| n.type == :header && n.options[:level] == level } # chunk into sections by header
19
+ .drop_while { |header,| !header } # drop before first header
20
+ .map(&:last) # drop `true`, `false` flags after chunk
21
+ .each_slice(2) # join header with subsequent nodes
22
+ .map { |(h, *), nodes| [h.options[:raw_text], nodes] }
23
+ end
24
+
25
+ def detect_version_level(nodes)
26
+ nodes
27
+ .select { |n| n.type == :header && n.options[:raw_text].match?(VERSION_LINE_REGEXP) }
28
+ .map { |n| n.options[:level] }.min
29
+ end
30
+
31
+ def make_version((title, nodes))
32
+ # TODO: date, if known
33
+ Version.new(
34
+ number: title[VERSION_LINE_REGEXP, :version],
35
+ header: title,
36
+ body: nodes.map(&I::Kramdowns.method(:el2md)).join
37
+ )
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,42 @@
1
+ module WhatTheGem
2
+ class Changes
3
+ class Parser
4
+ extend I::Callable
5
+
6
+ class << self
7
+ def call(file)
8
+ parser_for(file).versions
9
+ end
10
+
11
+ def parser_for(file)
12
+ case file.basename
13
+ when /\.(md|markdown)$/i
14
+ MarkdownParser.new(file)
15
+ else
16
+ # Most of the time in Ruby-land, when no extension it is RDoc or RDoc-alike
17
+ RDocParser.new(file)
18
+ end
19
+ end
20
+ end
21
+
22
+ attr_reader :file
23
+
24
+ def initialize(file)
25
+ @file = file
26
+ end
27
+
28
+ memoize def versions
29
+ fail NotImplementedError
30
+ end
31
+
32
+ private
33
+
34
+ memoize def content
35
+ file.read
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ require_relative 'markdown_parser'
42
+ require_relative 'rdoc_parser'
@@ -0,0 +1,42 @@
1
+ module WhatTheGem
2
+ class Changes
3
+ # FIXME: It kinda duplicates MarkdownParser for the _logic_, so should be unified somehow?
4
+ class RDocParser < Parser
5
+ def versions
6
+ nodes = I::RDocs.parts(content)
7
+ level = detect_version_level(nodes) # find level of headers which contain version
8
+
9
+ sections = sections(nodes, level)
10
+ .select { |title,| title.match?(VERSION_LINE_REGEXP) }
11
+ .map(&method(:make_version))
12
+ .sort_by(&:number) # it is internally converted to Gem::Version, so "1.12" is correctly > "1.2"
13
+ end
14
+
15
+ private
16
+
17
+ def sections(nodes, level)
18
+ nodes
19
+ .chunk { |n| n.is_a?(RDoc::Markup::Heading) && n.level == level } # chunk into sections by header
20
+ .drop_while { |header,| !header } # drop before first header
21
+ .map(&:last) # drop `true`, `false` flags after chunk
22
+ .each_slice(2) # join header with subsequent nodes
23
+ .map { |(h, *), nodes| [h.text, nodes] }
24
+ end
25
+
26
+ def detect_version_level(nodes)
27
+ nodes.grep(RDoc::Markup::Heading)
28
+ .select { |n| n.text.match?(VERSION_LINE_REGEXP) }
29
+ .map(&:level).min
30
+ end
31
+
32
+ def make_version((title, nodes))
33
+ # TODO: date, if known
34
+ Version.new(
35
+ number: title[VERSION_LINE_REGEXP, :version],
36
+ header: title,
37
+ body: nodes.map(&I::RDocs.method(:part2md)).join
38
+ )
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,27 @@
1
+ module WhatTheGem
2
+ class Changes
3
+ class ReleasesParser
4
+ extend I::Callable
5
+
6
+ def self.call(releases)
7
+ new(releases).versions
8
+ end
9
+
10
+ attr_reader :releases
11
+
12
+ def initialize(releases)
13
+ @releases = releases
14
+ end
15
+
16
+ def versions
17
+ releases.map { |tag_name:, name:, body:, **|
18
+ Version.new(
19
+ number: tag_name[VERSION_LINE_REGEXP, :version],
20
+ header: name.then.reject(&:empty?).first || tag_name,
21
+ body: body
22
+ )
23
+ }.sort_by(&:number)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,120 @@
1
+ module WhatTheGem
2
+ class Changes < Command
3
+ register description: 'Latest gem changes'
4
+
5
+ VERSION_REGEXP = '(v(er(sion)?)? ?)?(?<version>\d+\.\d+(\.\d+(\.\w+)?)?)'
6
+ VERSION_LINE_REGEXP = /(^#{VERSION_REGEXP}(\s|:|$)|\s#{VERSION_REGEXP}$)/i
7
+
8
+ CHANGELOG_NOT_FOUND = I::Pastel.red.bold("Can't find changelog to extract versions.")
9
+
10
+ class Version < Struct.new(:number, :header, :body, keyword_init: true)
11
+ def initialize(number:, header:, body:)
12
+ super(number: ::Gem::Version.new(number), header: header, body: body)
13
+ end
14
+
15
+ def to_h
16
+ super.merge(number: number.to_s)
17
+ end
18
+ end
19
+
20
+ BundledSince = Struct.new(:version) do
21
+ def to_h
22
+ {description: "your bundled version: #{version}"}
23
+ end
24
+ end
25
+
26
+ GlobalSince = Struct.new(:version) do
27
+ def to_h
28
+ {description: "your latest global version: #{version}"}
29
+ end
30
+ end
31
+
32
+ SinceFirst = Struct.new(:version) do
33
+ def to_h
34
+ {description: "the first version"}
35
+ end
36
+ end
37
+
38
+ TEMPLATE = Template.parse(<<~CHANGES)
39
+ _(since {{ since.description }})_
40
+
41
+ {% for version in versions %}
42
+ ### {{ version.header }}
43
+
44
+ {{ version.body | md_header_shift:4 }}
45
+
46
+ {% endfor %}
47
+
48
+ CHANGES
49
+
50
+ def locals
51
+ {
52
+ since: since.to_h,
53
+ versions: select_versions.reverse.map(&:to_h)
54
+ }
55
+ end
56
+
57
+ private
58
+
59
+ def output
60
+ return CHANGELOG_NOT_FOUND unless versions
61
+ markdown super
62
+ end
63
+
64
+ # TODO: allow to pass `since` as a parameter
65
+ def since
66
+ bundled_since || global_since || SinceFirst.new(versions.first.number)
67
+ end
68
+
69
+ def select_versions
70
+ return [] unless versions&.any?
71
+ res = versions.select { |v| v.number > since.version }
72
+ res = versions if res.empty? # if we are at the last version, show all historical changes
73
+
74
+ # TODO: If there are a lot of versions, we can do "intellectual selection", like
75
+ # two last minor + last major or something
76
+
77
+ res
78
+ end
79
+
80
+ def bundled_since
81
+ return unless gem.bundled.present?
82
+ gem.bundled.spec.version.then(&BundledSince.method(:new))
83
+ end
84
+
85
+ def global_since
86
+ gem.specs.first&.version&.then(&GlobalSince.method(:new))
87
+ end
88
+
89
+ memoize def versions
90
+ best_changelog(versions_from_releases, versions_from_file)
91
+ end
92
+
93
+ def versions_from_releases
94
+ gem.github&.releases&.then(&ReleasesParser)
95
+ end
96
+
97
+ def versions_from_file
98
+ # always take the freshest from GitHub, even when installed locally
99
+ gem.github&.changelog&.then(&Parser)
100
+ end
101
+
102
+ def best_changelog(*changelogs)
103
+ # in case they have both (releases & CHANGELOG.md)...
104
+ list = changelogs.compact.reject(&:empty?)
105
+ return list.first if list.size < 2
106
+
107
+ # Some gems have "old" changelog in file, and new in releases, or vice versa
108
+ if list.map { |*, last| last.number }.uniq.one?
109
+ # If both have the same latest version, return one having more text
110
+ list.max_by { |*, last| last.body.size }
111
+ else
112
+ # Return newer, if one of them are
113
+ list.max_by { |*, last| last.number }
114
+ end
115
+ end
116
+ end
117
+ end
118
+
119
+ require_relative 'changes/parser'
120
+ require_relative 'changes/releases_parser'
@@ -0,0 +1,101 @@
1
+ require 'tty/markdown'
2
+ require_relative 'tty-markdown_patch'
3
+
4
+ module WhatTheGem
5
+ class Command
6
+ Meta = Struct.new(:handle, :title, :description, keyword_init: true)
7
+
8
+ class << self
9
+ memoize def registry
10
+ {}
11
+ end
12
+
13
+ attr_reader :meta
14
+
15
+ def register(title: name.split('::').last, handle: title.downcase, description:)
16
+ Command.registry[handle] = self
17
+ @meta = Meta.new(
18
+ handle: handle,
19
+ title: title,
20
+ description: description
21
+ )
22
+ end
23
+
24
+ def get(handle)
25
+ Command.registry[handle]
26
+ end
27
+
28
+ def call(*args)
29
+ new(*args).call
30
+ end
31
+ end
32
+
33
+ # About info shortening: It is because minitest pastes their whole README
34
+ # there, including a quotations of how they are better than RSpec.
35
+ # (At the same time, RSpec's info reads "BDD for Ruby".)
36
+
37
+ # FIXME: It was > **{{ info.info | paragraphs:1 }}** but it looks weird due to tty-markdown
38
+ # bug: https://github.com/piotrmurach/tty-markdown/issues/11
39
+ HEADER_TEMPLATE = Template.parse(<<~HEADER)
40
+ # {{info.name}}
41
+ > {{info.info | paragraphs:1 | reflow }}
42
+ > ({{uris | join:", "}})
43
+
44
+ ## {{title}}
45
+
46
+
47
+ HEADER
48
+
49
+ attr_reader :gem
50
+
51
+ def initialize(gem)
52
+ @gem = gem
53
+ end
54
+
55
+ def meta
56
+ self.class.meta
57
+ end
58
+
59
+ def call
60
+ puts full_output
61
+ end
62
+
63
+ private
64
+
65
+ def full_output
66
+ gm = gem # otherwise next line breaks Sublime highlighting...
67
+ return %{Gem "#{gm.name}" is not registered at rubygems.org.} unless gem.exists?
68
+ header_locals.then(&HEADER_TEMPLATE).then(&method(:markdown)) + output
69
+ end
70
+
71
+ def output
72
+ locals.then(&template)
73
+ end
74
+
75
+ def header_locals
76
+ {
77
+ title: meta.title,
78
+ info: gem.rubygems.info,
79
+ uris: guess_uris(gem.rubygems.info),
80
+ }
81
+ end
82
+
83
+ def markdown(text)
84
+ TTY::Markdown.parse(text)
85
+ end
86
+
87
+ def template
88
+ self.class::TEMPLATE
89
+ end
90
+
91
+ def guess_uris(info)
92
+ [
93
+ info[:source_code_uri],
94
+ info.values_at(:homepage_uri, :documentation_uri, :project_uri).compact.reject(&:empty?).first
95
+ ]
96
+ .compact.reject(&:empty?).uniq { |u| u.chomp('/').sub('http:', 'https:') }
97
+ end
98
+ end
99
+ end
100
+
101
+ %w[info usage changes stats help].each { |f| require_relative(f) }
@@ -0,0 +1,30 @@
1
+ require 'backports/latest'
2
+ require 'memoist'
3
+ require 'pp'
4
+ require 'hm'
5
+ require 'pathname'
6
+ require 'forwardable'
7
+
8
+ class Object
9
+ extend Memoist
10
+ alias then yield_self # will be so in Ruby 2.6
11
+ alias :_class :class
12
+ end
13
+
14
+ Module.include Forwardable
15
+
16
+ class Pathname
17
+ def glob(pattern) # exists in Ruby 2.5, but not in backports
18
+ Dir.glob(self./(pattern).to_s).map(&Pathname.method(:new))
19
+ end
20
+ end
21
+
22
+ class Hm
23
+ class << self
24
+ alias call new
25
+ end
26
+
27
+ def self.to_proc
28
+ proc { |val| new(val) }
29
+ end
30
+ end
@@ -0,0 +1,55 @@
1
+ require 'bundler'
2
+
3
+ module WhatTheGem
4
+ class Gem
5
+ class Bundled
6
+ NoBundle = Struct.new(:name) do
7
+ def to_h
8
+ {type: 'nobundle'}
9
+ end
10
+
11
+ def present?
12
+ false
13
+ end
14
+ end
15
+
16
+ NotBundled = Struct.new(:name) do
17
+ def to_h
18
+ {type: 'notbundled', name: name}
19
+ end
20
+
21
+ def present?
22
+ false
23
+ end
24
+ end
25
+
26
+ def self.fetch(name)
27
+ return NoBundle.new(name) unless File.exists?('Gemfile') && File.exists?('Gemfile.lock')
28
+
29
+ definition = Bundler::Definition.build('Gemfile', 'Gemfile.lock', nil)
30
+ spec = definition.locked_gems.specs.detect { |s| s.name == name } or return NotBundled.new(name)
31
+ spec.send(:__materialize__)
32
+ new(spec)
33
+ end
34
+
35
+ attr_reader :spec
36
+
37
+ def initialize(spec)
38
+ @spec = spec
39
+ end
40
+
41
+ def present?
42
+ true
43
+ end
44
+
45
+ def to_h
46
+ {
47
+ type: 'bundled',
48
+ name: spec.name,
49
+ version: spec.version.to_s,
50
+ dir: spec.gem_dir
51
+ }
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,94 @@
1
+ module WhatTheGem
2
+ class Gem
3
+ class GitHub
4
+ CHANGELOG_PATTERN = /^(history|changelog|changes)(\.\w+)?$/i
5
+ Path = Struct.new(:basename, :read)
6
+
7
+ class << self
8
+ memoize def octokit
9
+ if (token = ENV['GITHUB_ACCESS_TOKEN'])
10
+ Octokit::Client.new(access_token: token).tap { |client| client.user.login }
11
+ else
12
+ Octokit::Client.new
13
+ end
14
+ end
15
+ end
16
+
17
+ attr_reader :repo_id
18
+
19
+ def initialize(repo_id)
20
+ @repo_id = repo_id
21
+ end
22
+
23
+ memoize def repository
24
+ req(:repository)
25
+ end
26
+
27
+ alias repo repository
28
+
29
+ memoize def last_commit
30
+ req(:commits, per_page: 1).first
31
+ end
32
+
33
+ memoize def open_issues
34
+ req(:issues, state: 'open', per_page: 50)
35
+ end
36
+
37
+ memoize def closed_issues
38
+ req(:issues, state: 'closed', per_page: 50)
39
+ end
40
+
41
+ memoize def releases
42
+ req(:releases)
43
+ end
44
+
45
+ def contents(path = '.')
46
+ req(:contents, path: path)
47
+ end
48
+
49
+ alias files contents
50
+
51
+ memoize def changelog
52
+ locate_file(CHANGELOG_PATTERN)
53
+ end
54
+
55
+ memoize def readme
56
+ locate_file(/^readme(\.\w+)?$/i)
57
+ end
58
+
59
+ private
60
+
61
+ def locate_file(pattern)
62
+ files.detect { |f| f.fetch(:name).match?(pattern) }
63
+ &.fetch(:path)
64
+ &.then { |path|
65
+ Path.new(path, contents(path).then(&method(:decode_content)))
66
+ }
67
+ end
68
+
69
+ def req(method, *args)
70
+ octokit.public_send(method, repo_id, *args).then(&method(:sawyer_to_hashes))
71
+ end
72
+
73
+ def sawyer_to_hashes(val)
74
+ case
75
+ when val.respond_to?(:to_hash)
76
+ val.to_hash.transform_values(&method(:sawyer_to_hashes))
77
+ when val.respond_to?(:to_ary)
78
+ val.to_ary.map(&method(:sawyer_to_hashes))
79
+ else
80
+ val
81
+ end
82
+ end
83
+
84
+ def decode_content(obj)
85
+ # TODO: encoding is specified as a part of the answer, could it be other than base64?
86
+ Base64.decode64(obj.fetch(:content)).force_encoding('UTF-8')
87
+ end
88
+
89
+ def octokit
90
+ self.class.octokit
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,29 @@
1
+ module WhatTheGem
2
+ class Gem
3
+ class RubyGems
4
+ attr_reader :name
5
+
6
+ def initialize(name)
7
+ @name = name
8
+ end
9
+
10
+ memoize def info
11
+ req(:info)
12
+ end
13
+
14
+ memoize def versions
15
+ req(:versions)
16
+ end
17
+
18
+ memoize def reverse_dependencies
19
+ req(:reverse_dependencies)
20
+ end
21
+
22
+ private
23
+
24
+ def req(method, *args)
25
+ ::Gems.public_send(method, name, *args).then(&Hm).transform_keys(&:to_sym).to_h
26
+ end
27
+ end
28
+ end
29
+ end