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.
- checksums.yaml +7 -0
- data/LICENSE +46 -0
- data/exe/slimx +6 -0
- data/lib/slimx/ast.rb +34 -0
- data/lib/slimx/cli.rb +63 -0
- data/lib/slimx/compiler.rb +467 -0
- data/lib/slimx/formatter.rb +99 -0
- data/lib/slimx/linter/rules.rb +446 -0
- data/lib/slimx/linter.rb +78 -0
- data/lib/slimx/parallel.rb +36 -0
- data/lib/slimx/parser.rb +498 -0
- data/lib/slimx/rails_handler.rb +31 -0
- data/lib/slimx/railtie.rb +12 -0
- data/lib/slimx/runtime.rb +98 -0
- data/lib/slimx/shortcuts.rb +62 -0
- data/lib/slimx/tilt.rb +18 -0
- data/lib/slimx/version.rb +5 -0
- data/lib/slimx.rb +68 -0
- metadata +60 -0
|
@@ -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
|
data/lib/slimx/linter.rb
ADDED
|
@@ -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
|