whatthegem 0.0.1

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
+ 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