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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +118 -0
- data/LICENSE +21 -0
- data/LICENSE-slim +22 -0
- data/README.md +217 -0
- data/config/rubocop.yml +212 -0
- data/exe/super-slim +6 -0
- data/lib/super-slim.rb +4 -0
- data/lib/super_slim/ast.rb +34 -0
- data/lib/super_slim/cli.rb +71 -0
- data/lib/super_slim/compiler.rb +467 -0
- data/lib/super_slim/formatter/line_wrapper.rb +131 -0
- data/lib/super_slim/formatter.rb +125 -0
- data/lib/super_slim/linter/rules.rb +585 -0
- data/lib/super_slim/linter.rb +81 -0
- data/lib/super_slim/parallel.rb +36 -0
- data/lib/super_slim/parser.rb +556 -0
- data/lib/super_slim/rails_handler.rb +31 -0
- data/lib/super_slim/railtie.rb +12 -0
- data/lib/super_slim/rubocop.rb +128 -0
- data/lib/super_slim/runtime.rb +103 -0
- data/lib/super_slim/shortcuts.rb +28 -0
- data/lib/super_slim/version.rb +5 -0
- data/lib/super_slim.rb +53 -0
- metadata +86 -0
|
@@ -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"
|