erb_lint 0.0.4

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 8866aae317226178c623de53d4c5e0791643c894
4
+ data.tar.gz: fac55efcf4a15f251f537af97039b8669960c00b
5
+ SHA512:
6
+ metadata.gz: b92425a8c4fbe2c65fde42a8dab091c841e2bc0f70b29833e49c06fc99acc85b42639dc9129f200b99195930e9f7fbb96d58fdcaaefb947cd40ad76008ffe765
7
+ data.tar.gz: aefbb18d6756d5e08045c4d5c8c0afb6ef8fb897f0b73c72fe905158b40d1cbc1114bfada2ec59029165b1a9d72930a172c17b6cfe059a11283bf8ec80e41bc9
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ERBLint
4
+ # Defines common functionality available to all linters.
5
+ class Linter
6
+ class << self
7
+ attr_accessor :simple_name
8
+
9
+ # When defining a Linter class, define its simple name as well. This
10
+ # assumes that the module hierarchy of every linter starts with
11
+ # `ERBLint::Linters::`, and removes this part of the class name.
12
+ #
13
+ # `ERBLint::Linters::Foo.simple_name` #=> "Foo"
14
+ # `ERBLint::Linters::Compass::Bar.simple_name` #=> "Compass::Bar"
15
+ def inherited(linter)
16
+ name_parts = linter.name.split('::')
17
+ name = name_parts.length < 3 ? '' : name_parts[2..-1].join('::')
18
+ linter.simple_name = name
19
+ end
20
+ end
21
+
22
+ # Must be implemented by the concrete inheriting class.
23
+ def initialize(_config)
24
+ raise NotImplementedError, "must implement ##{__method__}"
25
+ end
26
+
27
+ def lint_file(file_content)
28
+ lines = file_content.scan(/[^\n]*\n|[^\n]+/)
29
+ lint_lines(lines)
30
+ end
31
+
32
+ protected
33
+
34
+ # The lint_lines method that contains the logic for the linter and returns a list of errors.
35
+ # Must be implemented by the concrete inheriting class.
36
+ def lint_lines(_lines)
37
+ raise NotImplementedError, "must implement ##{__method__}"
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ERBLint
4
+ # Stores all linters available to the application.
5
+ module LinterRegistry
6
+ CUSTOM_LINTERS_DIR = '.erb-linters'
7
+ @linters = []
8
+
9
+ class << self
10
+ attr_reader :linters
11
+
12
+ def included(linter_class)
13
+ @linters << linter_class
14
+ end
15
+
16
+ def load_custom_linters(directory = CUSTOM_LINTERS_DIR)
17
+ ruby_files = Dir.glob(File.expand_path(File.join(directory, '**', '*.rb')))
18
+ ruby_files.each { |file| require file }
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ERBLint
4
+ module Linters
5
+ # Checks for deprecated classes in the start tags of HTML elements.
6
+ class DeprecatedClasses < Linter
7
+ include LinterRegistry
8
+
9
+ def initialize(config)
10
+ @deprecated_ruleset = []
11
+ config.fetch('rule_set', []).each do |rule|
12
+ suggestion = rule.fetch('suggestion', '')
13
+ rule.fetch('deprecated', []).each do |class_expr|
14
+ @deprecated_ruleset.push(
15
+ class_expr: class_expr,
16
+ suggestion: suggestion
17
+ )
18
+ end
19
+ end
20
+ @deprecated_ruleset.freeze
21
+
22
+ @addendum = config.fetch('addendum', '')
23
+ end
24
+
25
+ protected
26
+
27
+ def lint_lines(lines)
28
+ errors = []
29
+
30
+ lines.each_with_index do |line, index|
31
+ start_tags = StartTagHelper.start_tags(line)
32
+ start_tags.each do |start_tag|
33
+ start_tag.attributes.select(&:class?).each do |class_attr|
34
+ class_attr.value.split(' ').each do |class_name|
35
+ errors.push(*generate_errors(class_name, index + 1))
36
+ end
37
+ end
38
+ end
39
+ end
40
+ errors
41
+ end
42
+
43
+ private
44
+
45
+ def generate_errors(class_name, line_number)
46
+ violated_rules(class_name).map do |violated_rule|
47
+ suggestion = " #{violated_rule[:suggestion]}".rstrip
48
+ message = "Deprecated class `%s` detected matching the pattern `%s`.%s #{@addendum}".strip
49
+ {
50
+ line: line_number,
51
+ message: format(message, class_name, violated_rule[:class_expr], suggestion)
52
+ }
53
+ end
54
+ end
55
+
56
+ def violated_rules(class_name)
57
+ @deprecated_ruleset.select do |deprecated_rule|
58
+ /\A#{deprecated_rule[:class_expr]}\z/.match(class_name)
59
+ end
60
+ end
61
+ end
62
+
63
+ # Provides methods and classes for finding HTML start tags and their attributes.
64
+ module StartTagHelper
65
+ # These patterns cover a superset of the W3 HTML5 specification.
66
+ # Additional cases not included in the spec include those that are still rendered by some browsers.
67
+
68
+ # Attribute Patterns
69
+ # https://www.w3.org/TR/html5/syntax.html#syntax-attributes
70
+
71
+ # attribute names must be non empty and can't contain a certain set of special characters
72
+ ATTRIBUTE_NAME_PATTERN = %r{[^\s"'>\/=]+}
73
+
74
+ ATTRIBUTE_VALUE_PATTERN = %r{
75
+ "([^"]*)" | # double-quoted value
76
+ '([^']*)' | # single-quoted value
77
+ ([^\s"'=<>`]+) # unquoted non-empty value without special characters
78
+ }x
79
+
80
+ # attributes can be empty or have an attribute value
81
+ ATTRIBUTE_PATTERN = %r{
82
+ #{ATTRIBUTE_NAME_PATTERN} # attribute name
83
+ (
84
+ \s*=\s* # any whitespace around equals sign
85
+ (#{ATTRIBUTE_VALUE_PATTERN}) # attribute value
86
+ )? # attributes can be empty or have an assignemnt.
87
+ }x
88
+
89
+ # Start tag Patterns
90
+ # https://www.w3.org/TR/html5/syntax.html#syntax-start-tag
91
+
92
+ TAG_NAME_PATTERN = /[A-Za-z0-9]+/ # maybe add _ < ? etc later since it gets interpreted by some browsers
93
+
94
+ START_TAG_PATTERN = %r{
95
+ <(#{TAG_NAME_PATTERN}) # start of tag with tag name
96
+ (
97
+ (
98
+ \s+ # required whitespace between tag name and first attribute and between attributes
99
+ #{ATTRIBUTE_PATTERN} # attributes
100
+ )*
101
+ )? # having an attribute block is optional
102
+ \/?> # void or foreign elements can have a slash before tag close
103
+ }x
104
+
105
+ # Represents and provides an interface for a start tag found in the HTML.
106
+ class StartTag
107
+ attr_accessor :tag_name, :attributes
108
+
109
+ def initialize(tag_name, attributes)
110
+ @tag_name = tag_name
111
+ @attributes = attributes
112
+ end
113
+ end
114
+
115
+ # Represents and provides an interface for an attribute found in a start tag in the HTML.
116
+ class Attribute
117
+ ATTR_NAME_CLASS_PATTERN = /\Aclass\z/i # attribute names are case-insensitive
118
+ attr_accessor :attribute_name, :value
119
+
120
+ def initialize(attribute_name, value)
121
+ @attribute_name = attribute_name
122
+ @value = value
123
+ end
124
+
125
+ def class?
126
+ ATTR_NAME_CLASS_PATTERN.match(@attribute_name)
127
+ end
128
+ end
129
+
130
+ class << self
131
+ def start_tags(line)
132
+ # TODO: Implement String Scanner to track quotes before the start tag begins to ensure that it is
133
+ # not enclosed inside of a string. Alternatively this problem would be solved by using
134
+ # a 3rd party parser like Nokogiri::XML
135
+
136
+ start_tag_matching_groups = line.scan(/(#{START_TAG_PATTERN})/)
137
+ start_tag_matching_groups.map do |start_tag_matching_group|
138
+ tag_name = start_tag_matching_group[1]
139
+
140
+ # attributes_string can be nil if there is no space after the tag name (and therefore no attributes).
141
+ attributes_string = start_tag_matching_group[2] || ''
142
+
143
+ attribute_list = attributes(attributes_string)
144
+
145
+ StartTag.new(tag_name, attribute_list)
146
+ end
147
+ end
148
+
149
+ private
150
+
151
+ def attributes(attributes_string)
152
+ attributes_string.scan(/(#{ATTRIBUTE_PATTERN})/).map do |attribute_matching_group|
153
+ entire_string = attribute_matching_group[0]
154
+ value_with_equal_sign = attribute_matching_group[1] || '' # This can be nil if attribute is empty
155
+ name = entire_string.sub(value_with_equal_sign, '')
156
+
157
+ # The 3 captures [3..5] are the possibilities specified in ATTRIBUTE_VALUE_PATTERN
158
+ possible_value_formats = attribute_matching_group[3..5]
159
+ value = possible_value_formats.reduce { |a, e| a.nil? ? e : a }
160
+
161
+ Attribute.new(name, value)
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ERBLint
4
+ module Linters
5
+ # Checks for final newlines at the end of a file.
6
+ class FinalNewline < Linter
7
+ include LinterRegistry
8
+
9
+ def initialize(config)
10
+ @new_lines_should_be_present = config['present'].nil? ? true : config['present']
11
+ end
12
+
13
+ protected
14
+
15
+ def lint_lines(lines)
16
+ errors = []
17
+ return errors if lines.empty?
18
+
19
+ ends_with_newline = lines.last.chars[-1] == "\n"
20
+
21
+ if @new_lines_should_be_present && !ends_with_newline
22
+ errors.push(
23
+ line: lines.length,
24
+ message: 'Missing a trailing newline at the end of the file.'
25
+ )
26
+ elsif !@new_lines_should_be_present && ends_with_newline
27
+ errors.push(
28
+ line: lines.length,
29
+ message: 'Remove the trailing newline at the end of the file.'
30
+ )
31
+ end
32
+ errors
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ERBLint
4
+ # Runs all enabled linters against an html.erb file.
5
+ class Runner
6
+ def initialize(config = {})
7
+ @config = default_config.merge(config || {})
8
+
9
+ LinterRegistry.load_custom_linters
10
+ @linters = LinterRegistry.linters.select { |linter_class| linter_enabled?(linter_class) }
11
+ @linters.map! do |linter_class|
12
+ linter_config = @config.dig('linters', linter_class.simple_name)
13
+ linter_class.new(linter_config)
14
+ end
15
+ end
16
+
17
+ def run(filename, file_content)
18
+ linters_for_file = @linters.select { |linter| !linter_excludes_file?(linter, filename) }
19
+ linters_for_file.map do |linter|
20
+ {
21
+ linter_name: linter.class.simple_name,
22
+ errors: linter.lint_file(file_content)
23
+ }
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def linter_enabled?(linter_class)
30
+ linter_config = @config.dig('linters', linter_class.simple_name)
31
+ return false if linter_config.nil?
32
+ linter_config['enabled'] || false
33
+ end
34
+
35
+ def linter_excludes_file?(linter, filename)
36
+ excluded_filepaths = @config.dig('linters', linter.class.simple_name, 'exclude') || []
37
+ excluded_filepaths.each do |path|
38
+ return true if File.fnmatch?(path, filename)
39
+ end
40
+ false
41
+ end
42
+
43
+ def default_config
44
+ {
45
+ 'linters' => {
46
+ 'FinalNewline' => {
47
+ 'enabled' => true
48
+ }
49
+ }
50
+ }
51
+ end
52
+ end
53
+ end
data/lib/erb_lint.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb_lint/linter_registry'
4
+ require 'erb_lint/linter'
5
+ require 'erb_lint/runner'
6
+
7
+ # Load linters
8
+ Dir[File.expand_path('erb_lint/linters/**/*.rb', File.dirname(__FILE__))].each do |file|
9
+ require file
10
+ end
metadata ADDED
@@ -0,0 +1,78 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: erb_lint
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.4
5
+ platform: ruby
6
+ authors:
7
+ - Justin Chan
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-10-27 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rubocop
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ description: ERB Linter tool.
42
+ email:
43
+ - justin.the.c@gmail.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - lib/erb_lint.rb
49
+ - lib/erb_lint/linter.rb
50
+ - lib/erb_lint/linter_registry.rb
51
+ - lib/erb_lint/linters/deprecated_classes.rb
52
+ - lib/erb_lint/linters/final_newline.rb
53
+ - lib/erb_lint/runner.rb
54
+ homepage: https://github.com/justinthec/erb-lint
55
+ licenses:
56
+ - MIT
57
+ metadata: {}
58
+ post_install_message:
59
+ rdoc_options: []
60
+ require_paths:
61
+ - lib
62
+ required_ruby_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ required_rubygems_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ requirements: []
73
+ rubyforge_project:
74
+ rubygems_version: 2.5.2.1
75
+ signing_key:
76
+ specification_version: 4
77
+ summary: ERB lint tool
78
+ test_files: []