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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +208 -0
- data/RULES.md +46 -0
- data/Rakefile +8 -0
- data/benchmark/README.md +33 -0
- data/benchmark/lumin_bench.rb +65 -0
- data/benchmark/rubocop.yml +11 -0
- data/exe/lumin +6 -0
- data/lib/lumin/autocorrector.rb +125 -0
- data/lib/lumin/cache.rb +202 -0
- data/lib/lumin/cache_manifest.rb +189 -0
- data/lib/lumin/cli.rb +139 -0
- data/lib/lumin/cli_formatters.rb +15 -0
- data/lib/lumin/config/loader.rb +52 -0
- data/lib/lumin/config/validator.rb +110 -0
- data/lib/lumin/config.rb +99 -0
- data/lib/lumin/error.rb +5 -0
- data/lib/lumin/formatters/github.rb +26 -0
- data/lib/lumin/formatters/json.rb +32 -0
- data/lib/lumin/formatters/text.rb +33 -0
- data/lib/lumin/offense.rb +58 -0
- data/lib/lumin/parallel_executor.rb +188 -0
- data/lib/lumin/registry.rb +92 -0
- data/lib/lumin/rule.rb +97 -0
- data/lib/lumin/rules/lint/no_method_missing.rb +18 -0
- data/lib/lumin/rules/style/frozen_string_literal.rb +55 -0
- data/lib/lumin/rules/style/line_length.rb +109 -0
- data/lib/lumin/runner.rb +189 -0
- data/lib/lumin/source_snapshot.rb +30 -0
- data/lib/lumin/version.rb +5 -0
- data/lib/lumin/worker.rb +103 -0
- data/lib/lumin.rb +40 -0
- metadata +90 -0
data/lib/lumin/cache.rb
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "cache_manifest"
|
|
6
|
+
require_relative "registry"
|
|
7
|
+
require_relative "source_snapshot"
|
|
8
|
+
require_relative "version"
|
|
9
|
+
|
|
10
|
+
module Lumin
|
|
11
|
+
class Cache
|
|
12
|
+
PathEntry = CacheManifest::PathEntry
|
|
13
|
+
|
|
14
|
+
MANIFEST = "manifest".freeze
|
|
15
|
+
LOCK_FILE = "manifest.lock".freeze
|
|
16
|
+
MANIFEST_VERSION = CacheManifest::VERSION
|
|
17
|
+
LEGACY_DIRECTORY_PATTERN = /\A[0-9a-f]{2}\z/
|
|
18
|
+
MANIFEST_TEMPORARY_PATTERN = /\A#{Regexp.escape(MANIFEST)}\.\d+\.tmp\z/
|
|
19
|
+
|
|
20
|
+
attr_reader :directory
|
|
21
|
+
|
|
22
|
+
def self.default_directory
|
|
23
|
+
base = ENV["XDG_CACHE_HOME"]
|
|
24
|
+
base = File.join(Dir.home, ".cache") if base.nil? || base.empty?
|
|
25
|
+
File.join(base, "lumin")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(config:, registry: Registry, directory: self.class.default_directory)
|
|
29
|
+
@config = config
|
|
30
|
+
@registry = registry
|
|
31
|
+
@directory = File.expand_path(directory)
|
|
32
|
+
@environment_digest = build_environment_digest
|
|
33
|
+
@manifest = nil
|
|
34
|
+
@pending_paths = {}
|
|
35
|
+
@pending_offenses = {}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def fetch(path, source = nil)
|
|
39
|
+
expanded_path = File.expand_path(path)
|
|
40
|
+
entry = @pending_paths.fetch(expanded_path) { manifest.paths[expanded_path] }
|
|
41
|
+
if valid_entry?(entry, path)
|
|
42
|
+
cached = cached_offenses(entry.key)
|
|
43
|
+
return rebase(cached, path) if cached
|
|
44
|
+
end
|
|
45
|
+
return unless source
|
|
46
|
+
|
|
47
|
+
cached = cached_offenses(key(source))
|
|
48
|
+
rebase(cached, path) if cached
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def put(path:, offenses:, source: nil, content_digest: nil, fingerprint: nil)
|
|
52
|
+
current_fingerprint = SourceSnapshot.fingerprint(path)
|
|
53
|
+
return false if fingerprint && fingerprint != current_fingerprint
|
|
54
|
+
|
|
55
|
+
cache_key = key_for_digest(content_digest || digest_source(source))
|
|
56
|
+
@pending_offenses[cache_key] = offenses
|
|
57
|
+
@pending_paths[File.expand_path(path)] = PathEntry.new(
|
|
58
|
+
fingerprint: current_fingerprint,
|
|
59
|
+
environment_digest: @environment_digest,
|
|
60
|
+
key: cache_key
|
|
61
|
+
)
|
|
62
|
+
offenses
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def flush
|
|
66
|
+
return false if @pending_paths.empty?
|
|
67
|
+
|
|
68
|
+
with_lock do
|
|
69
|
+
merged = merge_pending(CacheManifest.load(manifest_path))
|
|
70
|
+
compacted = compact(merged)
|
|
71
|
+
atomic_write(manifest_path) { |file| file.write(CacheManifest.serialize(compacted)) }
|
|
72
|
+
remove_legacy_entries
|
|
73
|
+
@manifest = compacted
|
|
74
|
+
@pending_paths.clear
|
|
75
|
+
@pending_offenses.clear
|
|
76
|
+
end
|
|
77
|
+
true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def clear
|
|
81
|
+
with_lock do
|
|
82
|
+
FileUtils.rm_f(manifest_path)
|
|
83
|
+
remove_legacy_entries
|
|
84
|
+
@manifest = CacheManifest.empty
|
|
85
|
+
@pending_paths.clear
|
|
86
|
+
@pending_offenses.clear
|
|
87
|
+
end
|
|
88
|
+
true
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def key(source)
|
|
92
|
+
key_for_digest(Digest::SHA256.hexdigest(source))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def key_for_digest(content_digest)
|
|
98
|
+
Digest::SHA256.hexdigest(content_digest + @environment_digest)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def digest_source(source)
|
|
102
|
+
raise ArgumentError, "source or content_digest is required" unless source
|
|
103
|
+
|
|
104
|
+
Digest::SHA256.hexdigest(source)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def build_environment_digest
|
|
108
|
+
enabled = @config.enabled_rules(@registry).map(&:first).sort.join("\0")
|
|
109
|
+
Digest::SHA256.hexdigest([@config.digest, VERSION, enabled].join("\0"))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def manifest
|
|
113
|
+
@manifest ||= CacheManifest.load(manifest_path)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def merge_pending(latest)
|
|
117
|
+
paths = latest.paths.dup
|
|
118
|
+
@pending_paths.each do |path, entry|
|
|
119
|
+
paths[path] = entry if current_fingerprint?(path, entry.fingerprint)
|
|
120
|
+
end
|
|
121
|
+
CacheManifest::Contents.new(paths: paths, offenses: latest.offenses.merge(@pending_offenses))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def compact(data)
|
|
125
|
+
paths = data.paths.select do |path, entry|
|
|
126
|
+
data.offenses.key?(entry.key) && current_fingerprint?(path, entry.fingerprint)
|
|
127
|
+
end
|
|
128
|
+
referenced_keys = paths.each_value.to_h { |entry| [entry.key, true] }
|
|
129
|
+
offenses = data.offenses.select { |cache_key, _entries| referenced_keys.key?(cache_key) }
|
|
130
|
+
CacheManifest::Contents.new(paths: paths, offenses: offenses)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def valid_entry?(entry, path)
|
|
134
|
+
entry.is_a?(PathEntry) &&
|
|
135
|
+
entry.environment_digest == @environment_digest &&
|
|
136
|
+
current_fingerprint?(path, entry.fingerprint)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def current_fingerprint?(path, fingerprint)
|
|
140
|
+
SourceSnapshot.fingerprint(path) == fingerprint
|
|
141
|
+
rescue ArgumentError, SystemCallError
|
|
142
|
+
false
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def cached_offenses(cache_key)
|
|
146
|
+
@pending_offenses.fetch(cache_key) { manifest.offenses[cache_key] }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def rebase(offenses, path)
|
|
150
|
+
offenses.map { |offense| offense.with_path(path) }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def with_lock
|
|
154
|
+
FileUtils.mkdir_p(directory)
|
|
155
|
+
File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
|
|
156
|
+
lock.flock(File::LOCK_EX)
|
|
157
|
+
yield
|
|
158
|
+
ensure
|
|
159
|
+
lock.flock(File::LOCK_UN)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def atomic_write(target)
|
|
164
|
+
temporary = "#{target}.#{Process.pid}.tmp"
|
|
165
|
+
File.open(temporary, "wb", 0o600) { |file| yield file }
|
|
166
|
+
File.rename(temporary, target)
|
|
167
|
+
ensure
|
|
168
|
+
FileUtils.rm_f(temporary) if defined?(temporary) && temporary
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def remove_legacy_entries
|
|
172
|
+
Dir.children(directory).each do |child|
|
|
173
|
+
path = File.join(directory, child)
|
|
174
|
+
status = File.lstat(path)
|
|
175
|
+
if child.match?(LEGACY_DIRECTORY_PATTERN) && status.directory?
|
|
176
|
+
remove_legacy_content_files(path, child)
|
|
177
|
+
elsif child.match?(MANIFEST_TEMPORARY_PATTERN) && status.file?
|
|
178
|
+
FileUtils.rm_f(path)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
rescue SystemCallError
|
|
182
|
+
nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def remove_legacy_content_files(directory_path, prefix)
|
|
186
|
+
pattern = /\A#{prefix}[0-9a-f]{62}(?:\.\d+\.tmp)?\z/
|
|
187
|
+
Dir.children(directory_path).each do |entry|
|
|
188
|
+
path = File.join(directory_path, entry)
|
|
189
|
+
FileUtils.rm_f(path) if entry.match?(pattern) && File.lstat(path).file?
|
|
190
|
+
end
|
|
191
|
+
Dir.rmdir(directory_path) if Dir.empty?(directory_path)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def manifest_path
|
|
195
|
+
File.join(directory, MANIFEST)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def lock_path
|
|
199
|
+
File.join(directory, LOCK_FILE)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "offense"
|
|
5
|
+
|
|
6
|
+
module Lumin
|
|
7
|
+
class CacheManifest
|
|
8
|
+
PathEntry = Data.define(:fingerprint, :environment_digest, :key)
|
|
9
|
+
Contents = Data.define(:paths, :offenses)
|
|
10
|
+
|
|
11
|
+
VERSION = 2
|
|
12
|
+
DIGEST_PATTERN = /\A[0-9a-f]{64}\z/
|
|
13
|
+
LOCATION_KEYS = %w[start_offset end_offset start_line start_column end_line end_column].freeze
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def load(path)
|
|
17
|
+
return empty unless File.file?(path)
|
|
18
|
+
|
|
19
|
+
payload = File.open(path, "rb") do |file|
|
|
20
|
+
JSON.parse(file.read, create_additions: false, max_nesting: 20)
|
|
21
|
+
end
|
|
22
|
+
return empty unless payload.is_a?(Hash) && payload["version"] == VERSION
|
|
23
|
+
|
|
24
|
+
decode(payload)
|
|
25
|
+
rescue StandardError
|
|
26
|
+
empty
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def serialize(data)
|
|
30
|
+
JSON.generate(
|
|
31
|
+
"version" => VERSION,
|
|
32
|
+
"paths" => data.paths.map { |path, entry| encode_path_entry(path, entry) },
|
|
33
|
+
"offenses" => data.offenses.transform_values do |entries|
|
|
34
|
+
entries.map { |entry| encode_offense(entry) }
|
|
35
|
+
end
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def empty
|
|
40
|
+
Contents.new(paths: {}, offenses: {})
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def encode_path_entry(path, entry)
|
|
46
|
+
{
|
|
47
|
+
"path" => encode_string(path),
|
|
48
|
+
"fingerprint" => entry.fingerprint,
|
|
49
|
+
"environment_digest" => entry.environment_digest,
|
|
50
|
+
"key" => entry.key
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def encode_offense(offense)
|
|
55
|
+
{
|
|
56
|
+
"location" => offense.location.to_h.transform_keys(&:to_s),
|
|
57
|
+
"rule_name" => encode_string(offense.rule_name),
|
|
58
|
+
"message" => encode_string(offense.message),
|
|
59
|
+
"correctable" => offense.correctable?,
|
|
60
|
+
"safe" => offense.safe,
|
|
61
|
+
"source_line" => offense.source_line && encode_string(offense.source_line)
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def decode(payload)
|
|
66
|
+
raw_paths = payload["paths"].is_a?(Array) ? payload["paths"] : []
|
|
67
|
+
raw_offenses = payload["offenses"].is_a?(Hash) ? payload["offenses"] : {}
|
|
68
|
+
Contents.new(paths: decode_paths(raw_paths), offenses: decode_offenses(raw_offenses))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def decode_paths(raw_paths)
|
|
72
|
+
raw_paths.each_with_object({}) do |value, paths|
|
|
73
|
+
next unless value.is_a?(Hash)
|
|
74
|
+
|
|
75
|
+
path = decode_string(value["path"])
|
|
76
|
+
next unless path && !path.b.include?("\0".b)
|
|
77
|
+
|
|
78
|
+
entry = decode_path_entry(value)
|
|
79
|
+
paths[path] = entry if entry
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def decode_path_entry(value)
|
|
84
|
+
return unless value.is_a?(Hash)
|
|
85
|
+
|
|
86
|
+
fingerprint = value["fingerprint"]
|
|
87
|
+
environment_digest = value["environment_digest"]
|
|
88
|
+
cache_key = value["key"]
|
|
89
|
+
return unless valid_fingerprint?(fingerprint)
|
|
90
|
+
return unless valid_digest?(environment_digest) && valid_digest?(cache_key)
|
|
91
|
+
|
|
92
|
+
PathEntry.new(fingerprint: fingerprint.freeze, environment_digest: environment_digest, key: cache_key)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def decode_offenses(raw_offenses)
|
|
96
|
+
raw_offenses.each_with_object({}) do |(cache_key, values), offenses|
|
|
97
|
+
next unless valid_digest?(cache_key) && values.is_a?(Array)
|
|
98
|
+
|
|
99
|
+
entries = values.map { |value| decode_offense(value) }
|
|
100
|
+
offenses[cache_key] = entries if entries.none?(&:nil?)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def decode_offense(value)
|
|
105
|
+
return unless value.is_a?(Hash)
|
|
106
|
+
|
|
107
|
+
location = decode_location(value["location"])
|
|
108
|
+
rule_name = decode_string(value["rule_name"])
|
|
109
|
+
message = decode_string(value["message"])
|
|
110
|
+
correctable = value["correctable"]
|
|
111
|
+
safe = value["safe"]
|
|
112
|
+
source_line = value["source_line"] && decode_string(value["source_line"])
|
|
113
|
+
return unless location && rule_name && message
|
|
114
|
+
return unless boolean?(correctable) && boolean?(safe)
|
|
115
|
+
return if value["source_line"] && !source_line
|
|
116
|
+
|
|
117
|
+
Offense.new(
|
|
118
|
+
path: "",
|
|
119
|
+
location: location,
|
|
120
|
+
rule_name: rule_name,
|
|
121
|
+
message: message,
|
|
122
|
+
correctable: correctable,
|
|
123
|
+
safe: safe,
|
|
124
|
+
source_line: source_line
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def decode_location(value)
|
|
129
|
+
return unless value.is_a?(Hash)
|
|
130
|
+
|
|
131
|
+
attributes = LOCATION_KEYS.to_h { |key| [key.to_sym, value[key]] }
|
|
132
|
+
return unless attributes.values.all? { |part| part.is_a?(Integer) }
|
|
133
|
+
return unless valid_location?(attributes)
|
|
134
|
+
|
|
135
|
+
Astel::Location.new(**attributes)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def valid_location?(location)
|
|
139
|
+
return false if location[:start_offset].negative? || location[:start_offset] > location[:end_offset]
|
|
140
|
+
return false if location[:start_line] < 1 || location[:end_line] < location[:start_line]
|
|
141
|
+
return false if location[:start_column].negative? || location[:end_column].negative?
|
|
142
|
+
|
|
143
|
+
location[:end_line] != location[:start_line] || location[:end_column] >= location[:start_column]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def valid_fingerprint?(value)
|
|
147
|
+
return false unless value.is_a?(Array) && value.length == 6
|
|
148
|
+
return false unless value.all? { |part| part.is_a?(Integer) }
|
|
149
|
+
|
|
150
|
+
size, _mtime, mtime_nsec, _ctime, ctime_nsec, inode = value
|
|
151
|
+
size >= 0 && inode >= 0 && [mtime_nsec, ctime_nsec].all? { |part| part.between?(0, 999_999_999) }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def valid_digest?(value)
|
|
155
|
+
valid_json_string?(value) && value.match?(DIGEST_PATTERN)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def boolean?(value)
|
|
159
|
+
value == true || value == false
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def encode_string(value)
|
|
163
|
+
{
|
|
164
|
+
"bytes" => [value.b].pack("m0"),
|
|
165
|
+
"encoding" => value.encoding.name
|
|
166
|
+
}
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def decode_string(value)
|
|
170
|
+
return unless value.is_a?(Hash)
|
|
171
|
+
|
|
172
|
+
bytes = value["bytes"]
|
|
173
|
+
encoding_name = value["encoding"]
|
|
174
|
+
return unless valid_json_string?(bytes) && valid_json_string?(encoding_name)
|
|
175
|
+
|
|
176
|
+
encoding = Encoding.find(encoding_name)
|
|
177
|
+
return unless encoding.is_a?(Encoding)
|
|
178
|
+
|
|
179
|
+
bytes.unpack1("m0").force_encoding(encoding)
|
|
180
|
+
rescue ArgumentError, TypeError
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def valid_json_string?(value)
|
|
185
|
+
value.is_a?(String) && value.encoding == Encoding::UTF_8 && value.valid_encoding?
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
data/lib/lumin/cli.rb
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Lumin
|
|
6
|
+
class CLI
|
|
7
|
+
FORMATTER_FEATURES = {
|
|
8
|
+
"text" => "formatters/text",
|
|
9
|
+
"json" => "formatters/json",
|
|
10
|
+
"github" => "formatters/github"
|
|
11
|
+
}.freeze
|
|
12
|
+
autoload :FORMATTERS, File.expand_path("cli_formatters", __dir__)
|
|
13
|
+
|
|
14
|
+
def self.start(argv = ARGV, stdout: $stdout, stderr: $stderr)
|
|
15
|
+
new(stdout: stdout, stderr: stderr).run(argv)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(stdout:, stderr:)
|
|
19
|
+
@stdout = stdout
|
|
20
|
+
@stderr = stderr
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run(argv)
|
|
24
|
+
options = default_options
|
|
25
|
+
arguments = argv.dup
|
|
26
|
+
parser = option_parser(options)
|
|
27
|
+
parser.parse!(arguments)
|
|
28
|
+
if options[:help]
|
|
29
|
+
@stdout.puts(options[:help])
|
|
30
|
+
return 0
|
|
31
|
+
end
|
|
32
|
+
return print_version if options[:version]
|
|
33
|
+
raise OptionParser::InvalidOption, "--dry-run requires --fix" if options[:dry_run] && !options[:fix]
|
|
34
|
+
|
|
35
|
+
load_runtime
|
|
36
|
+
execute(options, arguments)
|
|
37
|
+
rescue OptionParser::ParseError, ArgumentError => error
|
|
38
|
+
@stderr.puts("lumin: #{error.message}")
|
|
39
|
+
2
|
|
40
|
+
rescue StandardError => error
|
|
41
|
+
@stderr.puts("lumin: internal error: #{error.class}: #{error.message}")
|
|
42
|
+
2
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def execute(options, arguments)
|
|
48
|
+
config = Config.load(options[:config])
|
|
49
|
+
runner = Runner.new(
|
|
50
|
+
config: config,
|
|
51
|
+
parallel: options[:parallel],
|
|
52
|
+
cache: options[:cache],
|
|
53
|
+
cache_dir: options[:cache_dir]
|
|
54
|
+
)
|
|
55
|
+
if options[:clear_cache]
|
|
56
|
+
runner.clear_cache
|
|
57
|
+
return 0
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
result = runner.run(arguments, fix: options[:fix], unsafe: options[:unsafe], dry_run: options[:dry_run])
|
|
61
|
+
result.diffs.each { |diff| @stdout.puts(diff) }
|
|
62
|
+
unless options[:dry_run]
|
|
63
|
+
formatted = formatter(options[:format]).new.format(result.offenses)
|
|
64
|
+
@stdout.puts(formatted) unless formatted.empty?
|
|
65
|
+
end
|
|
66
|
+
result.warnings.each { |warning| @stderr.puts("warning: #{warning}") }
|
|
67
|
+
result.errors.each { |error| @stderr.puts(error) }
|
|
68
|
+
return 2 unless result.errors.empty?
|
|
69
|
+
|
|
70
|
+
result.offenses.empty? ? 0 : 1
|
|
71
|
+
rescue Config::ConfigError => error
|
|
72
|
+
@stderr.puts("lumin: #{error.message}")
|
|
73
|
+
2
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def default_options
|
|
77
|
+
{
|
|
78
|
+
config: ".lumin.yml",
|
|
79
|
+
format: "text",
|
|
80
|
+
parallel: nil,
|
|
81
|
+
cache: true,
|
|
82
|
+
cache_dir: nil,
|
|
83
|
+
clear_cache: false,
|
|
84
|
+
fix: false,
|
|
85
|
+
unsafe: false,
|
|
86
|
+
dry_run: false,
|
|
87
|
+
help: nil,
|
|
88
|
+
version: false
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def option_parser(options)
|
|
93
|
+
OptionParser.new do |parser|
|
|
94
|
+
parser.banner = "Usage: lumin [options] [paths...]"
|
|
95
|
+
parser.on("-c", "--config PATH", "configuration file") { |value| options[:config] = value }
|
|
96
|
+
parser.on("-f", "--format NAME", FORMATTER_FEATURES.keys, "output format") do |value|
|
|
97
|
+
options[:format] = value
|
|
98
|
+
end
|
|
99
|
+
parser.on("--parallel[=N]", Integer, "number of worker processes") do |value|
|
|
100
|
+
options[:parallel] = value || processor_count
|
|
101
|
+
end
|
|
102
|
+
parser.on("--no-parallel", "disable parallel execution") { options[:parallel] = false }
|
|
103
|
+
parser.on("--[no-]cache", "enable or disable result cache") { |value| options[:cache] = value }
|
|
104
|
+
parser.on("--cache-dir PATH", "cache directory") { |value| options[:cache_dir] = value }
|
|
105
|
+
parser.on("--clear-cache", "remove cached results") { options[:clear_cache] = true }
|
|
106
|
+
parser.on("--fix", "apply safe corrections") { options[:fix] = true }
|
|
107
|
+
parser.on("--fix-unsafe", "apply safe and unsafe corrections") do
|
|
108
|
+
options[:fix] = true
|
|
109
|
+
options[:unsafe] = true
|
|
110
|
+
end
|
|
111
|
+
parser.on("--dry-run", "show corrections as a unified diff") { options[:dry_run] = true }
|
|
112
|
+
parser.on("-v", "--version", "print the version") { options[:version] = true }
|
|
113
|
+
parser.on("-h", "--help", "show this help") do
|
|
114
|
+
options[:help] = parser.to_s
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def print_version
|
|
120
|
+
require_relative "version"
|
|
121
|
+
@stdout.puts(VERSION)
|
|
122
|
+
0
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def processor_count
|
|
126
|
+
require "etc"
|
|
127
|
+
Etc.nprocessors
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def load_runtime
|
|
131
|
+
require_relative "../lumin"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def formatter(name)
|
|
135
|
+
require_relative FORMATTER_FEATURES.fetch(name)
|
|
136
|
+
Formatters.const_get(name.capitalize, false)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "formatters/text"
|
|
4
|
+
require_relative "formatters/json"
|
|
5
|
+
require_relative "formatters/github"
|
|
6
|
+
|
|
7
|
+
module Lumin
|
|
8
|
+
class CLI
|
|
9
|
+
FORMATTERS = {
|
|
10
|
+
"text" => Formatters::Text,
|
|
11
|
+
"json" => Formatters::Json,
|
|
12
|
+
"github" => Formatters::Github
|
|
13
|
+
}.freeze
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Lumin
|
|
6
|
+
class Config
|
|
7
|
+
class Loader
|
|
8
|
+
def initialize
|
|
9
|
+
@loading = []
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def load_file(path)
|
|
13
|
+
expanded = File.expand_path(path)
|
|
14
|
+
if @loading.include?(expanded)
|
|
15
|
+
raise ConfigError, "configuration inheritance cycle: #{expanded}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
@loading << expanded
|
|
19
|
+
raw = YAML.safe_load_file(expanded, permitted_classes: [], permitted_symbols: [], aliases: false) || {}
|
|
20
|
+
raise ConfigError, "configuration root must be a mapping" unless raw.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
raw = Config.stringify_keys(raw)
|
|
23
|
+
inherited = Array(raw.delete("inherit_from")).reduce({}) do |merged, reference|
|
|
24
|
+
Config.deep_merge(merged, load_file(resolve(reference, File.dirname(expanded))))
|
|
25
|
+
end
|
|
26
|
+
Config.deep_merge(inherited, raw)
|
|
27
|
+
rescue Psych::Exception => error
|
|
28
|
+
raise ConfigError, "cannot load #{expanded}: #{error.message}"
|
|
29
|
+
ensure
|
|
30
|
+
@loading.delete(expanded) if expanded
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def resolve(reference, directory)
|
|
36
|
+
raise ConfigError, "inherit_from entries must be strings" unless reference.is_a?(String)
|
|
37
|
+
|
|
38
|
+
candidate = File.expand_path(reference, directory)
|
|
39
|
+
return candidate if File.file?(candidate)
|
|
40
|
+
|
|
41
|
+
specification = Gem::Specification.find_by_name(reference)
|
|
42
|
+
gem_config = [File.join(specification.full_gem_path, ".lumin.yml"),
|
|
43
|
+
File.join(specification.full_gem_path, "config", "lumin.yml")].find { |path| File.file?(path) }
|
|
44
|
+
return gem_config if gem_config
|
|
45
|
+
|
|
46
|
+
raise ConfigError, "#{reference} does not provide a Lumin configuration"
|
|
47
|
+
rescue Gem::MissingSpecError
|
|
48
|
+
raise ConfigError, "cannot resolve inherited configuration: #{reference}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|