slimx 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,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SlimX
4
+ # Rewrites a template in its canonical layout. Text blocks shift as a whole, so the whitespace they render is
5
+ # kept; and the formatted template must compile to the same Ruby as the original, or nothing is rewritten.
6
+ class Formatter
7
+ INDENT_WIDTH = 2
8
+ CONTROL = /\A-\s*/
9
+ OUTPUT = /\A(==?)([<>]*)\s*/
10
+ TAG_OUTPUT = /\A\s*(==?)(['<>]*)\s*/
11
+ REDUNDANT_DIV = /\Adiv(?=[.#][\p{Word}-])/
12
+
13
+ def self.call(source, **)
14
+ new(**).call(source)
15
+ end
16
+
17
+ def initialize(shortcuts: Shortcuts::DEFAULT, tailwind_classes: SlimX.tailwind_classes)
18
+ @shortcuts = shortcuts
19
+ @tailwind_classes = tailwind_classes
20
+ end
21
+
22
+ def call(source)
23
+ formatted = layout(source)
24
+ return formatted if formatted == source || compiled(formatted) == compiled(source)
25
+
26
+ raise UnsafeFormattingError, "Formatting would change how this template renders"
27
+ end
28
+
29
+ # The canonical layout, without checking it renders the same: what strict mode compares templates against.
30
+ def layout(source)
31
+ parser = Parser.new(shortcuts: @shortcuts, outline: true, tailwind_classes: @tailwind_classes)
32
+ parser.call(source)
33
+ lines = source.delete_prefix("").split(/\r?\n/)
34
+
35
+ formatted = []
36
+ indent_shift_by_lineno = {}
37
+ lines.each.with_index(1) do |line, lineno|
38
+ info = parser.outline[lineno]
39
+ case info.kind
40
+ when :blank
41
+ formatted << "" unless formatted.empty? || formatted.last.empty?
42
+ when :text_blank
43
+ formatted << ""
44
+ when :structure
45
+ indent = info.depth * INDENT_WIDTH
46
+ indent_shift_by_lineno[lineno] = indent - indent_of(line)
47
+ formatted << (" " * indent) + structure(line, info)
48
+ else
49
+ formatted << continuation(line, info, indent_shift_by_lineno.fetch(info.owner, 0))
50
+ end
51
+ end
52
+ formatted.pop while formatted.last&.empty?
53
+ formatted.empty? ? "" : "#{formatted.join("\n")}\n"
54
+ end
55
+
56
+ private
57
+
58
+ def structure(line, info)
59
+ content = with_output_spacing(line, info).lstrip
60
+ content = content.rstrip unless info.text
61
+ case content
62
+ when CONTROL then $'.empty? ? "-" : "- #{$'}"
63
+ when OUTPUT then "#{$1}#{$2} #{$'}"
64
+ when REDUNDANT_DIV then $'
65
+ else content
66
+ end
67
+ end
68
+
69
+ def continuation(line, info, indent_shift)
70
+ line = with_output_spacing(expand_tabs(line), info)
71
+ indent = [line[/\A */].size + indent_shift, 0].max
72
+ content = line.lstrip
73
+ content = content.rstrip unless info.kind == :text || info.text
74
+ (" " * indent) + content
75
+ end
76
+
77
+ def with_output_spacing(line, info)
78
+ return line unless info.output_column
79
+
80
+ before = line[0...info.output_column].rstrip
81
+ line[info.output_column..] =~ TAG_OUTPUT
82
+ $'.empty? ? "#{before} #{$1}#{$2}" : "#{before} #{$1}#{$2} #{$'}"
83
+ end
84
+
85
+ def indent_of(line)
86
+ expand_tabs(line)[/\A */].size
87
+ end
88
+
89
+ def expand_tabs(line)
90
+ line.sub(/\A[ \t]*/) { it.gsub(Parser::TAB, "\\1#{" " * Parser::TAB_SIZE}") }
91
+ end
92
+
93
+ def compiled(source)
94
+ SlimX.compile(source, shortcuts: @shortcuts, tailwind_classes: @tailwind_classes, strict: false, format: :xml).gsub(/(?:\s*[;\n]\s*)+/, ";")
95
+ rescue Error => exception
96
+ exception.message
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,446 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SlimX
4
+ class Linter
5
+ module Rules
6
+ def self.all
7
+ constants.map { const_get(it) }.select { it < Rule }
8
+ end
9
+
10
+ class Rule
11
+ DEFAULTS = { enabled: true }.freeze
12
+
13
+ def self.rule_name
14
+ name.split("::").last
15
+ end
16
+
17
+ def initialize(options)
18
+ @options = options
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :options
24
+
25
+ def offense(document, lineno, message)
26
+ Offense.new(document.path, lineno, self.class.rule_name, message)
27
+ end
28
+
29
+ def each_tag(document, name = nil)
30
+ document.each_tag do |tag, ancestors|
31
+ yield tag, ancestors if name.nil? || tag.name == name
32
+ end
33
+ end
34
+
35
+ def attribute_names(tag)
36
+ tag.attributes.filter_map { it.name unless it.is_a?(AST::Splat) }
37
+ end
38
+
39
+ def each_line(document)
40
+ document.lines.each.with_index(1)
41
+ end
42
+
43
+ def missing_attribute(document, tag_name, attribute_name, message)
44
+ enum_for(:each_tag, document, tag_name).filter_map do |tag,|
45
+ offense(document, document.line_of(tag), message) unless splat?(tag) || attribute_names(tag).include?(attribute_name)
46
+ end
47
+ end
48
+
49
+ def splat?(tag)
50
+ tag.attributes.any? { it.is_a?(AST::Splat) }
51
+ end
52
+
53
+ # The value when it is written literally, nil when it is computed.
54
+ def literal_value(attribute)
55
+ case attribute
56
+ when AST::StaticAttribute then attribute.value
57
+ when AST::QuotedAttribute then attribute.text unless attribute.text.include?('#{')
58
+ end
59
+ end
60
+
61
+ def literal_attributes(tag, name)
62
+ tag.attributes.select { it.respond_to?(:name) && it.name == name && literal_value(it) }
63
+ end
64
+ end
65
+
66
+ # Layout: everything `slimx fmt` rewrites.
67
+
68
+ class Indentation < Rule
69
+ def offenses(document, _source)
70
+ document.outline.each_with_index.filter_map do |info, lineno|
71
+ next unless info&.kind == :structure
72
+
73
+ indent = document.lines[lineno - 1][/\A */].size
74
+ expected = info.depth * Formatter::INDENT_WIDTH
75
+ offense(document, lineno, "Indent with #{expected} spaces, not #{indent}") if indent != expected
76
+ end
77
+ end
78
+ end
79
+
80
+ class Tab < Rule
81
+ def offenses(document, source)
82
+ return [] unless source.include?("\t")
83
+
84
+ each_line(document).filter_map { |line, lineno| offense(document, lineno, "Indent with spaces, not tabs") if line.match?(/\A *[\t ]*\t/) }
85
+ end
86
+ end
87
+
88
+ class TrailingWhitespace < Rule
89
+ # Matches wherever a line may end in whitespace, and a little more (a \r before \n): it only skips files.
90
+ ANY_TRAILING_WHITESPACE = /[ \t\r\f\v](?=\r?\n|\z)/
91
+
92
+ def offenses(document, source)
93
+ return [] unless source.match?(ANY_TRAILING_WHITESPACE)
94
+
95
+ each_line(document).filter_map do |line, lineno|
96
+ next unless line.match?(/\s\z/)
97
+
98
+ rendered = document.outline[lineno].text || document.outline[lineno].kind == :text
99
+ offense(document, lineno, rendered ? "Trailing whitespace renders: write it with `'` or `>` instead" : "Remove trailing whitespace")
100
+ end
101
+ end
102
+ end
103
+
104
+ class EmptyLines < Rule
105
+ def offenses(document, _source)
106
+ previous_blank = true
107
+ document.outline.each_with_index.filter_map do |info, lineno|
108
+ next unless info
109
+
110
+ blank = info.kind == :blank
111
+ extra = blank && previous_blank
112
+ previous_blank = blank
113
+ offense(document, lineno, "Remove this extra empty line") if extra
114
+ end
115
+ end
116
+ end
117
+
118
+ class TrailingBlankLines < Rule
119
+ def offenses(document, source)
120
+ return [] if source.empty?
121
+ return [offense(document, source.lines.size, "End the file with a newline")] unless source.end_with?("\n")
122
+
123
+ source.end_with?("\n\n") ? [offense(document, source.lines.size, "Remove the empty lines at the end of the file")] : []
124
+ end
125
+ end
126
+
127
+ class ControlStatementSpacing < Rule
128
+ TAG_OUTPUT = /\A(\s*)(==?)(['<>]*)(\s*)(?=\S)/
129
+
130
+ def offenses(document, _source)
131
+ document.outline.each_with_index.filter_map do |info, lineno|
132
+ next unless info
133
+
134
+ line = document.lines[lineno - 1]
135
+ if info.output_column
136
+ line[info.output_column..] =~ TAG_OUTPUT
137
+ offense(document, lineno, "Put a space before and after `#{$2}`") if $1.empty? || $4.empty?
138
+ elsif info.kind == :structure
139
+ case line.lstrip
140
+ when /\A-(?=\S)/ then offense(document, lineno, "Put a space after `-`")
141
+ when /\A((?>==?[<>]*))(?=\S)/ then offense(document, lineno, "Put a space after `#{$1}`")
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
147
+
148
+ class RedundantDiv < Rule
149
+ def offenses(document, _source)
150
+ document.outline.each_with_index.filter_map do |info, lineno|
151
+ next unless info&.kind == :structure && document.lines[lineno - 1].lstrip.match?(Formatter::REDUNDANT_DIV)
152
+
153
+ offense(document, lineno, "`div` is implied by the `.class` or `#id` shortcut")
154
+ end
155
+ end
156
+ end
157
+
158
+ # Slim-lint's rules on code and tags.
159
+
160
+ class LineLength < Rule
161
+ DEFAULTS = { enabled: true, max: 120 }.freeze
162
+
163
+ def offenses(document, _source)
164
+ max = options[:max]
165
+ each_line(document).filter_map { |line, lineno| offense(document, lineno, "Line is too long [#{line.length}/#{max}]") if line.length > max }
166
+ end
167
+ end
168
+
169
+ class CommentControlStatement < Rule
170
+ ALLOWED = /\A\s*#\s*(rubocop:|Template Dependency:)/
171
+
172
+ def offenses(document, _source)
173
+ document.enum_for(:each_node).filter_map do |node,|
174
+ next unless node.is_a?(AST::Control) && node.code.match?(/\A\s*#/) && !node.code.match?(ALLOWED)
175
+
176
+ offense(document, document.line_of(node), "Write comments with `/`, not `- #`: they cost nothing at render time")
177
+ end
178
+ end
179
+ end
180
+
181
+ class EmptyControlStatement < Rule
182
+ def offenses(document, _source)
183
+ document.enum_for(:each_node).filter_map do |node,|
184
+ offense(document, document.line_of(node), "Remove this empty `-`") if node.is_a?(AST::Control) && node.code.strip.empty?
185
+ end
186
+ end
187
+ end
188
+
189
+ class ConsecutiveControlStatements < Rule
190
+ DEFAULTS = { enabled: true, max: 2 }.freeze
191
+
192
+ def offenses(document, _source)
193
+ sibling_lists = [document.nodes, *document.visits.filter_map { |node,| Document.children_of(node) }]
194
+ sibling_lists.select! { |siblings| siblings.count { flat_control?(it) } > options[:max] }
195
+ sibling_lists.flat_map do |siblings|
196
+ siblings.chunk_while { |control, following| flat_control?(control) && flat_control?(following) }
197
+ .select { it.size > options[:max] && flat_control?(it.first) }
198
+ .map { offense(document, document.line_of(it.first), "Merge these #{it.size} lines of Ruby into a `ruby:` block") }
199
+ end
200
+ end
201
+
202
+ private
203
+
204
+ def flat_control?(node)
205
+ node.is_a?(AST::Control) && AST.empty?(node.children)
206
+ end
207
+ end
208
+
209
+ class TagCase < Rule
210
+ def offenses(document, _source)
211
+ return [] if document.xml?
212
+
213
+ document.enum_for(:each_node).filter_map do |node, ancestors|
214
+ next unless node.is_a?(AST::Tag) && node.name.match?(/[A-Z]/)
215
+ next if ancestors.any? { it.is_a?(AST::Tag) && it.name == "svg" }
216
+
217
+ offense(document, document.line_of(node), "Write `#{node.name.downcase}` in lowercase")
218
+ end
219
+ end
220
+ end
221
+
222
+ class InstanceVariables < Rule
223
+ INSTANCE_VARIABLE = /(?<![\w@])@[a-z_]\w*/
224
+
225
+ def offenses(document, _source)
226
+ return [] unless document.partial?
227
+
228
+ document.enum_for(:each_node).filter_map do |node,|
229
+ code = case node
230
+ when AST::Control, AST::Output then node.code
231
+ when AST::Tag then node.attributes.filter_map { it.code if it.respond_to?(:code) }.join(" ")
232
+ end
233
+ next unless code && (variable = code[INSTANCE_VARIABLE])
234
+
235
+ offense(document, document.line_of(node), "Pass `#{variable}` to the partial as a local")
236
+ end
237
+ end
238
+ end
239
+
240
+ class StrictLocals < Rule
241
+ DEFAULTS = { enabled: false }.freeze
242
+
243
+ def offenses(document, _source)
244
+ return [] if !document.partial? || document.lines.any? { it.match?(%r{\A\s*/#\s*locals:}) }
245
+
246
+ [offense(document, 1, "Declare the partial's locals: `/# locals: (name:)`")]
247
+ end
248
+ end
249
+
250
+ # HTML and accessibility, after Herb, erb_lint and eslint-plugin-jsx-a11y.
251
+
252
+ class DuplicateId < Rule
253
+ def offenses(document, _source)
254
+ branch_by_id = {}
255
+ enum_for(:each_tag, document).filter_map do |tag, ancestors|
256
+ branch = ancestors.reverse.find { it.is_a?(AST::Control) }
257
+ literal_attributes(tag, "id").filter_map do |attribute|
258
+ id = literal_value(attribute)
259
+ if branch_by_id.key?(id) && branch_by_id[id].equal?(branch)
260
+ offense(document, document.line_of(tag), "The id `#{id}` is already used in this template")
261
+ else
262
+ branch_by_id[id] = branch
263
+ nil
264
+ end
265
+ end
266
+ end.flatten
267
+ end
268
+ end
269
+
270
+ class ImageAlt < Rule
271
+ def offenses(document, _source)
272
+ missing_attribute(document, "img", "alt", 'Describe the image with `alt`, or mark it decorative with `alt=""`')
273
+ end
274
+
275
+ end
276
+
277
+ class IframeTitle < Rule
278
+ def offenses(document, _source)
279
+ missing_attribute(document, "iframe", "title", "Name the frame with `title`")
280
+ end
281
+ end
282
+
283
+ class AnchorHref < Rule
284
+ def offenses(document, _source)
285
+ missing_attribute(document, "a", "href", "A link without `href` is not a link: use a `button` for actions")
286
+ end
287
+ end
288
+
289
+ class ButtonType < Rule
290
+ DEFAULTS = { enabled: false }.freeze
291
+
292
+ def offenses(document, _source)
293
+ missing_attribute(document, "button", "type", 'Say what the button does with `type="button"` or `type="submit"`')
294
+ end
295
+ end
296
+
297
+ class NestedLinks < Rule
298
+ def offenses(document, _source)
299
+ enum_for(:each_tag, document, "a").filter_map do |tag, ancestors|
300
+ next unless ancestors.any? { it.is_a?(AST::Tag) && it.name == "a" }
301
+
302
+ offense(document, document.line_of(tag), "Links cannot contain links")
303
+ end
304
+ end
305
+ end
306
+
307
+ class PositiveTabindex < Rule
308
+ def offenses(document, _source)
309
+ enum_for(:each_tag, document).flat_map do |tag,|
310
+ literal_attributes(tag, "tabindex").filter_map do |attribute|
311
+ next unless literal_value(attribute).to_i.positive?
312
+
313
+ offense(document, document.line_of(tag), "A positive `tabindex` breaks the reading order: use 0 or -1")
314
+ end
315
+ end
316
+ end
317
+ end
318
+
319
+ class BooleanAttributeValue < Rule
320
+ BOOLEAN_ATTRIBUTES = %w[allowfullscreen async autofocus autoplay checked controls default defer disabled formnovalidate
321
+ hidden inert ismap loop multiple muted nomodule novalidate open playsinline readonly required
322
+ reversed selected].freeze
323
+
324
+ def offenses(document, _source)
325
+ enum_for(:each_tag, document).flat_map do |tag,|
326
+ tag.attributes.filter_map do |attribute|
327
+ next unless attribute.respond_to?(:name) && BOOLEAN_ATTRIBUTES.include?(attribute.name)
328
+ next unless ["", attribute.name].include?(literal_value(attribute))
329
+
330
+ offense(document, document.line_of(tag), "Write `#{attribute.name}=true`, or `#{attribute.name}` alone inside an attribute list")
331
+ end
332
+ end
333
+ end
334
+ end
335
+
336
+ class AriaRole < Rule
337
+ ROLES = %w[alert alertdialog application article banner blockquote button caption cell checkbox code columnheader
338
+ combobox complementary contentinfo definition deletion dialog document emphasis feed figure form generic
339
+ grid gridcell group heading img insertion link list listbox listitem log main marquee math menu menubar
340
+ menuitem menuitemcheckbox menuitemradio meter navigation none note option paragraph presentation
341
+ progressbar radio radiogroup region row rowgroup rowheader scrollbar search searchbox separator slider
342
+ spinbutton status strong subscript superscript switch tab table tablist tabpanel term textbox time timer
343
+ toolbar tooltip tree treegrid treeitem].freeze
344
+
345
+ def offenses(document, _source)
346
+ enum_for(:each_tag, document).flat_map do |tag,|
347
+ literal_attributes(tag, "role").flat_map do |attribute|
348
+ literal_value(attribute).split.reject { ROLES.include?(it) || it.start_with?("doc-", "graphics-") }.map do |role|
349
+ offense(document, document.line_of(tag), "`#{role}` is not an ARIA role")
350
+ end
351
+ end
352
+ end
353
+ end
354
+ end
355
+
356
+ class AriaAttribute < Rule
357
+ ATTRIBUTES = %w[activedescendant atomic autocomplete braillelabel brailleroledescription busy checked colcount
358
+ colindex colindextext colspan controls current describedby description details disabled
359
+ dropeffect errormessage expanded flowto grabbed haspopup hidden invalid keyshortcuts label
360
+ labelledby level live modal multiline multiselectable orientation owns placeholder posinset
361
+ pressed readonly relevant required roledescription rowcount rowindex rowindextext rowspan selected
362
+ setsize sort valuemax valuemin valuenow valuetext].map { "aria-#{it}" }.freeze
363
+
364
+ def offenses(document, _source)
365
+ enum_for(:each_tag, document).flat_map do |tag,|
366
+ attribute_names(tag).filter_map do |name|
367
+ next unless name.start_with?("aria-") && !ATTRIBUTES.include?(name)
368
+
369
+ offense(document, document.line_of(tag), "`#{name}` is not an ARIA attribute")
370
+ end
371
+ end
372
+ end
373
+ end
374
+
375
+ class DeprecatedClasses < Rule
376
+ DEFAULTS = { enabled: true, classes: {} }.freeze
377
+
378
+ def offenses(document, _source)
379
+ replacement_by_class = options[:classes]
380
+ return [] if replacement_by_class.empty?
381
+
382
+ enum_for(:each_tag, document).flat_map do |tag,|
383
+ literal_attributes(tag, "class").flat_map { literal_value(it).split }.filter_map do |css_class|
384
+ next unless replacement_by_class.key?(css_class)
385
+
386
+ offense(document, document.line_of(tag), "`#{css_class}` is deprecated: use `#{replacement_by_class[css_class]}`")
387
+ end
388
+ end
389
+ end
390
+ end
391
+
392
+ class InlineStyles < Rule
393
+ DEFAULTS = { enabled: false }.freeze
394
+
395
+ def offenses(document, _source)
396
+ enum_for(:each_tag, document).filter_map do |tag,|
397
+ offense(document, document.line_of(tag), "Move inline styles to a stylesheet") if literal_attributes(tag, "style").any?
398
+ end
399
+ end
400
+ end
401
+
402
+ # Ruby that a plainer line of Slim says better, after haml-lint.
403
+
404
+ class UnnecessaryInterpolation < Rule
405
+ LONE_INTERPOLATION = /\A#\{([^{}]+)\}\z/
406
+
407
+ def offenses(document, _source)
408
+ document.enum_for(:each_node).filter_map do |node, ancestors|
409
+ next unless node.is_a?(AST::TextBlock) && (lineno = document.line_of(node) || document.line_of(ancestors.last))
410
+
411
+ texts = node.children.grep(AST::Text)
412
+ next unless texts.size == 1 && texts.first.text =~ LONE_INTERPOLATION
413
+
414
+ offense(document, lineno, "Write `= #{$1.strip}` instead of interpolating it alone")
415
+ end
416
+ end
417
+ end
418
+
419
+ class UnnecessaryStringOutput < Rule
420
+ PLAIN_STRING = /\A(["'])([^"'\\#&<>\s](?:[^"'\\#&<>]*[^"'\\#&<>\s])?)\1\z/
421
+
422
+ def offenses(document, _source)
423
+ document.enum_for(:each_node).filter_map do |node,|
424
+ next unless node.is_a?(AST::Output) && AST.empty?(node.children) && node.code =~ PLAIN_STRING
425
+
426
+ offense(document, document.line_of(node), "Write the text `| #{$2}` instead of outputting a string")
427
+ end
428
+ end
429
+ end
430
+
431
+ class Zwsp < Rule
432
+ INVISIBLE = /[\u200B-\u200D\u2060\uFEFF]/
433
+
434
+ def offenses(document, source)
435
+ return [] unless source.match?(INVISIBLE)
436
+
437
+ each_line(document).filter_map do |line, lineno|
438
+ next unless line.match?(INVISIBLE) && !(lineno == 1 && line.start_with?("\uFEFF") && !line[1..].match?(INVISIBLE))
439
+
440
+ offense(document, lineno, "Remove the invisible zero-width character")
441
+ end
442
+ end
443
+ end
444
+ end
445
+ end
446
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module SlimX
6
+ class Linter
7
+ Offense = Data.define(:path, :lineno, :rule, :message)
8
+
9
+ # The tree is walked once per template: every rule iterates `visits`, each node with its ancestors.
10
+ Document = Data.define(:path, :lines, :nodes, :outline, :line_by_node, :visits, :tag_visits) do
11
+ def self.visits_of(nodes, ancestors = [], visits = [])
12
+ nodes&.each do |node|
13
+ next if node == AST::NEWLINE
14
+
15
+ visits << [node, ancestors]
16
+ children = children_of(node)
17
+ visits_of(children, [*ancestors, node].freeze, visits) if children
18
+ end
19
+ visits
20
+ end
21
+
22
+ def partial?
23
+ File.basename(path.to_s).start_with?("_")
24
+ end
25
+
26
+ def xml?
27
+ path.to_s.match?(/\.(xml|rss|atom)\.slim\z/)
28
+ end
29
+
30
+ def line_of(node)
31
+ line_by_node[node]
32
+ end
33
+
34
+ def each_node(&) = visits.each(&)
35
+ def each_tag(&) = tag_visits.each(&)
36
+
37
+ def self.children_of(node)
38
+ case node
39
+ when AST::Tag then node.content
40
+ when AST::Control, AST::Output, AST::TextBlock, AST::Comment, AST::InlineHtml, AST::Embedded then node.children
41
+ end
42
+ end
43
+ end
44
+
45
+ CONFIG_FILE = ".slimx.yml"
46
+
47
+ def self.load(directory = Dir.pwd)
48
+ new(configuration(directory).fetch("linters", {}))
49
+ end
50
+
51
+ def self.configuration(directory = Dir.pwd)
52
+ path = File.join(directory, CONFIG_FILE)
53
+ File.exist?(path) ? YAML.safe_load_file(path) || {} : {}
54
+ end
55
+
56
+ def initialize(options_by_rule_name = {})
57
+ @rules = Rules.all.filter_map do |rule_class|
58
+ options = rule_class::DEFAULTS.merge(options_by_rule_name.fetch(rule_class.rule_name, {}).transform_keys(&:to_sym))
59
+ rule_class.new(options) if options[:enabled]
60
+ end
61
+ end
62
+
63
+ def call(path, source)
64
+ parser = Parser.new(file: path, outline: true)
65
+ nodes = parser.call(source)
66
+ visits = Document.visits_of(nodes)
67
+ document = Document.new(path:, lines: source.split(/\r?\n/), nodes:, outline: parser.outline, line_by_node: parser.line_by_node,
68
+ visits:, tag_visits: visits.select { |node,| node.is_a?(AST::Tag) })
69
+ offenses = @rules.flat_map { it.offenses(document, source) }
70
+ # sort_by is not stable: the index keeps offenses of one line in rule order.
71
+ offenses.each_with_index.sort_by { |offense, index| [offense.lineno, index] }.map(&:first)
72
+ rescue SyntaxError => exception
73
+ [Offense.new(path, exception.lineno, "Syntax", exception.error)]
74
+ end
75
+ end
76
+ end
77
+
78
+ require_relative "linter/rules"
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+
5
+ module SlimX
6
+ # Maps items in forked processes, one per core, and returns the results in item order. Below
7
+ # minimum_per_process items per process, forking costs more than it saves, and the map stays in this process.
8
+ module Parallel
9
+ module_function
10
+
11
+ def map(items, minimum_per_process: 100, &block)
12
+ process_count = [Etc.nprocessors, items.size / minimum_per_process].min
13
+ return items.map(&block) if process_count < 2 || !Process.respond_to?(:fork)
14
+
15
+ readers = items.each_slice((items.size.to_f / process_count).ceil).map do |slice|
16
+ reader, writer = IO.pipe
17
+ fork do
18
+ reader.close
19
+ results = begin
20
+ slice.map(&block)
21
+ rescue Exception => exception # rubocop:disable Lint/RescueException -- re-raised by the parent
22
+ exception
23
+ end
24
+ writer.binmode.write(Marshal.dump(results))
25
+ exit!(0)
26
+ end
27
+ writer.close
28
+ reader
29
+ end
30
+ results_by_process = readers.map { Marshal.load(it.binmode.read) }
31
+ Process.waitall
32
+ results_by_process.each { raise it if it.is_a?(Exception) }
33
+ results_by_process.flatten(1)
34
+ end
35
+ end
36
+ end