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.
@@ -0,0 +1,64 @@
1
+ require 'gems'
2
+ require 'octokit'
3
+ require 'base64'
4
+
5
+ module WhatTheGem
6
+ class Gem
7
+ GITHUB_URI_PATTERN = %r{^https?://(www\.)?github\.com/}
8
+
9
+ NoGem = Struct.new(:name) do
10
+ def exists?
11
+ false
12
+ end
13
+ end
14
+
15
+ def self.fetch(name)
16
+ # Empty hash in rubygems info means it does not exist.
17
+ # FIXME: Could be wrong in case of: a) private gems and b) "local-only" command checks
18
+ new(name).then { |gem| gem.rubygems.info.empty? ? NoGem.new(name) : gem }
19
+ end
20
+
21
+ attr_reader :name
22
+
23
+ def initialize(name)
24
+ @name = name
25
+ end
26
+
27
+ def exists?
28
+ true
29
+ end
30
+
31
+ memoize def rubygems
32
+ RubyGems.new(name)
33
+ end
34
+
35
+ memoize def github
36
+ rubygems.info.to_h.values_at(:source_code_uri, :homepage_uri)
37
+ .then(&method(:detect_repo_id))
38
+ &.then(&GitHub.method(:new))
39
+ end
40
+
41
+ memoize def specs
42
+ ::Gem::Specification.select { |s| s.name == name }.sort_by(&:version)
43
+ end
44
+
45
+ memoize def bundled
46
+ Bundled.fetch(name)
47
+ end
48
+
49
+ private
50
+
51
+ # FIXME: active_record's actual path is https://github.com/rails/rails/tree/v5.2.1/activerecord
52
+ def detect_repo_id(urls)
53
+ # Octokit can't correctly guess repo slug from https://github.com/bitaxis/annotate_models.git
54
+ urls.grep(GITHUB_URI_PATTERN).first
55
+ &.sub(/\.git$/, '')
56
+ &.then(&Octokit::Repository.method(:from_url))
57
+ &.slug
58
+ end
59
+ end
60
+ end
61
+
62
+ require_relative 'gem/rubygems'
63
+ require_relative 'gem/github'
64
+ require_relative 'gem/bundled'
@@ -0,0 +1,31 @@
1
+ module WhatTheGem
2
+ class Help < Command
3
+ TEMPLATE = Template.parse(<<~HELP)
4
+ `whatthegem` is a small tool for fetching information about Ruby gems from various sources.
5
+
6
+ **Usage:** `whatthegem <gemname> [<command>]`
7
+
8
+ Known commands:
9
+
10
+ {% for command in commands %}
11
+ * `{{command.handle}}`: {{command.description}}{% endfor %}
12
+ HELP
13
+
14
+ def initialize(*)
15
+ end
16
+
17
+ def locals
18
+ {commands: commands}
19
+ end
20
+
21
+ private
22
+
23
+ def full_output
24
+ markdown output
25
+ end
26
+
27
+ def commands
28
+ Command.registry.values.map(&:meta).map(&:to_h)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,61 @@
1
+ module WhatTheGem
2
+ class Hobject
3
+ class << self
4
+ def deep(hash_or_array)
5
+ deep_value(hash_or_array)
6
+ end
7
+
8
+ private
9
+
10
+ def deep_value(val)
11
+ case
12
+ when val.respond_to?(:to_hash)
13
+ val.to_hash.transform_values(&method(:deep_value)).then(&method(:new))
14
+ when val.respond_to?(:to_ary)
15
+ val.to_ary.map(&method(:deep_value))
16
+ else
17
+ val
18
+ end
19
+ end
20
+ end
21
+
22
+ def initialize(hash)
23
+ @hash = hash.transform_keys(&:to_sym).freeze
24
+ @hash.each do |key, val|
25
+ define_singleton_method(key) { val }
26
+ define_singleton_method("#{key}?") { !!val }
27
+ end
28
+ end
29
+
30
+ def to_h
31
+ @hash
32
+ end
33
+
34
+ def merge(other)
35
+ Hobject.new(to_h.merge(other.to_h))
36
+ end
37
+
38
+ def deep_to_h
39
+ @hash.transform_values(&method(:val_to_h))
40
+ end
41
+
42
+ def inspect
43
+ '#<Hobject(%s)>' % @hash.map { |k, v| "#{k}: #{v.inspect}" }.join(', ')
44
+ end
45
+
46
+ alias to_s inspect
47
+
48
+ private
49
+
50
+ def val_to_h(value)
51
+ case value
52
+ when Hobject
53
+ value.deep_to_h
54
+ when Array
55
+ value.map(&method(:val_to_h))
56
+ else
57
+ value
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,56 @@
1
+ require 'rdoc'
2
+ require 'kramdown'
3
+ require 'pastel'
4
+
5
+ # Will be necessary when switching to kramdown-2, but tty-markdown doesn't support it yet.
6
+ # require 'kramdown-parser-gfm'
7
+
8
+ module WhatTheGem
9
+ # I for Internal
10
+ # @private
11
+ module I
12
+ extend self
13
+
14
+ module Callable
15
+ def to_proc
16
+ proc { |*args| call(*args) }
17
+ end
18
+ end
19
+
20
+ Pastel = ::Pastel.new
21
+
22
+ module Kramdowns
23
+ extend self
24
+
25
+ def elements(source)
26
+ Kramdown::Document.new(source, input: 'GFM').root.children
27
+ end
28
+
29
+ # Somehow there is no saner methods for converting parsed element back to source :shrug:
30
+ def el2md(el)
31
+ el.options[:encoding] = 'UTF-8'
32
+ el.attr.replace({}) # don't render header anchors
33
+ Kramdown::Converter::Kramdown.convert(el, line_width: 1000).first
34
+ end
35
+ end
36
+
37
+ module RDocs
38
+ extend self
39
+
40
+ def parts(source)
41
+ RDoc::Comment.new(source).parse.parts
42
+ end
43
+
44
+ def part2md(part)
45
+ formatter = RDoc::Markup::ToMarkdown.new
46
+ RDoc::Markup::Document.new(part).accept(formatter)
47
+ end
48
+ end
49
+
50
+ def ago_text(tm)
51
+ diff = TimeMath.measure(tm, Time.now)
52
+ unit, num = diff.detect { |_, v| !v.zero? }
53
+ "#{num} #{unit} ago"
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,58 @@
1
+ module WhatTheGem
2
+ class Info < Command
3
+ register description: '(default) General information about the gem'
4
+
5
+ # About info shortening: It is because minitest pastes their whole README
6
+ # there, including a quotations of how they are better than RSpec.
7
+ # (At the same time, RSpec's info reads "BDD for Ruby".)
8
+
9
+ TEMPLATE = Template.parse(<<~INFO)
10
+ Latest version: {{info.version}} ({{age}})
11
+ Installed versions: {% if specs %}{{ specs | map:"version" | join: ", "}}{% else %}—{% endif %}
12
+ {% if current %}
13
+ Most recent installed at: {{current.dir}}
14
+ {% endif %}
15
+ {% unless bundled.type == 'nobundle' %}
16
+ In your bundle: {% if bundled.type == 'notbundled' %}—{% else
17
+ %}{{ bundled.version }} at {{ bundled.dir }}{% endif %}
18
+ {% endunless %}
19
+
20
+ Try also:
21
+ {% for command in commands %}
22
+ `whatthegem {{info.name}} {{command.handle}}` -- {{command.description}}{% endfor %}
23
+ INFO
24
+
25
+ def locals
26
+ {
27
+ info: gem.rubygems.info,
28
+ age: age,
29
+ specs: specs,
30
+ current: specs.last,
31
+ bundled: gem.bundled.to_h,
32
+ commands: commands
33
+ }
34
+ end
35
+
36
+ private
37
+
38
+ def age
39
+ gem.rubygems.versions
40
+ .first&.dig(:created_at)
41
+ &.then(&Time.method(:parse))&.then(&I.method(:ago_text))
42
+ end
43
+
44
+ def specs
45
+ gem.specs.map { |spec|
46
+ {
47
+ name: spec.name,
48
+ version: spec.version.to_s,
49
+ dir: spec.gem_dir
50
+ }
51
+ }
52
+ end
53
+
54
+ def commands
55
+ Command.registry.values.-([self.class]).map(&:meta).map(&:to_h)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,50 @@
1
+ require 'time_math'
2
+
3
+ module WhatTheGem
4
+ class Stats
5
+ module Definitions
6
+ require_relative 'meters'
7
+
8
+ FIFTY_PLUS = proc { |res| res == 50 ? '50+' : res }
9
+ HAS_REACTION = proc { |i| i[:labels].any? || i[:comments].positive? }
10
+
11
+ def self.meters
12
+ @meters ||= []
13
+ end
14
+
15
+ def self.metric(name, *path, thresholds: nil, &block)
16
+ meters << Meter.new(name, path, thresholds, block || :itself.to_proc)
17
+ end
18
+
19
+ # TODO: Replace with TimeCalc when it'll be ready.
20
+ # TimeMath.month.decrease(now) => TimeCalc.now.-(1, :month)
21
+ T = TimeMath
22
+ now = Time.now
23
+
24
+ metric 'Downloads', :rubygems, :info, :downloads, thresholds: [5_000, 10_000]
25
+ metric 'Latest version',
26
+ :rubygems, :versions, 0, :created_at,
27
+ thresholds: [T.year.decrease(now), T.month.decrease(now, 2)],
28
+ &Time.method(:parse)
29
+
30
+ metric 'Used by', :rubygems, :reverse_dependencies, :count, thresholds: [10, 100]
31
+
32
+ metric 'Stars', :github, :repo, :stargazers_count, thresholds: [100, 500]
33
+ metric 'Forks', :github, :repo, :forks_count, thresholds: [5, 20]
34
+ metric 'Last commit',
35
+ :github, :last_commit, :commit, :committer, :date,
36
+ thresholds: [T.month.decrease(now, 4), T.month.decrease(now)]
37
+
38
+ metric('Open issues', :github, :open_issues, :count, &FIFTY_PLUS)
39
+ metric('...without reaction', :github, :open_issues, thresholds: [-20, -4]) { |issues|
40
+ issues.reject(&HAS_REACTION).count
41
+ }
42
+ metric('...last reaction',
43
+ :github, :open_issues,
44
+ thresholds: [T.month.decrease(now), T.week.decrease(now)]
45
+ ) { |issues| issues.detect(&HAS_REACTION)&.dig(:updated_at) }
46
+ metric('Closed issues', :github, :closed_issues, :count, &FIFTY_PLUS)
47
+ metric '...last closed', :github, :closed_issues, 0, :closed_at
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,78 @@
1
+ require 'pastel'
2
+
3
+ module WhatTheGem
4
+ class Stats
5
+ class Meter < Struct.new(:name, :path, :thresholds, :block)
6
+ def call(gem)
7
+ dig(gem, *path)&.then(&block)
8
+ .then { |val| Metric.new(name, val, *thresholds) }
9
+ end
10
+
11
+ private
12
+
13
+ def dig(value, first = nil, *rest)
14
+ return value unless first
15
+ case value
16
+ when nil
17
+ value
18
+ when ->(v) { first.is_a?(Symbol) && v.respond_to?(first) }
19
+ dig(value.public_send(first), *rest)
20
+ when Hash, Array
21
+ value.dig(first, *rest)
22
+ else
23
+ fail "Can't dig #{first} in #{value}"
24
+ end
25
+ end
26
+ end
27
+
28
+ class Metric
29
+ attr_reader :value, :name, :color
30
+
31
+ def initialize(name, value, *thresholds)
32
+ @name = name
33
+ @value = value
34
+ @color = deduce_color(*thresholds)
35
+ end
36
+
37
+ def format
38
+ '%20s: %s' % [name, colorized_value]
39
+ end
40
+
41
+ private
42
+
43
+ def colorized_value
44
+ Pastel.new.send(color, formatted_value)
45
+ end
46
+
47
+ def formatted_value
48
+ case value
49
+ when nil
50
+ '—'
51
+ when String
52
+ value
53
+ when Numeric
54
+ # 100000 => 100,000
55
+ value.to_s.chars.reverse.each_slice(3).to_a.map(&:join).join(',').reverse
56
+ when Date, Time
57
+ I.ago_text(value)
58
+ else
59
+ fail ArgumentError, "Unformattable #{value.inspect}"
60
+ end
61
+ end
62
+
63
+ def deduce_color(red = nil, yellow = nil)
64
+ return :dark if value.nil?
65
+ return :white if !yellow # no thresholds given
66
+
67
+ # special trick to tell "lower is better" from "higher is better" situations
68
+ val = red.is_a?(Numeric) && red < 0 ? -value : value
69
+
70
+ case
71
+ when val < red then :red
72
+ when val < yellow then :yellow
73
+ else :green
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,12 @@
1
+ module WhatTheGem
2
+ class Stats < Command
3
+ register description: 'Gem freshness and popularity stats'
4
+
5
+ def output
6
+ Definitions.meters.map { |meter| meter.call(gem).format }.join("\n")
7
+ end
8
+ end
9
+ end
10
+
11
+ require_relative 'stats/meters'
12
+ require_relative 'stats/definitions'
@@ -0,0 +1,53 @@
1
+ require 'liquid'
2
+ require 'rouge'
3
+
4
+ module WhatTheGem
5
+ class Template < Liquid::Template
6
+ module Filters
7
+ def paragraphs(text, num)
8
+ # split on markdown-alike paragraph break (\n\n), or "paragraph, then list" (\n* )
9
+ text.split(/\n(?:\n|(?= *\*))/).first(num).join("\n\n").gsub(/\n +/, "\n").strip
10
+ end
11
+
12
+ def reflow(text)
13
+ text.tr("\n", ' ')
14
+ end
15
+
16
+ def nfirst(array, num)
17
+ array.first(num)
18
+ end
19
+
20
+ # Consistently shift all markdown headers - ### - so they would be at least minlevel deep
21
+ def md_header_shift(text, minlevel)
22
+ current_min = text.scan(/^(\#+) /).flatten.map(&:length).min
23
+ return text if !current_min || current_min > minlevel
24
+ shift = minlevel - current_min
25
+ text.gsub(/^(\#+) /, '#' * shift + '\\1 ')
26
+ end
27
+
28
+ def rouge(text)
29
+ lexer = Rouge::Lexers::Ruby.new
30
+ Rouge::Formatters::Terminal256.new(Rouge::Themes::Base16::Monokai.new).format(lexer.lex(text))
31
+ end
32
+ end
33
+
34
+ def self.parse(src)
35
+ new.parse(src.chomp.gsub(/\n *({%.+?%})\n/, "\\1\n"))
36
+ end
37
+
38
+ def parse(src)
39
+ super(src, error_mode: :strict)
40
+ end
41
+
42
+ def render(data, **options)
43
+ super(Hm.(data).transform_keys(&:to_s).to_h, filters: [Filters], **options)
44
+ end
45
+
46
+ alias call render
47
+
48
+ def to_proc
49
+ # proc { |data| render(data) }
50
+ method(:call).to_proc
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,9 @@
1
+ module WhatTheGem
2
+ module TTYMarkdownPatch
3
+ def convert_br(el, opts)
4
+ opts[:result] << "\n"
5
+ end
6
+ end
7
+
8
+ ::TTY::Markdown::Parser.include TTYMarkdownPatch
9
+ end
@@ -0,0 +1,104 @@
1
+ require 'ripper'
2
+
3
+ module WhatTheGem
4
+ class Usage
5
+ # TODO:
6
+ # more robustly ignore "installation" section or just remove gem 'foo' / gem install
7
+ # -> select only ```ruby section from markdown (if they exist)?
8
+ # -> more removal patterns? Like `rake something`
9
+ #
10
+ class Extractor
11
+ REMOVE_BLOCKS = [
12
+ %{gem ['"]}, # gem install instructions
13
+ 'gem install',
14
+ 'bundle install',
15
+ 'rake ',
16
+
17
+ 'rails g ', # rails generator
18
+ 'git clone', # instructions to contribute
19
+
20
+ '\\$', # bash command
21
+ 'ruby (\S+)$', # run one Ruby command
22
+
23
+ 'Copyright ' # Sometimes they render license in ```
24
+ ]
25
+
26
+ REMOVE_BLOCKS_RE = /^#{REMOVE_BLOCKS.join('|')}/
27
+
28
+ extend I::Callable
29
+
30
+ class CodeBlock < Struct.new(:body)
31
+ def initialize(body)
32
+ super(try_sanitize(body))
33
+ end
34
+
35
+ def ruby?
36
+ # Ripper returns nil for anything but correct Ruby
37
+ #
38
+ # TODO: Unfortunately, Ripper is fixed to current version syntax, so trying this trick on
39
+ # Ruby 2.4 with something that uses Ruby 2.7 features will unhelpfully return "no, not Ruby"
40
+ #
41
+ # Maybe trying parser gem could lead to the same effect
42
+ !Ripper.sexp(body).nil?
43
+ end
44
+
45
+ def service?
46
+ body.match?(REMOVE_BLOCKS_RE)
47
+ end
48
+
49
+ def to_h
50
+ {body: body}
51
+ end
52
+
53
+ private
54
+
55
+ def try_sanitize(text)
56
+ text
57
+ .gsub(/^>> /, '') # Imitating work from IRB, TODO: various IRB/Pry patterns
58
+ .gsub(/^(=> )/, '# \\1') # Output in IRB, should be hidden as comment
59
+ .gsub(/^(Results: )/, '# \\1') # Output, in some gems
60
+ .gsub(/^( *)(\.{2,})/, '\\1# \\2') # "...and so on..." in code examples
61
+ end
62
+ end
63
+
64
+ def self.call(file)
65
+ new(file).call
66
+ end
67
+
68
+ def initialize(file)
69
+ @file = file
70
+ end
71
+
72
+ def call
73
+ code_blocks(file.read).map(&CodeBlock.method(:new)).reject(&:service?).select(&:ruby?)
74
+ end
75
+
76
+ private
77
+
78
+ attr_reader :file
79
+
80
+ memoize def format
81
+ case file.basename.to_s
82
+ when /\.(md|markdown)$/i
83
+ :markdown
84
+ else
85
+ :rdoc
86
+ end
87
+ end
88
+
89
+ def code_blocks(content)
90
+ __send__("#{format}_code_blocks", content)
91
+ end
92
+
93
+ def markdown_code_blocks(content)
94
+ I::Kramdowns.elements(content)
95
+ .select { |c| c.type == :codeblock }
96
+ .map(&:value).map(&:strip)
97
+ end
98
+
99
+ def rdoc_code_blocks(content)
100
+ I::RDocs.parts(content).grep(RDoc::Markup::Verbatim).map(&:text)
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,47 @@
1
+ module WhatTheGem
2
+ # TODO
3
+ # use Piotr's markdown formatter
4
+ #
5
+ # friendly report of "usage instructions not found"
6
+ #
7
+ # If gem not found locally -- fetch from GitHub
8
+ #
9
+ class Usage < Command
10
+ register description: 'Gem usage examples'
11
+
12
+ TEMPLATE = Template.parse(<<~USAGE)
13
+ {% for u in usage %}
14
+ {{ u.body | rouge }}
15
+ {% endfor %}
16
+ USAGE
17
+
18
+ README_NOT_FOUND = I::Pastel.red.bold("Can't find `README` locally or in repo to extract usage.")
19
+
20
+ def locals
21
+ {
22
+ usage: readme.then(&Extractor).first(2).map(&:to_h)
23
+ }
24
+ end
25
+
26
+ private
27
+
28
+ def output
29
+ return README_NOT_FOUND unless readme
30
+ super
31
+ end
32
+
33
+ memoize def readme
34
+ local_readme || github_readme
35
+ end
36
+
37
+ def local_readme
38
+ gem.specs.last&.gem_dir&.then(&Pathname.method(:new))&.glob('README{,.*}')&.first
39
+ end
40
+
41
+ def github_readme
42
+ gem.github&.readme
43
+ end
44
+ end
45
+ end
46
+
47
+ require_relative 'usage/extractor'
data/lib/whatthegem.rb ADDED
@@ -0,0 +1,12 @@
1
+ require 'hm'
2
+
3
+ module WhatTheGem
4
+ end
5
+
6
+ require_relative 'whatthegem/core_ext'
7
+ require_relative 'whatthegem/i'
8
+
9
+ require_relative 'whatthegem/gem'
10
+ require_relative 'whatthegem/template'
11
+ require_relative 'whatthegem/hobject'
12
+ require_relative 'whatthegem/commands'