super-slim 3.0.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,585 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperSlim
4
+ class Linter
5
+ module Rules
6
+ # In the order of this file, which orders the offenses of one line: Module#constants has no order of its own.
7
+ def self.all
8
+ constants.sort_by { const_source_location(_1).last }.filter_map do |constant_name|
9
+ rule = const_get(constant_name)
10
+ rule if rule < Rule
11
+ end
12
+ end
13
+
14
+ class Rule
15
+ DEFAULTS = {enabled: true}.freeze
16
+
17
+ def self.rule_name
18
+ name.split("::").last
19
+ end
20
+
21
+ GLOB_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH
22
+
23
+ def initialize(options)
24
+ @options = options
25
+ end
26
+
27
+ def applies_to?(path)
28
+ included = options[:include].nil? || Array(options[:include]).any? { File.fnmatch?(_1, path, GLOB_FLAGS) }
29
+ included && Array(options[:exclude]).none? { File.fnmatch?(_1, path, GLOB_FLAGS) }
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :options
35
+
36
+ def offense(document, lineno, message)
37
+ Offense.new(document.path, lineno, self.class.rule_name, message)
38
+ end
39
+
40
+ def each_tag(document, name = nil)
41
+ document.each_tag do |tag, ancestors|
42
+ yield tag, ancestors if name.nil? || tag.name == name
43
+ end
44
+ end
45
+
46
+ def attribute_names(tag)
47
+ tag.attributes.filter_map { _1.name unless _1.is_a?(AST::Splat) }
48
+ end
49
+
50
+ # Text nodes carry no line of their own: they take the line of the closest node that does.
51
+ def line_near(document, node, ancestors)
52
+ [node, *ancestors.reverse].each do |candidate|
53
+ lineno = document.line_of(candidate)
54
+ return lineno if lineno
55
+ end
56
+ nil
57
+ end
58
+
59
+ def each_line(document)
60
+ document.lines.each.with_index(1)
61
+ end
62
+
63
+ def missing_attribute(document, tag_name, attribute_name, message)
64
+ enum_for(:each_tag, document, tag_name).filter_map do |tag,|
65
+ offense(document, document.line_of(tag), message) unless splat?(tag) || attribute_names(tag).include?(attribute_name)
66
+ end
67
+ end
68
+
69
+ def splat?(tag)
70
+ tag.attributes.any? { _1.is_a?(AST::Splat) }
71
+ end
72
+
73
+ # The value when it is written literally, nil when it is computed.
74
+ def literal_value(attribute)
75
+ case attribute
76
+ when AST::StaticAttribute then attribute.value
77
+ when AST::QuotedAttribute then attribute.text unless attribute.text.include?('#{')
78
+ end
79
+ end
80
+
81
+ def literal_attributes(tag, name)
82
+ tag.attributes.select { _1.respond_to?(:name) && _1.name == name && literal_value(_1) }
83
+ end
84
+ end
85
+
86
+ # Layout: everything `super-slim format` rewrites.
87
+
88
+ class Indentation < Rule
89
+ def offenses(document, _source)
90
+ document.outline.each_with_index.filter_map do |info, lineno|
91
+ next unless info&.kind == :structure
92
+
93
+ indent = document.lines[lineno - 1][/\A */].size
94
+ expected = info.depth * Formatter::INDENT_WIDTH
95
+ offense(document, lineno, "Indent with #{expected} spaces, not #{indent}") if indent != expected
96
+ end
97
+ end
98
+ end
99
+
100
+ class Tab < Rule
101
+ def offenses(document, source)
102
+ return [] unless source.include?("\t")
103
+
104
+ each_line(document).filter_map { |line, lineno| offense(document, lineno, "Indent with spaces, not tabs") if line.match?(/\A *[\t ]*\t/) }
105
+ end
106
+ end
107
+
108
+ class TrailingWhitespace < Rule
109
+ # Matches wherever a line may end in whitespace, and a little more (a \r before \n): it only skips files.
110
+ ANY_TRAILING_WHITESPACE = /[ \t\r\f\v](?=\r?\n|\z)/
111
+
112
+ def offenses(document, source)
113
+ return [] unless source.match?(ANY_TRAILING_WHITESPACE)
114
+
115
+ each_line(document).filter_map do |line, lineno|
116
+ next unless line.match?(/\s\z/)
117
+
118
+ rendered = document.outline[lineno].text || document.outline[lineno].kind == :text
119
+ offense(document, lineno, rendered ? "Trailing whitespace renders: write it with `'` or `>` instead" : "Remove trailing whitespace")
120
+ end
121
+ end
122
+ end
123
+
124
+ class EmptyLines < Rule
125
+ def offenses(document, _source)
126
+ previous_blank = true
127
+ document.outline.each_with_index.filter_map do |info, lineno|
128
+ next unless info
129
+
130
+ blank = info.kind == :blank
131
+ extra = blank && previous_blank
132
+ previous_blank = blank
133
+ offense(document, lineno, "Remove this extra empty line") if extra
134
+ end
135
+ end
136
+ end
137
+
138
+ class TrailingBlankLines < Rule
139
+ def offenses(document, source)
140
+ return [] if source.empty?
141
+ return [offense(document, source.lines.size, "End the file with a newline")] unless source.end_with?("\n")
142
+
143
+ source.end_with?("\n\n") ? [offense(document, source.lines.size, "Remove the empty lines at the end of the file")] : []
144
+ end
145
+ end
146
+
147
+ class ControlStatementSpacing < Rule
148
+ TAG_OUTPUT = /\A(\s*)(==?)(['<>]*)(\s*)(?=\S)/
149
+
150
+ def offenses(document, _source)
151
+ document.outline.each_with_index.filter_map do |info, lineno|
152
+ next unless info
153
+
154
+ line = document.lines[lineno - 1]
155
+ if info.output_column
156
+ line[info.output_column..] =~ TAG_OUTPUT
157
+ offense(document, lineno, "Put a space before and after `#{$2}`") if $1.empty? || $4.empty?
158
+ elsif info.kind == :structure
159
+ case line.lstrip
160
+ when /\A-(?=\S)/ then offense(document, lineno, "Put a space after `-`")
161
+ when /\A((?>==?[<>]*))(?=\S)/ then offense(document, lineno, "Put a space after `#{$1}`")
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
167
+
168
+ class RedundantDiv < Rule
169
+ def offenses(document, _source)
170
+ document.outline.each_with_index.filter_map do |info, lineno|
171
+ next unless info&.kind == :structure && document.lines[lineno - 1].lstrip.match?(Formatter::REDUNDANT_DIV)
172
+
173
+ offense(document, lineno, "`div` is implied by the `.class` or `#id` shortcut")
174
+ end
175
+ end
176
+ end
177
+
178
+ # Slim-lint's rules on code and tags.
179
+
180
+ class LineLength < Rule
181
+ DEFAULTS = {enabled: true, max: 120}.freeze
182
+
183
+ def offenses(document, _source)
184
+ max = options[:max]
185
+ each_line(document).filter_map { |line, lineno| offense(document, lineno, "Line is too long [#{line.length}/#{max}]") if line.length > max }
186
+ end
187
+ end
188
+
189
+ class CommentControlStatement < Rule
190
+ ALLOWED = /\A\s*#\s*(rubocop:|Template Dependency:)/
191
+
192
+ def offenses(document, _source)
193
+ document.enum_for(:each_node).filter_map do |node,|
194
+ next unless node.is_a?(AST::Control) && node.code.match?(/\A\s*#/) && !node.code.match?(ALLOWED)
195
+
196
+ offense(document, document.line_of(node), "Write comments with `/`, not `- #`: they cost nothing at render time")
197
+ end
198
+ end
199
+ end
200
+
201
+ class EmptyControlStatement < Rule
202
+ def offenses(document, _source)
203
+ document.enum_for(:each_node).filter_map do |node,|
204
+ offense(document, document.line_of(node), "Remove this empty `-`") if node.is_a?(AST::Control) && node.code.strip.empty?
205
+ end
206
+ end
207
+ end
208
+
209
+ class ConsecutiveControlStatements < Rule
210
+ DEFAULTS = {enabled: true, max: 2}.freeze
211
+
212
+ def offenses(document, _source)
213
+ sibling_lists = [document.nodes, *document.visits.filter_map { |node,| Document.children_of(node) }]
214
+ sibling_lists.select! { |siblings| siblings.count { flat_control?(_1) } > options[:max] }
215
+ sibling_lists.flat_map do |siblings|
216
+ siblings.chunk_while { |control, following| flat_control?(control) && flat_control?(following) }
217
+ .filter_map do
218
+ next unless _1.size > options[:max] && flat_control?(_1.first)
219
+
220
+ offense(document, document.line_of(_1.first), "Merge these #{_1.size} lines of Ruby into a `ruby:` block")
221
+ end
222
+ end
223
+ end
224
+
225
+ private
226
+
227
+ def flat_control?(node)
228
+ node.is_a?(AST::Control) && AST.empty?(node.children)
229
+ end
230
+ end
231
+
232
+ class TagCase < Rule
233
+ def offenses(document, _source)
234
+ return [] if document.xml?
235
+
236
+ document.enum_for(:each_node).filter_map do |node, ancestors|
237
+ next unless node.is_a?(AST::Tag) && node.name.match?(/[A-Z]/)
238
+ next if ancestors.any? { _1.is_a?(AST::Tag) && _1.name == "svg" }
239
+
240
+ offense(document, document.line_of(node), "Write `#{node.name.downcase}` in lowercase")
241
+ end
242
+ end
243
+ end
244
+
245
+ class InstanceVariables < Rule
246
+ INSTANCE_VARIABLE = /(?<![\w@])@[a-z_]\w*/
247
+
248
+ def offenses(document, _source)
249
+ return [] unless document.partial?
250
+
251
+ document.enum_for(:each_node).filter_map do |node,|
252
+ code = case node
253
+ when AST::Control, AST::Output then node.code
254
+ when AST::Tag then node.attributes.filter_map { _1.code if _1.respond_to?(:code) }.join(" ")
255
+ end
256
+ next unless code && (variable = code[INSTANCE_VARIABLE])
257
+
258
+ offense(document, document.line_of(node), "Pass `#{variable}` to the partial as a local")
259
+ end
260
+ end
261
+ end
262
+
263
+ class StrictLocals < Rule
264
+ DEFAULTS = {enabled: false}.freeze
265
+
266
+ def offenses(document, _source)
267
+ return [] if !document.partial? || document.lines.any? { _1.match?(%r{\A\s*/#\s*locals:}) }
268
+
269
+ [offense(document, 1, "Declare the partial's locals: `/# locals: (name:)`")]
270
+ end
271
+ end
272
+
273
+ # HTML and accessibility, after Herb, erb_lint and eslint-plugin-jsx-a11y.
274
+
275
+ class DuplicateId < Rule
276
+ def offenses(document, _source)
277
+ branch_by_id = {}
278
+ enum_for(:each_tag, document).filter_map do |tag, ancestors|
279
+ branch = ancestors.reverse.find { _1.is_a?(AST::Control) }
280
+ literal_attributes(tag, "id").filter_map do |attribute|
281
+ id = literal_value(attribute)
282
+ if branch_by_id.key?(id) && branch_by_id[id].equal?(branch)
283
+ offense(document, document.line_of(tag), "The id `#{id}` is already used in this template")
284
+ else
285
+ branch_by_id[id] = branch
286
+ nil
287
+ end
288
+ end
289
+ end.flatten
290
+ end
291
+ end
292
+
293
+ class ImageAlt < Rule
294
+ def offenses(document, _source)
295
+ missing_attribute(document, "img", "alt", 'Describe the image with `alt`, or mark it decorative with `alt=""`')
296
+ end
297
+ end
298
+
299
+ class IframeTitle < Rule
300
+ def offenses(document, _source)
301
+ missing_attribute(document, "iframe", "title", "Name the frame with `title`")
302
+ end
303
+ end
304
+
305
+ class AnchorHref < Rule
306
+ def offenses(document, _source)
307
+ missing_attribute(document, "a", "href", "A link without `href` is not a link: use a `button` for actions")
308
+ end
309
+ end
310
+
311
+ class ButtonType < Rule
312
+ DEFAULTS = {enabled: false}.freeze
313
+
314
+ def offenses(document, _source)
315
+ missing_attribute(document, "button", "type", 'Say what the button does with `type="button"` or `type="submit"`')
316
+ end
317
+ end
318
+
319
+ class NestedLinks < Rule
320
+ def offenses(document, _source)
321
+ enum_for(:each_tag, document, "a").filter_map do |tag, ancestors|
322
+ next unless ancestors.any? { _1.is_a?(AST::Tag) && _1.name == "a" }
323
+
324
+ offense(document, document.line_of(tag), "Links cannot contain links")
325
+ end
326
+ end
327
+ end
328
+
329
+ class PositiveTabindex < Rule
330
+ def offenses(document, _source)
331
+ enum_for(:each_tag, document).flat_map do |tag,|
332
+ literal_attributes(tag, "tabindex").filter_map do |attribute|
333
+ next unless literal_value(attribute).to_i.positive?
334
+
335
+ offense(document, document.line_of(tag), "A positive `tabindex` breaks the reading order: use 0 or -1")
336
+ end
337
+ end
338
+ end
339
+ end
340
+
341
+ class BooleanAttributeValue < Rule
342
+ BOOLEAN_ATTRIBUTES = %w[allowfullscreen async autofocus autoplay checked controls default defer disabled formnovalidate
343
+ hidden inert ismap loop multiple muted nomodule novalidate open playsinline readonly required
344
+ reversed selected].freeze
345
+
346
+ def offenses(document, _source)
347
+ enum_for(:each_tag, document).flat_map do |tag,|
348
+ tag.attributes.filter_map do |attribute|
349
+ next unless attribute.respond_to?(:name) && BOOLEAN_ATTRIBUTES.include?(attribute.name)
350
+ next unless ["", attribute.name].include?(literal_value(attribute))
351
+
352
+ offense(document, document.line_of(tag), "Write `#{attribute.name}=true`, or `#{attribute.name}` alone inside an attribute list")
353
+ end
354
+ end
355
+ end
356
+ end
357
+
358
+ class AriaRole < Rule
359
+ ROLES = %w[alert alertdialog application article banner blockquote button caption cell checkbox code columnheader
360
+ combobox complementary contentinfo definition deletion dialog document emphasis feed figure form generic
361
+ grid gridcell group heading img insertion link list listbox listitem log main marquee math menu menubar
362
+ menuitem menuitemcheckbox menuitemradio meter navigation none note option paragraph presentation
363
+ progressbar radio radiogroup region row rowgroup rowheader scrollbar search searchbox separator slider
364
+ spinbutton status strong subscript superscript switch tab table tablist tabpanel term textbox time timer
365
+ toolbar tooltip tree treegrid treeitem].freeze
366
+
367
+ def offenses(document, _source)
368
+ enum_for(:each_tag, document).flat_map do |tag,|
369
+ literal_attributes(tag, "role").flat_map do |attribute|
370
+ literal_value(attribute).split.filter_map do |role|
371
+ next if ROLES.include?(role) || role.start_with?("doc-", "graphics-")
372
+
373
+ offense(document, document.line_of(tag), "`#{role}` is not an ARIA role")
374
+ end
375
+ end
376
+ end
377
+ end
378
+ end
379
+
380
+ class AriaAttribute < Rule
381
+ ATTRIBUTES = %w[activedescendant atomic autocomplete braillelabel brailleroledescription busy checked colcount
382
+ colindex colindextext colspan controls current describedby description details disabled
383
+ dropeffect errormessage expanded flowto grabbed haspopup hidden invalid keyshortcuts label
384
+ labelledby level live modal multiline multiselectable orientation owns placeholder posinset
385
+ pressed readonly relevant required roledescription rowcount rowindex rowindextext rowspan selected
386
+ setsize sort valuemax valuemin valuenow valuetext].map { "aria-#{_1}" }.freeze
387
+
388
+ def offenses(document, _source)
389
+ enum_for(:each_tag, document).flat_map do |tag,|
390
+ attribute_names(tag).filter_map do |name|
391
+ next unless name.start_with?("aria-") && !ATTRIBUTES.include?(name)
392
+
393
+ offense(document, document.line_of(tag), "`#{name}` is not an ARIA attribute")
394
+ end
395
+ end
396
+ end
397
+ end
398
+
399
+ class DeprecatedClasses < Rule
400
+ DEFAULTS = {enabled: true, classes: {}}.freeze
401
+
402
+ # A key ending in `*` matches every class with that prefix, and carries the rest over: `ml-*: ms-*`.
403
+ def initialize(options)
404
+ super
405
+ prefixed, exact = options[:classes].partition { |deprecated,| deprecated.end_with?("*") }
406
+ @replacement_by_class = exact.to_h
407
+ @prefixed_replacements = prefixed.map { |deprecated, replacement| [deprecated.delete_suffix("*"), replacement] }
408
+ end
409
+
410
+ def offenses(document, _source)
411
+ return [] if options[:classes].empty?
412
+
413
+ enum_for(:each_tag, document).flat_map do |tag,|
414
+ literal_attributes(tag, "class").flat_map { literal_value(_1).split }.filter_map do |css_class|
415
+ next unless (replacement = replacement_of(css_class))
416
+
417
+ offense(document, document.line_of(tag), "`#{css_class}` is deprecated: use `#{replacement}`")
418
+ end
419
+ end
420
+ end
421
+
422
+ private
423
+
424
+ def replacement_of(css_class)
425
+ return @replacement_by_class[css_class] if @replacement_by_class.key?(css_class)
426
+
427
+ prefix, replacement = @prefixed_replacements.find { |prefix,| css_class.start_with?(prefix) }
428
+ replacement&.sub("*", css_class.delete_prefix(prefix))
429
+ end
430
+ end
431
+
432
+ class DeprecatedAttributes < Rule
433
+ DEFAULTS = {enabled: true, attributes: {}}.freeze
434
+
435
+ def offenses(document, _source)
436
+ replacement_by_attribute = options[:attributes]
437
+ return [] if replacement_by_attribute.empty?
438
+
439
+ enum_for(:each_tag, document).flat_map do |tag,|
440
+ attribute_names(tag).filter_map do |name|
441
+ next unless replacement_by_attribute.key?(name)
442
+
443
+ offense(document, document.line_of(tag), "`#{name}` is deprecated: use `#{replacement_by_attribute[name]}`")
444
+ end
445
+ end
446
+ end
447
+ end
448
+
449
+ class InlineStyles < Rule
450
+ DEFAULTS = {enabled: false}.freeze
451
+
452
+ def offenses(document, _source)
453
+ enum_for(:each_tag, document).filter_map do |tag,|
454
+ offense(document, document.line_of(tag), "Move inline styles to a stylesheet") if literal_attributes(tag, "style").any?
455
+ end
456
+ end
457
+ end
458
+
459
+ class UnescapedOutput < Rule
460
+ DEFAULTS = {enabled: false}.freeze
461
+ SKIPS_ESCAPING = /\.html_safe\b|(?<![\w.:@])raw[ (]/
462
+ UNESCAPED_INTERPOLATION = /#\{\{/
463
+ MESSAGE = "skips HTML escaping: output with `=`, or render HTML from a translation key ending in `_html`"
464
+
465
+ def offenses(document, _source)
466
+ document.enum_for(:each_node).flat_map do |node, ancestors|
467
+ reasons_of(node).map { offense(document, line_near(document, node, ancestors), "#{_1} #{MESSAGE}") }
468
+ end
469
+ end
470
+
471
+ private
472
+
473
+ def reasons_of(node)
474
+ case node
475
+ when AST::Output then [*("`==`" unless node.escape), *code_reason(node.code)]
476
+ when AST::Control then code_reason(node.code)
477
+ when AST::Tag then node.attributes.flat_map { attribute_reasons(_1) }
478
+ when AST::Text then node.text.match?(UNESCAPED_INTERPOLATION) ? ["`\#{{}}`"] : []
479
+ when AST::Embedded then (node.engine == "ruby") ? code_reason(node.children.grep(AST::Text).map(&:text).join("\n")) : []
480
+ else []
481
+ end
482
+ end
483
+
484
+ def attribute_reasons(attribute)
485
+ case attribute
486
+ when AST::CodeAttribute then [*("`==`" unless attribute.escape), *code_reason(attribute.code)]
487
+ when AST::Splat then code_reason(attribute.code)
488
+ else []
489
+ end
490
+ end
491
+
492
+ def code_reason(code)
493
+ return [] unless (call = code[SKIPS_ESCAPING])
494
+
495
+ [call.start_with?(".") ? "`html_safe`" : "`raw`"]
496
+ end
497
+ end
498
+
499
+ class EmbeddedEngines < Rule
500
+ DEFAULTS = {enabled: false, forbidden_engines: []}.freeze
501
+
502
+ def offenses(document, _source)
503
+ forbidden_engines = options[:forbidden_engines]
504
+ document.enum_for(:each_node).filter_map do |node,|
505
+ next unless node.is_a?(AST::Embedded) && forbidden_engines.include?(node.engine)
506
+
507
+ offense(document, document.line_of(node), "The `#{node.engine}:` engine is forbidden here: move this code to a file of its own")
508
+ end
509
+ end
510
+ end
511
+
512
+ class HardcodedText < Rule
513
+ DEFAULTS = {enabled: false}.freeze
514
+ NOT_WORDS = /#\{\{?[^{}]*\}?\}|&(?:[a-z]+|#\d+|#x\h+);|<!\[CDATA\[|\]\]>|<[^<>]+>/i
515
+ EXCERPT_LENGTH = 40
516
+
517
+ def offenses(document, _source)
518
+ document.enum_for(:each_node).filter_map do |node, ancestors|
519
+ next unless node.is_a?(AST::TextBlock) && ancestors.none?(AST::Comment)
520
+
521
+ text = node.children.grep(AST::Text).map(&:text).join(" ").gsub(/\s+/, " ").strip
522
+ next unless text.gsub(NOT_WORDS, "").match?(/\p{L}/)
523
+
524
+ excerpt = (text.length > EXCERPT_LENGTH) ? "#{text[0, EXCERPT_LENGTH]}…" : text
525
+ offense(document, line_near(document, node, ancestors), "Translate `#{excerpt}` instead of writing it in the template")
526
+ end
527
+ end
528
+ end
529
+
530
+ # Ruby that a plainer line of Slim says better, after haml-lint.
531
+
532
+ class UnnecessaryInterpolation < Rule
533
+ LONE_INTERPOLATION = /\A#\{([^{}]+)\}\z/
534
+
535
+ def offenses(document, _source)
536
+ document.enum_for(:each_node).filter_map do |node, ancestors|
537
+ next unless node.is_a?(AST::TextBlock)
538
+ next unless (lineno = document.line_of(node) || document.line_of(ancestors.last))
539
+
540
+ texts = node.children.grep(AST::Text)
541
+ next unless texts.size == 1 && texts.first.text =~ LONE_INTERPOLATION
542
+
543
+ offense(document, lineno, "Write `= #{$1.strip}` instead of interpolating it alone")
544
+ end
545
+ end
546
+ end
547
+
548
+ class UnnecessaryStringOutput < Rule
549
+ PLAIN_STRING = /\A(["'])([^"'\\#&<>\s](?:[^"'\\#&<>]*[^"'\\#&<>\s])?)\1\z/
550
+ # Text starting with one of these, written inline after a tag, reads as attributes or output: `span (note)`.
551
+ READ_AS_SYNTAX_AFTER_TAG = /\A[(\[{=*\/]/
552
+
553
+ def offenses(document, _source)
554
+ document.enum_for(:each_node).filter_map do |node, ancestors|
555
+ next unless node.is_a?(AST::Output) && AST.empty?(node.children) && node.code =~ PLAIN_STRING
556
+
557
+ text = $2
558
+ lineno = document.line_of(node)
559
+ message = if text.match?(READ_AS_SYNTAX_AFTER_TAG) && ancestors.any? && document.line_of(ancestors.last) == lineno
560
+ "Write the text `| #{text}` on its own line, under the tag: inline, it would read as attributes"
561
+ else
562
+ "Write the text `| #{text}` instead of outputting a string"
563
+ end
564
+ offense(document, lineno, message)
565
+ end
566
+ end
567
+ end
568
+
569
+ class Zwsp < Rule
570
+ INVISIBLE = /[\u200B-\u200D\u2060\uFEFF]/
571
+ # The zero-width joiner of 🐈‍⬛ or 👩🏻‍💻: between two pictographs, after an optional skin tone or emoji style.
572
+ EMOJI_JOINER = /(?<=\p{Extended_Pictographic}|[\u{1F3FB}-\u{1F3FF}]|\uFE0F)\u200D(?=\p{Extended_Pictographic})/
573
+
574
+ def offenses(document, source)
575
+ return [] unless source.match?(INVISIBLE)
576
+
577
+ each_line(document).filter_map do |line, lineno|
578
+ line = line.delete_prefix("\uFEFF") if lineno == 1
579
+ offense(document, lineno, "Remove the invisible zero-width character") if line.gsub(EMOJI_JOINER, "").match?(INVISIBLE)
580
+ end
581
+ end
582
+ end
583
+ end
584
+ end
585
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module SuperSlim
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 = ".super-slim.yml"
46
+
47
+ def self.load(directory = Dir.pwd)
48
+ new(configuration(directory).fetch("linters", {}), directory)
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
+ # include: and exclude: globs match paths relative to `root`, the directory of .super-slim.yml.
57
+ def initialize(options_by_rule_name = {}, root = Dir.pwd)
58
+ @root = File.join(File.expand_path(root), "")
59
+ @rules = Rules.all.filter_map do |rule_class|
60
+ options = rule_class::DEFAULTS.merge(options_by_rule_name.fetch(rule_class.rule_name, {}).transform_keys(&:to_sym))
61
+ rule_class.new(options) if options[:enabled]
62
+ end
63
+ end
64
+
65
+ def call(path, source)
66
+ parser = Parser.new(file: path, outline: true)
67
+ nodes = parser.call(source)
68
+ visits = Document.visits_of(nodes)
69
+ document = Document.new(path:, lines: source.split(/\r?\n/), nodes:, outline: parser.outline, line_by_node: parser.line_by_node,
70
+ visits:, tag_visits: visits.select { |node,| node.is_a?(AST::Tag) })
71
+ relative_path = File.expand_path(path).delete_prefix(@root)
72
+ offenses = @rules.select { _1.applies_to?(relative_path) }.flat_map { _1.offenses(document, source) }
73
+ # sort_by is not stable: the index keeps offenses of one line in rule order.
74
+ offenses.each_with_index.sort_by { |offense, index| [offense.lineno, index] }.map(&:first)
75
+ rescue SyntaxError => exception
76
+ [Offense.new(path, exception.lineno, "Syntax", exception.error)]
77
+ end
78
+ end
79
+ end
80
+
81
+ require_relative "linter/rules"