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/rule.rb
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "astel"
|
|
4
|
+
require_relative "offense"
|
|
5
|
+
require_relative "registry"
|
|
6
|
+
|
|
7
|
+
module Lumin
|
|
8
|
+
class Rule
|
|
9
|
+
class << self
|
|
10
|
+
def inherited(subclass)
|
|
11
|
+
super
|
|
12
|
+
Registry.register(subclass)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def on(node_type, &block)
|
|
16
|
+
raise ArgumentError, "callback is required" unless block
|
|
17
|
+
|
|
18
|
+
subscriptions << [node_type.to_sym, block]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def on_new_source(&block)
|
|
22
|
+
raise ArgumentError, "callback is required" unless block
|
|
23
|
+
|
|
24
|
+
source_callbacks << block
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def subscriptions
|
|
28
|
+
@subscriptions ||= []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def source_callbacks
|
|
32
|
+
@source_callbacks ||= []
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def default_config(values = nil)
|
|
36
|
+
@default_config = symbolize(values).freeze if values
|
|
37
|
+
@default_config ||= {}.freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def safe(value = nil)
|
|
41
|
+
@safe = value unless value.nil?
|
|
42
|
+
@safe.nil? ? true : @safe
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def validate_config(&block)
|
|
46
|
+
@config_validator = block if block
|
|
47
|
+
@config_validator
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def configuration_error(settings)
|
|
51
|
+
validator = validate_config
|
|
52
|
+
validator&.call(settings)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def symbolize(hash)
|
|
58
|
+
hash.to_h { |key, value| [key.to_sym, value] }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
attr_reader :config, :offenses, :source
|
|
63
|
+
|
|
64
|
+
def initialize(config = {})
|
|
65
|
+
@config = self.class.default_config.merge(config.transform_keys(&:to_sym)).freeze
|
|
66
|
+
@offenses = []
|
|
67
|
+
@source = nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def begin_source(source)
|
|
71
|
+
@source = source
|
|
72
|
+
@offenses = []
|
|
73
|
+
self.class.source_callbacks.each { |callback| instance_exec(source, &callback) }
|
|
74
|
+
self
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def dispatch(node_type, node)
|
|
78
|
+
self.class.subscriptions.each do |subscribed_type, callback|
|
|
79
|
+
instance_exec(node, &callback) if subscribed_type == node_type
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def add_offense(location, message:, &correction)
|
|
84
|
+
correction = nil if config[:autocorrect] == false
|
|
85
|
+
normalized = Astel::Location.from(location)
|
|
86
|
+
offenses << Offense.new(
|
|
87
|
+
path: source.path,
|
|
88
|
+
location: normalized,
|
|
89
|
+
rule_name: Registry.name_for(self.class),
|
|
90
|
+
message: message,
|
|
91
|
+
correctable: !correction.nil?,
|
|
92
|
+
safe: self.class.safe,
|
|
93
|
+
correction: correction
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../rule"
|
|
4
|
+
|
|
5
|
+
module Lumin
|
|
6
|
+
module Rules
|
|
7
|
+
module Lint
|
|
8
|
+
class NoMethodMissing < Rule
|
|
9
|
+
MSG = "avoid defining method_missing"
|
|
10
|
+
PATTERN = Astel::NodePattern.compile("(def_node name: :method_missing)")
|
|
11
|
+
|
|
12
|
+
on :def_node do |node|
|
|
13
|
+
add_offense(node.name_loc, message: MSG) if PATTERN.match?(node)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../rule"
|
|
4
|
+
|
|
5
|
+
module Lumin
|
|
6
|
+
module Rules
|
|
7
|
+
module Style
|
|
8
|
+
class FrozenStringLiteral < Rule
|
|
9
|
+
MSG = "frozen_string_literal magic comment is missing"
|
|
10
|
+
BOM = "\uFEFF"
|
|
11
|
+
MAGIC_COMMENT_PATTERN =
|
|
12
|
+
/\A#[^\S\n]*frozen_string_literal[^\S\n]*:[^\S\n]*true\b/
|
|
13
|
+
|
|
14
|
+
on_new_source do |source|
|
|
15
|
+
code = source.source
|
|
16
|
+
next if magic_comment?(code)
|
|
17
|
+
|
|
18
|
+
add_offense(source.bof, message: MSG) do |rewriter|
|
|
19
|
+
offset = code.start_with?(BOM) ? BOM.bytesize : 0
|
|
20
|
+
line = 1
|
|
21
|
+
if code.getbyte(offset) == 0x23 && code.getbyte(offset + 1) == 0x21
|
|
22
|
+
newline = offset + 2
|
|
23
|
+
newline += 1 while newline < code.bytesize && code.getbyte(newline) != 0x0A
|
|
24
|
+
if newline < code.bytesize
|
|
25
|
+
offset = newline + 1
|
|
26
|
+
line = 2
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
location = Astel::Location.point(
|
|
30
|
+
offset: offset,
|
|
31
|
+
line: line,
|
|
32
|
+
column: 0
|
|
33
|
+
)
|
|
34
|
+
rewriter.insert_before(location, "# frozen_string_literal: true\n\n")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def magic_comment?(code)
|
|
41
|
+
lines_checked = 0
|
|
42
|
+
code.each_line do |line|
|
|
43
|
+
candidate = line.start_with?(BOM) ? line.delete_prefix(BOM) : line
|
|
44
|
+
return true if candidate.match?(MAGIC_COMMENT_PATTERN)
|
|
45
|
+
|
|
46
|
+
lines_checked += 1
|
|
47
|
+
break if lines_checked == 2
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../rule"
|
|
4
|
+
|
|
5
|
+
module Lumin
|
|
6
|
+
module Rules
|
|
7
|
+
module Style
|
|
8
|
+
class LineLength < Rule
|
|
9
|
+
MSG = "line is too long"
|
|
10
|
+
CHARACTER_BOUNDARY_PATTERN = /\G/
|
|
11
|
+
default_config max: 120
|
|
12
|
+
validate_config do |settings|
|
|
13
|
+
"max must be greater than zero" if settings.fetch(:max, 120) <= 0
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
on_new_source do |source|
|
|
17
|
+
code = source.source
|
|
18
|
+
max = Integer(config.fetch(:max))
|
|
19
|
+
|
|
20
|
+
if code.ascii_only?
|
|
21
|
+
inspect_ascii_lines(code, max)
|
|
22
|
+
else
|
|
23
|
+
inspect_multibyte_lines(code, max)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def inspect_ascii_lines(code, max)
|
|
30
|
+
byte_offset = 0
|
|
31
|
+
line_number = 1
|
|
32
|
+
source_bytesize = code.bytesize
|
|
33
|
+
|
|
34
|
+
while byte_offset < source_bytesize
|
|
35
|
+
newline = code.index("\n", byte_offset)
|
|
36
|
+
raw_end_byte = newline || source_bytesize
|
|
37
|
+
trailing_cr = raw_end_byte > byte_offset && code.getbyte(raw_end_byte - 1) == 0x0D
|
|
38
|
+
content_end_byte = raw_end_byte - (trailing_cr ? 1 : 0)
|
|
39
|
+
content_length = content_end_byte - byte_offset
|
|
40
|
+
|
|
41
|
+
if content_length > max
|
|
42
|
+
add_line_offense(byte_offset + max, content_end_byte, line_number, content_length, max)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
byte_offset = newline ? newline + 1 : source_bytesize
|
|
46
|
+
line_number += 1
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def inspect_multibyte_lines(code, max)
|
|
51
|
+
byte_offset = 0
|
|
52
|
+
line_number = 1
|
|
53
|
+
|
|
54
|
+
code.each_line do |line|
|
|
55
|
+
line_bytesize = line.bytesize
|
|
56
|
+
has_newline = line.getbyte(line_bytesize - 1) == 0x0A
|
|
57
|
+
content_bytesize = line_bytesize - (has_newline ? 1 : 0)
|
|
58
|
+
trailing_cr = content_bytesize.positive? && line.getbyte(content_bytesize - 1) == 0x0D
|
|
59
|
+
content_bytesize -= 1 if trailing_cr
|
|
60
|
+
content_length = line.length - (has_newline ? 1 : 0) - (trailing_cr ? 1 : 0)
|
|
61
|
+
|
|
62
|
+
if content_length > max
|
|
63
|
+
start_offset = if content_bytesize == content_length
|
|
64
|
+
byte_offset + max
|
|
65
|
+
elsif !line.valid_encoding?
|
|
66
|
+
byte_offset + invalid_encoding_cutoff(line, max)
|
|
67
|
+
else
|
|
68
|
+
boundary = CHARACTER_BOUNDARY_PATTERN.match(line, max)
|
|
69
|
+
byte_offset + boundary.byteoffset(0).fetch(1)
|
|
70
|
+
end
|
|
71
|
+
add_line_offense(
|
|
72
|
+
start_offset, byte_offset + content_bytesize, line_number, content_length, max
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
byte_offset += line_bytesize
|
|
77
|
+
line_number += 1
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def invalid_encoding_cutoff(line, max)
|
|
82
|
+
bytesize = 0
|
|
83
|
+
characters = 0
|
|
84
|
+
line.each_char do |character|
|
|
85
|
+
break if characters == max
|
|
86
|
+
|
|
87
|
+
bytesize += character.bytesize
|
|
88
|
+
characters += 1
|
|
89
|
+
end
|
|
90
|
+
bytesize
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def add_line_offense(start_offset, end_offset, line_number, content_length, max)
|
|
94
|
+
add_offense(
|
|
95
|
+
Astel::Location.new(
|
|
96
|
+
start_offset: start_offset,
|
|
97
|
+
end_offset: end_offset,
|
|
98
|
+
start_line: line_number,
|
|
99
|
+
start_column: max,
|
|
100
|
+
end_line: line_number,
|
|
101
|
+
end_column: content_length
|
|
102
|
+
),
|
|
103
|
+
message: "#{MSG} [#{content_length}/#{max}]"
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
data/lib/lumin/runner.rb
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require_relative "parallel_executor"
|
|
6
|
+
require_relative "registry"
|
|
7
|
+
require_relative "worker"
|
|
8
|
+
|
|
9
|
+
module Lumin
|
|
10
|
+
class Runner
|
|
11
|
+
RunResult = Data.define(:offenses, :diffs, :warnings, :errors, :files)
|
|
12
|
+
AUTO_PARALLEL_MIN_BYTES = 256 * 1024
|
|
13
|
+
AUTO_WORKER_SCALE_BYTES = 32 * 1024
|
|
14
|
+
|
|
15
|
+
def initialize(config:, registry: Registry, parallel: nil, cache: true, cache_dir: nil)
|
|
16
|
+
@config = config
|
|
17
|
+
@registry = registry
|
|
18
|
+
@parallel = parallel
|
|
19
|
+
@cache_enabled = cache
|
|
20
|
+
@cache_dir = cache_dir
|
|
21
|
+
@cache = build_cache if @cache_enabled
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def clear_cache
|
|
25
|
+
cache.clear
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def run(paths = [], fix: false, unsafe: false, dry_run: false)
|
|
29
|
+
files = enumerate(paths)
|
|
30
|
+
cached_results, pending = load_cached(files, use_cache: @cache_enabled && !fix)
|
|
31
|
+
executed_results = execute(pending, fix: fix, unsafe: unsafe, dry_run: dry_run)
|
|
32
|
+
results = cached_results + executed_results
|
|
33
|
+
if @cache_enabled && !dry_run
|
|
34
|
+
store_results(executed_results)
|
|
35
|
+
cache.flush
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
ordered = results.sort_by(&:path)
|
|
39
|
+
RunResult.new(
|
|
40
|
+
offenses: ordered.flat_map(&:offenses).sort_by { |offense| offense_key(offense) }.freeze,
|
|
41
|
+
diffs: ordered.filter_map(&:diff).freeze,
|
|
42
|
+
warnings: ordered.flat_map(&:warnings).freeze,
|
|
43
|
+
errors: ordered.filter_map { |result| "#{result.path}: #{result.error}" if result.error }.freeze,
|
|
44
|
+
files: files.freeze
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def enumerate(paths)
|
|
51
|
+
candidates = if paths.empty?
|
|
52
|
+
@config.include_patterns.flat_map { |pattern| Dir.glob(pattern, File::FNM_EXTGLOB) }
|
|
53
|
+
else
|
|
54
|
+
paths.flat_map { |path| expand_path(path) }
|
|
55
|
+
end
|
|
56
|
+
candidates.select { |path| File.extname(path) == ".rb" }
|
|
57
|
+
.map { |path| clean_path(path) }
|
|
58
|
+
.reject { |path| excluded?(path) }
|
|
59
|
+
.uniq
|
|
60
|
+
.sort
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def expand_path(path)
|
|
64
|
+
return [path] if File.file?(path)
|
|
65
|
+
return Dir.glob(File.join(path, "**", "*.rb"), File::FNM_EXTGLOB) if File.directory?(path)
|
|
66
|
+
|
|
67
|
+
Dir.glob(path, File::FNM_EXTGLOB)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def clean_path(path)
|
|
71
|
+
return path unless path.include?("/./") || path.include?("/../") || path.include?("//")
|
|
72
|
+
|
|
73
|
+
Pathname.new(path).cleanpath.to_s
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def excluded?(path)
|
|
77
|
+
normalized = path.delete_prefix("./")
|
|
78
|
+
@config.exclude_patterns.any? do |pattern|
|
|
79
|
+
matches_pattern?(pattern, normalized)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def matches_pattern?(pattern, path)
|
|
84
|
+
flags = File::FNM_PATHNAME | File::FNM_EXTGLOB
|
|
85
|
+
return File.fnmatch?(pattern, path, flags) if Pathname.new(pattern).absolute?
|
|
86
|
+
|
|
87
|
+
File.fnmatch?(pattern, path, flags) || File.fnmatch?("**/#{pattern}", path.delete_prefix("/"), flags)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def load_cached(files, use_cache:)
|
|
91
|
+
return [[], files] unless use_cache
|
|
92
|
+
|
|
93
|
+
cached = []
|
|
94
|
+
pending = []
|
|
95
|
+
files.each do |path|
|
|
96
|
+
offenses = cache.fetch(path)
|
|
97
|
+
if offenses
|
|
98
|
+
cached << Worker::FileResult.new(
|
|
99
|
+
path: path, offenses: offenses, corrected: false, diff: nil, warnings: [], error: nil,
|
|
100
|
+
content_digest: nil, fingerprint: nil
|
|
101
|
+
)
|
|
102
|
+
else
|
|
103
|
+
pending << path
|
|
104
|
+
end
|
|
105
|
+
rescue StandardError
|
|
106
|
+
pending << path
|
|
107
|
+
end
|
|
108
|
+
[cached, pending]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def execute(files, fix:, unsafe:, dry_run:)
|
|
112
|
+
count = worker_count(files)
|
|
113
|
+
return serial(files, fix: fix, unsafe: unsafe, dry_run: dry_run) if count == 1
|
|
114
|
+
|
|
115
|
+
ParallelExecutor.new(config: @config, registry: @registry, count: count).call(
|
|
116
|
+
files, fix: fix, unsafe: unsafe, dry_run: dry_run
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def worker_count(files)
|
|
121
|
+
return explicit_worker_count(files.length) unless @parallel.nil?
|
|
122
|
+
|
|
123
|
+
automatic_worker_count(files)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def explicit_worker_count(file_count)
|
|
127
|
+
return 1 if @parallel == false || file_count < 2
|
|
128
|
+
|
|
129
|
+
requested = [Integer(@parallel), 1].max
|
|
130
|
+
|
|
131
|
+
[requested, file_count].min
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def automatic_worker_count(files)
|
|
135
|
+
return 1 if files.length < 2
|
|
136
|
+
|
|
137
|
+
workload_bytes = files.sum { |path| file_size(path) }
|
|
138
|
+
return 1 if workload_bytes <= AUTO_PARALLEL_MIN_BYTES
|
|
139
|
+
|
|
140
|
+
scaled_count = Math.sqrt(workload_bytes.fdiv(AUTO_WORKER_SCALE_BYTES)).ceil
|
|
141
|
+
available_count = [Etc.nprocessors, 1].max
|
|
142
|
+
|
|
143
|
+
[scaled_count, available_count, files.length].min
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def file_size(path)
|
|
147
|
+
File.size(path)
|
|
148
|
+
rescue SystemCallError, ArgumentError
|
|
149
|
+
0
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def serial(files, **options)
|
|
153
|
+
worker = Worker.new(config: @config, registry: @registry)
|
|
154
|
+
files.map { |path| worker.call(path, **options) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def store_results(results)
|
|
158
|
+
results.each do |result|
|
|
159
|
+
next if result.error || !result.content_digest || !result.fingerprint
|
|
160
|
+
|
|
161
|
+
cache.put(
|
|
162
|
+
path: result.path,
|
|
163
|
+
content_digest: result.content_digest,
|
|
164
|
+
fingerprint: result.fingerprint,
|
|
165
|
+
offenses: result.offenses
|
|
166
|
+
)
|
|
167
|
+
rescue StandardError
|
|
168
|
+
next
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def offense_key(offense)
|
|
173
|
+
[offense.path, offense.location.start_line, offense.location.start_column, offense.rule_name]
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def cache
|
|
177
|
+
@cache ||= build_cache
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def build_cache
|
|
181
|
+
require_relative "cache"
|
|
182
|
+
Cache.new(
|
|
183
|
+
config: @config,
|
|
184
|
+
registry: @registry,
|
|
185
|
+
directory: @cache_dir || Cache.default_directory
|
|
186
|
+
)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lumin
|
|
4
|
+
SourceSnapshot = Data.define(:source, :fingerprint) do
|
|
5
|
+
class ChangedError < StandardError; end
|
|
6
|
+
|
|
7
|
+
def self.read(path)
|
|
8
|
+
File.open(path, "rb") do |file|
|
|
9
|
+
before = fingerprint(file.stat)
|
|
10
|
+
source = file.read
|
|
11
|
+
after = fingerprint(file.stat)
|
|
12
|
+
raise ChangedError, "#{path} changed while it was being read" unless before == after
|
|
13
|
+
|
|
14
|
+
new(source: source, fingerprint: after)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.fingerprint(path_or_stat)
|
|
19
|
+
stat = path_or_stat.is_a?(File::Stat) ? path_or_stat : File.stat(path_or_stat)
|
|
20
|
+
[
|
|
21
|
+
stat.size,
|
|
22
|
+
stat.mtime.to_i,
|
|
23
|
+
stat.mtime.nsec,
|
|
24
|
+
stat.ctime.to_i,
|
|
25
|
+
stat.ctime.nsec,
|
|
26
|
+
stat.ino
|
|
27
|
+
].freeze
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/lumin/worker.rb
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "offense"
|
|
5
|
+
require_relative "registry"
|
|
6
|
+
require_relative "source_snapshot"
|
|
7
|
+
|
|
8
|
+
module Lumin
|
|
9
|
+
class Worker
|
|
10
|
+
FileResult = Data.define(
|
|
11
|
+
:path, :offenses, :corrected, :diff, :warnings, :error, :content_digest, :fingerprint
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
attr_reader :config
|
|
15
|
+
|
|
16
|
+
def initialize(config:, registry: Registry)
|
|
17
|
+
@config = config
|
|
18
|
+
@registry = registry
|
|
19
|
+
@rules = build_rules
|
|
20
|
+
@dispatcher = build_dispatcher
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(path, fix: false, unsafe: false, dry_run: false)
|
|
24
|
+
if fix
|
|
25
|
+
require_relative "autocorrector"
|
|
26
|
+
result = Autocorrector.new(self).call(path, unsafe: unsafe, dry_run: dry_run)
|
|
27
|
+
return FileResult.new(
|
|
28
|
+
path: path,
|
|
29
|
+
offenses: result.offenses,
|
|
30
|
+
corrected: result.corrected,
|
|
31
|
+
diff: result.diff,
|
|
32
|
+
warnings: result.warnings,
|
|
33
|
+
error: nil,
|
|
34
|
+
content_digest: result.content_digest,
|
|
35
|
+
fingerprint: result.fingerprint
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
snapshot = SourceSnapshot.read(path)
|
|
40
|
+
source = Astel::SourceFile.from_string(
|
|
41
|
+
snapshot.source, path: path, version: @config.target_ruby
|
|
42
|
+
)
|
|
43
|
+
FileResult.new(
|
|
44
|
+
path: path,
|
|
45
|
+
offenses: lint_source(source),
|
|
46
|
+
corrected: false,
|
|
47
|
+
diff: nil,
|
|
48
|
+
warnings: [],
|
|
49
|
+
error: nil,
|
|
50
|
+
content_digest: Digest::SHA256.hexdigest(snapshot.source),
|
|
51
|
+
fingerprint: snapshot.fingerprint
|
|
52
|
+
)
|
|
53
|
+
rescue StandardError => error
|
|
54
|
+
FileResult.new(
|
|
55
|
+
path: path,
|
|
56
|
+
offenses: [],
|
|
57
|
+
corrected: false,
|
|
58
|
+
diff: nil,
|
|
59
|
+
warnings: [],
|
|
60
|
+
error: "#{error.class}: #{error.message}",
|
|
61
|
+
content_digest: nil,
|
|
62
|
+
fingerprint: nil
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def lint_source(source)
|
|
67
|
+
return syntax_offenses(source) unless source.valid?
|
|
68
|
+
|
|
69
|
+
@rules.each { |rule| rule.begin_source(source) }
|
|
70
|
+
@dispatcher.run(source.ast)
|
|
71
|
+
@rules.flat_map(&:offenses)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def build_rules
|
|
77
|
+
@config.enabled_rules(@registry).map do |_name, rule_class, settings|
|
|
78
|
+
rule_class.new(settings.reject { |key, _value| key == :enabled })
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def build_dispatcher
|
|
83
|
+
dispatcher = Astel::Dispatcher.new
|
|
84
|
+
@rules.each do |rule|
|
|
85
|
+
rule.class.subscriptions.map(&:first).uniq.each do |node_type|
|
|
86
|
+
dispatcher.on(node_type) { |node| rule.dispatch(node_type, node) }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
dispatcher
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def syntax_offenses(source)
|
|
93
|
+
source.errors.map do |error|
|
|
94
|
+
Offense.new(
|
|
95
|
+
path: source.path,
|
|
96
|
+
location: error.location,
|
|
97
|
+
rule_name: "Lint/Syntax",
|
|
98
|
+
message: error.message
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
data/lib/lumin.rb
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lumin/version"
|
|
4
|
+
require_relative "lumin/error"
|
|
5
|
+
|
|
6
|
+
module Lumin
|
|
7
|
+
autoload :Offense, File.expand_path("lumin/offense", __dir__)
|
|
8
|
+
autoload :Registry, File.expand_path("lumin/registry", __dir__)
|
|
9
|
+
autoload :Rule, File.expand_path("lumin/rule", __dir__)
|
|
10
|
+
autoload :Config, File.expand_path("lumin/config", __dir__)
|
|
11
|
+
autoload :SourceSnapshot, File.expand_path("lumin/source_snapshot", __dir__)
|
|
12
|
+
autoload :CacheManifest, File.expand_path("lumin/cache_manifest", __dir__)
|
|
13
|
+
autoload :Cache, File.expand_path("lumin/cache", __dir__)
|
|
14
|
+
autoload :Autocorrector, File.expand_path("lumin/autocorrector", __dir__)
|
|
15
|
+
autoload :Worker, File.expand_path("lumin/worker", __dir__)
|
|
16
|
+
autoload :ParallelExecutor, File.expand_path("lumin/parallel_executor", __dir__)
|
|
17
|
+
autoload :Runner, File.expand_path("lumin/runner", __dir__)
|
|
18
|
+
autoload :CLI, File.expand_path("lumin/cli", __dir__)
|
|
19
|
+
|
|
20
|
+
module Formatters
|
|
21
|
+
autoload :Text, File.expand_path("lumin/formatters/text", __dir__)
|
|
22
|
+
autoload :Json, File.expand_path("lumin/formatters/json", __dir__)
|
|
23
|
+
autoload :Github, File.expand_path("lumin/formatters/github", __dir__)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
module Rules
|
|
27
|
+
module Style
|
|
28
|
+
autoload :FrozenStringLiteral, File.expand_path("lumin/rules/style/frozen_string_literal", __dir__)
|
|
29
|
+
autoload :LineLength, File.expand_path("lumin/rules/style/line_length", __dir__)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
module Lint
|
|
33
|
+
autoload :NoMethodMissing, File.expand_path("lumin/rules/lint/no_method_missing", __dir__)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.register_plugin(namespace)
|
|
38
|
+
Registry.register_namespace(namespace)
|
|
39
|
+
end
|
|
40
|
+
end
|