rubocop-schema-gen 0.1.0 → 0.1.1

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