rubocop-schema-gen 0.1.0 → 0.1.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.
@@ -4,19 +4,14 @@ require 'net/http'
4
4
 
5
5
  module RuboCop
6
6
  module Schema
7
- class Cache
8
- # @return [URI]
9
- attr_reader :base_url
10
-
11
- def initialize(cache_dir, base_url: nil, &event_handler)
7
+ class CachedHTTPClient
8
+ def initialize(cache_dir, &event_handler)
12
9
  @cache_dir = Pathname(cache_dir)
13
- @base_url = validate_url(base_url)
14
10
  @event_handler = event_handler
15
11
  end
16
12
 
17
13
  def get(url)
18
14
  url = URI(url)
19
- url = @base_url + url if @base_url && url.relative?
20
15
  validate_url url
21
16
 
22
17
  path = path_for_url(url)
@@ -24,7 +19,10 @@ module RuboCop
24
19
 
25
20
  path.parent.mkpath
26
21
  @event_handler&.call Event.new(type: :request)
27
- Net::HTTP.get(url).force_encoding(Encoding::UTF_8).tap(&path.method(:write))
22
+
23
+ res = Net::HTTP.get_response(url)
24
+ res.body = '' unless res.is_a? Net::HTTPOK
25
+ res.body.force_encoding(Encoding::UTF_8).tap(&path.method(:write))
28
26
  end
29
27
 
30
28
  private
@@ -34,13 +32,11 @@ module RuboCop
34
32
 
35
33
  raise ArgumentError, 'Expected an absolute URL' unless url.absolute?
36
34
  raise ArgumentError, 'Expected an HTTP URL' unless url.is_a? URI::HTTP
37
-
38
- url
39
35
  end
40
36
 
41
37
  # @param [URI::HTTP] url
42
38
  def path_for_url(url)
43
- @cache_dir + url.scheme + url.hostname + url.path[1..]
39
+ @cache_dir + url.scheme + url.hostname + url.path[1..-1]
44
40
  end
45
41
  end
46
42
  end
@@ -1,67 +1,97 @@
1
1
  require 'pathname'
2
2
  require 'json'
3
3
 
4
+ require 'rubocop/schema/document_loader'
5
+ require 'rubocop/schema/cached_http_client'
6
+ require 'rubocop/schema/generator'
7
+ require 'rubocop/schema/extension_spec'
8
+
4
9
  module RuboCop
5
10
  module Schema
6
11
  class CLI
7
12
  # @param [Pathname] working_dir
8
13
  # @param [Hash] env
9
14
  # @param [Array<String>] args
10
- def initialize(working_dir, env, args)
15
+ # @param [String] home
16
+ # @param [IO] out_file
17
+ # @param [IO] log_file
18
+ def initialize(working_dir: Dir.pwd, env: ENV, args: ARGV, home: Dir.home, out_file: nil, log_file: $stderr)
11
19
  @working_dir = Pathname(working_dir)
20
+ @home_dir = Pathname(home)
12
21
  @env = env
13
22
  @args = args
23
+ @out_file = out_file
24
+ @log_file = log_file
25
+ @out_path = args.first
26
+
27
+ raise ArgumentError, 'Cannot accept an out_file and an argument' if @out_file && @out_path
14
28
  end
15
29
 
16
30
  def run
31
+ lockfile_path = @working_dir + 'Gemfile.lock'
17
32
  fail "Cannot read #{lockfile_path}" unless lockfile_path.readable?
18
- fail 'RuboCop is not part of this project' unless lockfile.specs.any?
19
33
 
20
- schema = report_duration { Scraper.new(lockfile, cache).schema }
21
- puts JSON.pretty_generate schema
34
+ spec = ExtensionSpec.from_lockfile(lockfile_path)
35
+ fail 'RuboCop is not part of this project' if spec.empty?
36
+
37
+ assign_outfile(spec)
38
+ print "Generating #{@out_path} … " if @out_path
39
+
40
+ schema = report_duration(lowercase: @out_path) { Generator.new(spec.specs, document_loader).schema }
41
+ @out_file.puts JSON.pretty_generate schema
22
42
  end
23
43
 
24
44
  private
25
45
 
26
- def report_duration
27
- started = Time.now
46
+ def assign_outfile(spec)
47
+ return if @out_file
48
+
49
+ case @out_path
50
+ when '-'
51
+ @out_file = $stdout
52
+ @out_path = nil
53
+ when nil
54
+ @out_path = "#{spec}-config-schema.json"
55
+ end
56
+
57
+ @out_file ||= File.open(@out_path, 'w') # rubocop:disable Naming/MemoizedInstanceVariableName
58
+ end
59
+
60
+ def report_duration(lowercase: false)
61
+ started = Time.now
28
62
  yield
29
63
  ensure
30
64
  finished = Time.now
31
- handle_event Event.new(message: "Complete in #{(finished - started).round 1}s")
65
+ message = "Complete in #{(finished - started).round 1}s"
66
+ message.downcase! if lowercase
67
+ handle_event Event.new(message: message)
32
68
  end
33
69
 
34
70
  def handle_event(event)
35
71
  case event.type
36
72
  when :request
37
- $stderr << '.'
73
+ @log_file << '.'
38
74
  @line_dirty = true
39
75
  else
40
- $stderr.puts '' if @line_dirty
76
+ @log_file.puts '' if @line_dirty
41
77
  @line_dirty = false
42
- $stderr.puts event.message.to_s
78
+ @log_file.puts event.message.to_s
43
79
  end
44
80
  end
45
81
 
46
82
  def fail(msg)
47
- $stderr.puts msg.to_s
83
+ @log_file.puts msg.to_s
48
84
  exit 1
49
85
  end
50
86
 
51
- def lockfile
52
- @lockfile ||= LockfileInspector.new(lockfile_path)
53
- end
54
-
55
- def lockfile_path
56
- @lockfile_path ||= @working_dir + 'Gemfile.lock'
57
- end
58
-
59
- def cache
60
- @cache ||= Cache.new(cache_dir, &method(:handle_event))
61
- end
62
-
63
- def cache_dir
64
- @cache_dir ||= Pathname(Dir.home) + '.rubocop-schema-cache'
87
+ def document_loader
88
+ @document_loader ||=
89
+ DocumentLoader.new(
90
+ CachedHTTPClient.new(
91
+ @home_dir + '.rubocop-schema-cache',
92
+ &method(:handle_event)
93
+ )
94
+ )
65
95
  end
66
96
  end
67
97
  end
@@ -0,0 +1,54 @@
1
+ module RuboCop
2
+ module Schema
3
+ class CopInfoMerger
4
+ # @param [CopInfo] old
5
+ # @param [CopInfo] new
6
+ # @return [CopInfo]
7
+ def self.merge(old, new)
8
+ new(old, new).merged
9
+ end
10
+
11
+ # @return [CopInfo]
12
+ attr_reader :merged
13
+
14
+ def initialize(old, new)
15
+ @merged = old.dup
16
+ @new = new
17
+ merge
18
+ end
19
+
20
+ private
21
+
22
+ def merge
23
+ @merged.supports_autocorrect = @new.supports_autocorrect if @merged.supports_autocorrect.nil?
24
+ @merged.enabled_by_default = @new.enabled_by_default if @merged.enabled_by_default.nil?
25
+ @merged.attributes = merge_attribute_sets(@merged.attributes, @new.attributes)
26
+ @merged.description ||= @new.description
27
+ end
28
+
29
+ # @param [Array<Attribute>] old
30
+ # @param [Array<Attribute>] new
31
+ # @return [Array<Attribute>]
32
+ def merge_attribute_sets(old, new)
33
+ return old || new unless old && new
34
+
35
+ merged = old.map { |attr| [attr.name, attr] }.to_h
36
+ new.each do |attr|
37
+ merged[attr.name] = merged.key?(attr.name) ? merge_attributes(merged[attr.name], attr) : attr
38
+ end
39
+
40
+ merged.values
41
+ end
42
+
43
+ # @param [Attribute] old
44
+ # @param [Attribute] new
45
+ # @return [Attribute]
46
+ def merge_attributes(old, new)
47
+ old.dup.tap do |merged|
48
+ merged.type ||= new.type
49
+ merged.default ||= new.default
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -1,40 +1,83 @@
1
+ require 'rubocop/schema/helpers'
2
+
1
3
  module RuboCop
2
4
  module Schema
3
5
  class CopSchema
4
- # @return [Class<RuboCop::Cop::Base>]
5
- attr_reader :cop
6
+ include Helpers
7
+
8
+ KNOWN_TYPES = {
9
+ 'boolean' => 'boolean',
10
+ 'integer' => 'integer',
11
+ 'array' => 'array',
12
+ 'string' => 'string',
13
+ 'float' => 'number'
14
+ }.freeze
6
15
 
7
- # @param [Class<RuboCop::Cop::Base>] cop
8
16
  # @param [CopInfo] info
9
- def initialize(cop, info)
10
- raise ArgumentError unless cop.is_a?(Class) && cop < Cop::Base
17
+ def initialize(info)
18
+ @info = info.dup.freeze
19
+ @json = template('cop_schema')
20
+ generate
21
+ end
11
22
 
12
- @cop = cop
13
- @info = info
23
+ def as_json
24
+ @json
14
25
  end
15
26
 
16
- KNOWN_TYPES = Set.new(%w[boolean integer array string]).freeze
27
+ alias to_h as_json
17
28
 
18
- def as_json
19
- Schema.template('cop_schema').tap do |json|
20
- json['$comment'] = cop.documentation_url
21
- json['properties'] = props = json.fetch('properties').dup
22
-
23
- # AutoCorrect
24
- props['AutoCorrect'] = { 'type' => 'boolean' } if cop.support_autocorrect?
25
-
26
- if @info
27
- json['description'] = @info.description
28
- @info.attributes.each do |attr|
29
- next if attr.name.blank?
30
-
31
- props[attr.name] = prop = {}
32
- prop['description'] = "Default: #{attr.default}" unless attr.default.blank?
33
- prop['type'] = attr.type if KNOWN_TYPES.include? attr.type
34
- end
35
- end
29
+ def freeze
30
+ @json.freeze
31
+ super
32
+ end
33
+
34
+ private
35
+
36
+ # @return Hash
37
+ attr_reader :json
38
+
39
+ # @return CopInfo
40
+ attr_reader :info
41
+
42
+ def props
43
+ json['properties']
44
+ end
45
+
46
+ def generate
47
+ json['description'] = info.description unless info.description.nil?
48
+ assign_default_attributes
49
+ info.attributes&.each do |attr|
50
+ prop = props[attr.name] ||= {}
51
+ assign_attribute_type prop, attr
52
+ assign_attribute_description prop, attr
53
+ end
54
+ end
55
+
56
+ def assign_default_attributes
57
+ props['AutoCorrect'] = boolean if info.supports_autocorrect
58
+ props['Enabled']['description'] = "Default: #{info.enabled_by_default}" if info.enabled_by_default
59
+ end
60
+
61
+ # @param [Hash] prop
62
+ # @param [Attribute] attr
63
+ def assign_attribute_type(prop, attr)
64
+ if KNOWN_TYPES.key? attr.type&.downcase
65
+ prop['type'] ||= KNOWN_TYPES[attr.type.downcase] unless prop.key? '$ref'
66
+ elsif attr.type
67
+ prop['enum'] = attr.type.split(/\s*,\s*/)
36
68
  end
37
69
  end
70
+
71
+ # @param [Hash] prop
72
+ # @param [Attribute] attr
73
+ def assign_attribute_description(prop, attr)
74
+ prop['description'] = format_default(attr.default) unless attr.default.nil?
75
+ end
76
+
77
+ def format_default(default)
78
+ default = default.join(', ') if default.is_a? Array
79
+ "Default: #{default}"
80
+ end
38
81
  end
39
82
  end
40
83
  end
@@ -0,0 +1,46 @@
1
+ require 'rubocop/schema/value_objects'
2
+
3
+ module RuboCop
4
+ module Schema
5
+ class DefaultsRipper
6
+ EXCLUDE_ATTRIBUTES = Set.new(%w[Description VersionAdded VersionChanged StyleGuide]).freeze
7
+
8
+ TYPE_MAP = {
9
+ integer: [Integer],
10
+ number: [Float],
11
+ boolean: [TrueClass, FalseClass],
12
+ string: [String],
13
+ array: [Array]
14
+ }.freeze
15
+
16
+ # @return [Array<CopInfo>]
17
+ attr_reader :cops
18
+
19
+ # @param [Hash] defaults
20
+ def initialize(defaults)
21
+ @cops = defaults.map do |cop_name, attributes|
22
+ CopInfo.new(
23
+ name: cop_name,
24
+ description: attributes['Description'],
25
+ enabled_by_default: attributes['Enabled'] == true,
26
+ attributes: transform_attributes(attributes)
27
+ )
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def transform_attributes(hash)
34
+ hash.map do |name, default|
35
+ next if EXCLUDE_ATTRIBUTES.include? name
36
+
37
+ Attribute.new(
38
+ name: name,
39
+ type: TYPE_MAP.find { |_, v| v.any? { |c| default.is_a? c } }&.first&.to_s,
40
+ default: default
41
+ )
42
+ end.compact
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,41 @@
1
+ module RuboCop
2
+ module Schema
3
+ class DocumentLoader
4
+ DOCS_URL_TEMPLATE =
5
+ -'https://raw.githubusercontent.com/rubocop/%s/v%s/docs/modules/ROOT/pages/cops%s.adoc'
6
+ DEFAULTS_URL_TEMPLATE =
7
+ -'https://raw.githubusercontent.com/rubocop/%s/v%s/config/default.yml'
8
+
9
+ # @param [CachedHTTPClient] http_client
10
+ def initialize(http_client)
11
+ @http_client = http_client
12
+ @docs = {}
13
+ @defaults = {}
14
+ end
15
+
16
+ # @param [Spec] spec
17
+ def defaults(spec)
18
+ @defaults[spec] ||=
19
+ YAML.safe_load @http_client.get(url_for_defaults(spec)), [Regexp, Symbol]
20
+ end
21
+
22
+ # @param [Spec] spec
23
+ # @param [String] department
24
+ # @return [Asciidoctor::Document]
25
+ def doc(spec, department = nil)
26
+ @docs[[spec, department]] ||=
27
+ Asciidoctor.load @http_client.get url_for_doc(spec, department)
28
+ end
29
+
30
+ private
31
+
32
+ def url_for_doc(spec, department)
33
+ format DOCS_URL_TEMPLATE, spec.name, spec.version, department && "_#{department.to_s.downcase}"
34
+ end
35
+
36
+ def url_for_defaults(spec)
37
+ format DEFAULTS_URL_TEMPLATE, spec.name, spec.version
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,59 @@
1
+ require 'rubocop/schema/value_objects'
2
+
3
+ module RuboCop
4
+ module Schema
5
+ class ExtensionSpec
6
+ KNOWN_CLASSES = Set.new(
7
+ %w[
8
+ RuboCop
9
+ RuboCop::Rake
10
+ RuboCop::RSpec
11
+ RuboCop::Minitest
12
+ RuboCop::Performance
13
+ RuboCop::Rails
14
+ ]
15
+ ).freeze
16
+
17
+ KNOWN_GEMS = Set.new(['rubocop', *KNOWN_CLASSES.map { |e| e.sub('::', '-').downcase }]).freeze
18
+
19
+ def self.internal
20
+ @internal ||= new(KNOWN_CLASSES.map do |klass_name|
21
+ next unless Object.const_defined? klass_name
22
+
23
+ klass = Object.const_get(klass_name)
24
+ Spec.new(
25
+ name: klass.name.sub('::', '-').downcase,
26
+ version: (defined?(klass::VERSION) ? klass::VERSION : klass::Version::STRING)
27
+ )
28
+ end.compact)
29
+ end
30
+
31
+ def self.from_lockfile(lockfile)
32
+ new(Bundler::LockfileParser.new(lockfile.to_s).sources.flat_map do |source|
33
+ source.specs.map do |stub|
34
+ next unless KNOWN_GEMS.include? stub.name
35
+
36
+ Spec.new(
37
+ name: stub.name,
38
+ version: stub.version.to_s
39
+ )
40
+ end.compact
41
+ end)
42
+ end
43
+
44
+ attr_reader :specs
45
+
46
+ def initialize(specs)
47
+ @specs = specs.dup.sort_by(&:name).freeze
48
+ end
49
+
50
+ def to_s
51
+ @specs.join '-'
52
+ end
53
+
54
+ def empty?
55
+ @specs.empty?
56
+ end
57
+ end
58
+ end
59
+ end