whatthegem 0.0.1

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