lumin 0.1.0

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,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "did_you_mean"
4
+ require "prism"
5
+
6
+ module Lumin
7
+ class Config
8
+ class Validator
9
+ def initialize(data, registry)
10
+ @data = data
11
+ @registry = registry
12
+ end
13
+
14
+ def validate!
15
+ validate_top_level
16
+ validate_rules
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :data, :registry
22
+
23
+ def validate_top_level
24
+ unknown = data.keys - TOP_LEVEL_KEYS
25
+ raise ConfigError, "unknown configuration key: #{unknown.first}" unless unknown.empty?
26
+
27
+ raise ConfigError, "target_ruby must be a string" unless data["target_ruby"].is_a?(String)
28
+ validate_target_ruby(data["target_ruby"])
29
+ validate_string_array("include", data["include"])
30
+ validate_string_array("exclude", data["exclude"])
31
+ raise ConfigError, "rules must be a mapping" unless data["rules"].is_a?(Hash)
32
+ end
33
+
34
+ def validate_rules
35
+ data["rules"].each do |name, settings|
36
+ rule_class = registry.all[name]
37
+ raise ConfigError, unknown_rule_message(name) unless rule_class
38
+ raise ConfigError, "configuration for #{name} must be a mapping" unless settings.is_a?(Hash)
39
+
40
+ validate_rule_settings(name, settings, rule_class)
41
+ end
42
+ end
43
+
44
+ def validate_rule_settings(name, settings, rule_class)
45
+ allowed = RULE_CONTROL_KEYS + rule_class.default_config.keys.map(&:to_s)
46
+ unknown = settings.keys - allowed
47
+ raise ConfigError, "unknown setting #{unknown.first} for #{name}" unless unknown.empty?
48
+
49
+ settings.each do |key, value|
50
+ validate_leaf(name, value)
51
+ validate_control(name, key, value)
52
+ validate_parameter_type(name, key, value, rule_class)
53
+ end
54
+
55
+ message = rule_class.configuration_error(settings.transform_keys(&:to_sym))
56
+ raise ConfigError, "#{name} #{message}" if message
57
+ end
58
+
59
+ def validate_target_ruby(value)
60
+ version = Gem::Version.new(value)
61
+ raise ConfigError, "target_ruby must be 3.3 or newer" if version < Gem::Version.new("3.3")
62
+
63
+ prism_version = value.match?(/\A\d+\.\d+\z/) ? "#{value}.0" : value
64
+ Prism.parse("", version: prism_version)
65
+ rescue ArgumentError
66
+ raise ConfigError, "unsupported target_ruby: #{value.inspect}"
67
+ end
68
+
69
+ def validate_string_array(name, value)
70
+ valid = value.is_a?(Array) && value.all? { |entry| entry.is_a?(String) }
71
+ raise ConfigError, "#{name} must be an array of strings" unless valid
72
+ end
73
+
74
+ def validate_leaf(name, value)
75
+ valid = value.nil? || value.is_a?(String) || value.is_a?(Numeric) ||
76
+ value == true || value == false ||
77
+ (value.is_a?(Array) && value.all? { |entry| !entry.is_a?(Hash) && !entry.is_a?(Array) })
78
+ raise ConfigError, "#{name} settings must contain only scalar values or arrays" unless valid
79
+ end
80
+
81
+ def validate_control(name, key, value)
82
+ return unless RULE_CONTROL_KEYS.include?(key)
83
+ return if value == true || value == false
84
+
85
+ raise ConfigError, "#{name} #{key} must be true or false"
86
+ end
87
+
88
+ def validate_parameter_type(name, key, value, rule_class)
89
+ default = rule_class.default_config[key.to_sym]
90
+ return if default.nil? || RULE_CONTROL_KEYS.include?(key) || compatible_type?(value, default)
91
+
92
+ raise ConfigError, "#{name} #{key} must be a #{default.class}"
93
+ end
94
+
95
+ def compatible_type?(value, default)
96
+ return value == true || value == false if default == true || default == false
97
+ return value.is_a?(Integer) if default.is_a?(Integer)
98
+ return value.is_a?(Numeric) if default.is_a?(Numeric)
99
+
100
+ value.is_a?(default.class)
101
+ end
102
+
103
+ def unknown_rule_message(name)
104
+ suggestion = DidYouMean::SpellChecker.new(dictionary: registry.all.keys).correct(name).first
105
+ suffix = suggestion ? "; did you mean #{suggestion}?" : ""
106
+ "unknown rule: #{name}#{suffix}"
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require_relative "error"
6
+ require_relative "registry"
7
+
8
+ module Lumin
9
+ class Config
10
+ class ConfigError < Error; end
11
+
12
+ DEFAULTS = {
13
+ "target_ruby" => "3.3",
14
+ "include" => ["**/*.rb"],
15
+ "exclude" => ["vendor/**/*"],
16
+ "rules" => {}
17
+ }.freeze
18
+ TOP_LEVEL_KEYS = (DEFAULTS.keys + ["inherit_from"]).freeze
19
+ RULE_CONTROL_KEYS = %w[enabled autocorrect].freeze
20
+
21
+ attr_reader :data, :digest, :path
22
+
23
+ def self.load(path = ".lumin.yml", registry: Registry)
24
+ data = File.file?(path) ? Loader.new.load_file(File.expand_path(path)) : DEFAULTS
25
+ new(data, path: File.expand_path(path), registry: registry)
26
+ end
27
+
28
+ def self.from_hash(data = nil, registry: Registry, **values)
29
+ data ||= values
30
+ new(deep_merge(DEFAULTS, stringify_keys(data)), path: nil, registry: registry)
31
+ end
32
+
33
+ def self.deep_merge(base, override)
34
+ base.merge(override) do |_key, left, right|
35
+ left.is_a?(Hash) && right.is_a?(Hash) ? deep_merge(left, right) : right
36
+ end
37
+ end
38
+
39
+ def self.stringify_keys(value)
40
+ case value
41
+ when Hash then value.to_h { |key, child| [key.to_s, stringify_keys(child)] }
42
+ when Array then value.map { |child| stringify_keys(child) }
43
+ else value
44
+ end
45
+ end
46
+
47
+ def initialize(data, path:, registry: Registry)
48
+ @data = self.class.deep_merge(DEFAULTS, self.class.stringify_keys(data))
49
+ @path = path
50
+ Validator.new(@data, registry).validate!
51
+ @data = deep_freeze(@data)
52
+ @digest = Digest::SHA256.hexdigest(JSON.generate(canonical(@data)))
53
+ end
54
+
55
+ def target_ruby
56
+ data.fetch("target_ruby")
57
+ end
58
+
59
+ def include_patterns
60
+ data.fetch("include")
61
+ end
62
+
63
+ def exclude_patterns
64
+ data.fetch("exclude")
65
+ end
66
+
67
+ def rule_config(name)
68
+ data.fetch("rules").fetch(name, {}).transform_keys(&:to_sym)
69
+ end
70
+
71
+ def enabled_rules(registry = Registry)
72
+ registry.all.filter_map do |name, rule_class|
73
+ settings = rule_config(name)
74
+ [name, rule_class, settings] unless settings[:enabled] == false
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def deep_freeze(value)
81
+ case value
82
+ when Hash then value.each { |key, child| key.freeze; deep_freeze(child) }
83
+ when Array then value.each { |child| deep_freeze(child) }
84
+ end
85
+ value.freeze
86
+ end
87
+
88
+ def canonical(value)
89
+ case value
90
+ when Hash then value.keys.sort.to_h { |key| [key, canonical(value[key])] }
91
+ when Array then value.map { |child| canonical(child) }
92
+ else value
93
+ end
94
+ end
95
+ end
96
+ end
97
+
98
+ require_relative "config/loader"
99
+ require_relative "config/validator"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lumin
4
+ class Error < StandardError; end
5
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lumin
4
+ module Formatters
5
+ class Github
6
+ def format(offenses)
7
+ offenses.map do |offense|
8
+ location = offense.location
9
+ properties = "file=#{escape_property(offense.path)},line=#{location.start_line}," \
10
+ "col=#{location.start_column + 1},title=#{escape_property(offense.rule_name)}"
11
+ "::error #{properties}::#{escape_data(offense.message)}"
12
+ end.join("\n")
13
+ end
14
+
15
+ private
16
+
17
+ def escape_property(value)
18
+ escape_data(value).gsub(":", "%3A").gsub(",", "%2C")
19
+ end
20
+
21
+ def escape_data(value)
22
+ value.to_s.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Lumin
6
+ module Formatters
7
+ class Json
8
+ def format(offenses)
9
+ JSON.generate(offenses.map { |offense| serialize(offense) })
10
+ end
11
+
12
+ private
13
+
14
+ def serialize(offense)
15
+ location = offense.location
16
+ {
17
+ path: offense.path,
18
+ rule: offense.rule_name,
19
+ message: offense.message,
20
+ correctable: offense.correctable?,
21
+ safe: offense.safe,
22
+ location: {
23
+ start_line: location.start_line,
24
+ start_column: location.start_column,
25
+ end_line: location.end_line,
26
+ end_column: location.end_column
27
+ }
28
+ }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lumin
4
+ module Formatters
5
+ class Text
6
+ def format(offenses)
7
+ @lines_by_path = {}
8
+ offenses.map { |offense| format_offense(offense) }.join("\n")
9
+ end
10
+
11
+ private
12
+
13
+ def format_offense(offense)
14
+ location = offense.location
15
+ marker = offense.correctable? ? " [correctable]" : ""
16
+ heading = "#{offense.path}:#{location.start_line}:#{location.start_column + 1}: " \
17
+ "#{offense.rule_name}: #{offense.message}#{marker}"
18
+ source_line = offense.source_line || source_line_for(offense)
19
+ return heading unless source_line
20
+
21
+ width = [location.end_column - location.start_column, 1].max
22
+ "#{heading}\n #{source_line}\n #{' ' * location.start_column}#{'^' * width}"
23
+ end
24
+
25
+ def source_line_for(offense)
26
+ lines = @lines_by_path[offense.path] ||= File.readlines(offense.path, chomp: true)
27
+ lines[offense.location.start_line - 1]
28
+ rescue SystemCallError, EncodingError
29
+ nil
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "astel"
4
+
5
+ module Lumin
6
+ class Offense
7
+ attr_reader :path, :location, :rule_name, :message, :safe, :source_line, :correction
8
+
9
+ def initialize(path:, location:, rule_name:, message:, correctable: false, safe: true, source_line: nil,
10
+ correction: nil)
11
+ @path = path.to_s.freeze
12
+ @location = Astel::Location.from(location)
13
+ @rule_name = rule_name.to_s.freeze
14
+ @message = message.to_s.freeze
15
+ @correctable = correctable
16
+ @safe = safe
17
+ @source_line = source_line&.freeze
18
+ @correction = correction
19
+ end
20
+
21
+ def correctable?
22
+ @correctable
23
+ end
24
+
25
+ def with_path(path)
26
+ self.class.new(**to_h.merge(path: path))
27
+ end
28
+
29
+ def to_h
30
+ {
31
+ path: path,
32
+ location: location,
33
+ rule_name: rule_name,
34
+ message: message,
35
+ correctable: correctable?,
36
+ safe: safe,
37
+ source_line: source_line
38
+ }
39
+ end
40
+
41
+ def ==(other)
42
+ other.is_a?(self.class) && to_h == other.to_h
43
+ end
44
+ alias eql? ==
45
+
46
+ def hash
47
+ to_h.hash
48
+ end
49
+
50
+ def marshal_dump
51
+ to_h
52
+ end
53
+
54
+ def marshal_load(attributes)
55
+ initialize(**attributes)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thread"
4
+ require_relative "worker"
5
+
6
+ module Lumin
7
+ class ParallelExecutor
8
+ Assignment = Data.define(:worker, :paths)
9
+
10
+ def initialize(config:, registry:, count:, worker_class: Worker)
11
+ @config = config
12
+ @registry = registry
13
+ @count = count
14
+ @worker_class = worker_class
15
+ end
16
+
17
+ def call(files, **options)
18
+ if Process.respond_to?(:fork)
19
+ process_parallel(files, options)
20
+ else
21
+ thread_parallel(files, options)
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def process_parallel(files, options)
28
+ workers = []
29
+ queue = build_jobs(files)
30
+ active = {}
31
+ @count.times do
32
+ worker = spawn_worker(options)
33
+ workers << worker
34
+ assign(worker, queue.shift, active)
35
+ end
36
+
37
+ collect_process_results(active, queue, workers, options)
38
+ ensure
39
+ workers&.each { |worker| shutdown_worker(worker) }
40
+ end
41
+
42
+ def collect_process_results(active, queue, workers, options)
43
+ results = []
44
+ until active.empty?
45
+ ready, = IO.select(active.keys)
46
+ ready.each do |reader|
47
+ assignment = active.fetch(reader)
48
+ batch = receive_result(reader, assignment)
49
+ results.concat(batch)
50
+ if worker_failure?(batch)
51
+ retire_worker(assignment.worker, reader, active)
52
+ start_replacement(queue, active, workers, options) unless queue.empty?
53
+ elsif queue.empty?
54
+ stop_worker(assignment.worker, reader, active)
55
+ else
56
+ assign(assignment.worker, queue.shift, active)
57
+ end
58
+ end
59
+ end
60
+ results
61
+ end
62
+
63
+ def spawn_worker(options)
64
+ job_reader, job_writer = IO.pipe
65
+ result_reader, result_writer = IO.pipe
66
+ worker = @worker_class.new(config: @config, registry: @registry)
67
+ pid = fork do
68
+ job_writer.close
69
+ result_reader.close
70
+ run_child(worker, job_reader, result_writer, options)
71
+ end
72
+ job_reader.close
73
+ result_writer.close
74
+ { pid: pid, job_writer: job_writer, result_reader: result_reader }
75
+ end
76
+
77
+ def run_child(worker, job_reader, result_writer, options)
78
+ loop do
79
+ paths = Marshal.load(job_reader)
80
+ break if paths.nil?
81
+
82
+ Marshal.dump(paths.map { |path| worker.call(path, **options) }, result_writer)
83
+ result_writer.flush
84
+ end
85
+ exit! 0
86
+ rescue EOFError
87
+ exit! 0
88
+ rescue StandardError
89
+ exit! 1
90
+ end
91
+
92
+ def send_job(worker, path)
93
+ Marshal.dump(path, worker.fetch(:job_writer))
94
+ worker.fetch(:job_writer).flush
95
+ end
96
+
97
+ def assign(worker, paths, active)
98
+ send_job(worker, paths)
99
+ active[worker.fetch(:result_reader)] = Assignment.new(worker: worker, paths: paths)
100
+ end
101
+
102
+ def receive_result(reader, assignment)
103
+ Marshal.load(reader)
104
+ rescue EOFError, TypeError => error
105
+ assignment.paths.map do |path|
106
+ Worker::FileResult.new(
107
+ path: path,
108
+ offenses: [],
109
+ corrected: false,
110
+ diff: nil,
111
+ warnings: [],
112
+ error: "worker process ended before returning a result (#{error.class})",
113
+ content_digest: nil,
114
+ fingerprint: nil
115
+ )
116
+ end
117
+ end
118
+
119
+ def start_replacement(queue, active, workers, options)
120
+ worker = spawn_worker(options)
121
+ workers << worker
122
+ assign(worker, queue.shift, active)
123
+ end
124
+
125
+ def worker_failure?(batch)
126
+ batch.any? && batch.all? { |result| result.error&.start_with?("worker process") }
127
+ end
128
+
129
+ def build_jobs(files)
130
+ batch_size = [[files.length / (@count * 64), 1].max, 8].min
131
+ files.each_slice(batch_size).to_a
132
+ end
133
+
134
+ def stop_worker(worker, reader, active)
135
+ send_job(worker, nil)
136
+ worker.fetch(:job_writer).close
137
+ reader.close
138
+ wait_for(worker)
139
+ active.delete(reader)
140
+ end
141
+
142
+ def retire_worker(worker, reader, active)
143
+ worker.fetch(:job_writer).close unless worker.fetch(:job_writer).closed?
144
+ reader.close unless reader.closed?
145
+ wait_for(worker)
146
+ active.delete(reader)
147
+ end
148
+
149
+ def shutdown_worker(worker)
150
+ return if worker[:waited]
151
+
152
+ send_job(worker, nil) unless worker[:job_writer]&.closed?
153
+ rescue Errno::EPIPE, IOError
154
+ nil
155
+ ensure
156
+ worker[:job_writer]&.close unless worker[:job_writer]&.closed?
157
+ worker[:result_reader]&.close unless worker[:result_reader]&.closed?
158
+ wait_for(worker)
159
+ end
160
+
161
+ def wait_for(worker)
162
+ return if worker[:waited]
163
+
164
+ Process.wait(worker.fetch(:pid))
165
+ worker[:waited] = true
166
+ rescue Errno::ECHILD
167
+ worker[:waited] = true
168
+ end
169
+
170
+ def thread_parallel(files, options)
171
+ queue = Queue.new
172
+ build_jobs(files).each { |paths| queue << paths }
173
+ @count.times { queue << nil }
174
+ results = Queue.new
175
+ threads = Array.new(@count) do
176
+ Thread.new do
177
+ worker = @worker_class.new(config: @config, registry: @registry)
178
+ while (paths = queue.pop)
179
+ results << paths.map { |path| worker.call(path, **options) }
180
+ end
181
+ end
182
+ end
183
+ threads.each(&:join)
184
+ batches = Array.new(build_jobs(files).length) { results.pop }
185
+ batches.flatten
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "thread"
5
+ require_relative "error"
6
+
7
+ module Lumin
8
+ module Registry
9
+ BUILTIN_RULE_NAMES = %w[
10
+ Style/FrozenStringLiteral
11
+ Style/LineLength
12
+ Lint/NoMethodMissing
13
+ ].freeze
14
+
15
+ class << self
16
+ def register(rule_class)
17
+ classes << rule_class unless classes.include?(rule_class)
18
+ rule_class
19
+ end
20
+
21
+ def all
22
+ load_builtin_rules
23
+ ordered_classes.to_h { |rule_class| [name_for(rule_class), rule_class] }.freeze
24
+ end
25
+
26
+ def fetch(name)
27
+ all.fetch(name)
28
+ end
29
+
30
+ def name_for(rule_class)
31
+ name = rule_class.name
32
+ raise Error, "anonymous rule classes cannot be registered" unless name
33
+
34
+ name.sub(/\ALumin::Rules::/, "").gsub("::", "/")
35
+ end
36
+
37
+ def register_namespace(namespace, visited = Set.new)
38
+ return if visited.include?(namespace.object_id)
39
+
40
+ visited << namespace.object_id
41
+ namespace.constants(false).each do |constant|
42
+ value = namespace.const_get(constant)
43
+ if value.is_a?(Class) && defined?(Lumin::Rule) && value < Lumin::Rule
44
+ register(value)
45
+ elsif value.is_a?(Module)
46
+ register_namespace(value, visited)
47
+ end
48
+ end
49
+ end
50
+
51
+ def reset!
52
+ load_builtin_rules
53
+ builtin_mutex.synchronize do
54
+ @classes = []
55
+ @builtins_loaded = true
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def classes
62
+ @classes ||= []
63
+ end
64
+
65
+ def ordered_classes
66
+ builtins, extensions = classes.partition do |rule_class|
67
+ BUILTIN_RULE_NAMES.include?(name_for(rule_class))
68
+ end
69
+ builtins.sort_by! { |rule_class| BUILTIN_RULE_NAMES.index(name_for(rule_class)) }
70
+ builtins.concat(extensions)
71
+ end
72
+
73
+ def load_builtin_rules
74
+ return if @builtins_loaded
75
+
76
+ builtin_mutex.synchronize do
77
+ return if @builtins_loaded
78
+
79
+ require_relative "rule"
80
+ require_relative "rules/style/frozen_string_literal"
81
+ require_relative "rules/style/line_length"
82
+ require_relative "rules/lint/no_method_missing"
83
+ @builtins_loaded = true
84
+ end
85
+ end
86
+
87
+ def builtin_mutex
88
+ @builtin_mutex ||= Mutex.new
89
+ end
90
+ end
91
+ end
92
+ end