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.
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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lumin
4
+ VERSION = "0.1.0"
5
+ end
@@ -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