liquid_lint 1.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.
Files changed (61) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.md +1 -0
  3. data/bin/liquid-lint +7 -0
  4. data/config/default.yml +99 -0
  5. data/lib/liquid_lint/atom.rb +98 -0
  6. data/lib/liquid_lint/capture_map.rb +19 -0
  7. data/lib/liquid_lint/cli.rb +163 -0
  8. data/lib/liquid_lint/configuration.rb +109 -0
  9. data/lib/liquid_lint/configuration_loader.rb +86 -0
  10. data/lib/liquid_lint/constants.rb +10 -0
  11. data/lib/liquid_lint/document.rb +76 -0
  12. data/lib/liquid_lint/engine.rb +45 -0
  13. data/lib/liquid_lint/exceptions.rb +20 -0
  14. data/lib/liquid_lint/file_finder.rb +88 -0
  15. data/lib/liquid_lint/filters/attribute_processor.rb +31 -0
  16. data/lib/liquid_lint/filters/control_processor.rb +47 -0
  17. data/lib/liquid_lint/filters/inject_line_numbers.rb +43 -0
  18. data/lib/liquid_lint/filters/sexp_converter.rb +17 -0
  19. data/lib/liquid_lint/filters/splat_processor.rb +15 -0
  20. data/lib/liquid_lint/lint.rb +43 -0
  21. data/lib/liquid_lint/linter/comment_control_statement.rb +22 -0
  22. data/lib/liquid_lint/linter/consecutive_control_statements.rb +26 -0
  23. data/lib/liquid_lint/linter/control_statement_spacing.rb +24 -0
  24. data/lib/liquid_lint/linter/embedded_engines.rb +22 -0
  25. data/lib/liquid_lint/linter/empty_control_statement.rb +15 -0
  26. data/lib/liquid_lint/linter/empty_lines.rb +26 -0
  27. data/lib/liquid_lint/linter/file_length.rb +20 -0
  28. data/lib/liquid_lint/linter/line_length.rb +21 -0
  29. data/lib/liquid_lint/linter/redundant_div.rb +22 -0
  30. data/lib/liquid_lint/linter/rubocop.rb +116 -0
  31. data/lib/liquid_lint/linter/tab.rb +19 -0
  32. data/lib/liquid_lint/linter/tag_case.rb +15 -0
  33. data/lib/liquid_lint/linter/trailing_blank_lines.rb +21 -0
  34. data/lib/liquid_lint/linter/trailing_whitespace.rb +19 -0
  35. data/lib/liquid_lint/linter/zwsp.rb +18 -0
  36. data/lib/liquid_lint/linter.rb +93 -0
  37. data/lib/liquid_lint/linter_registry.rb +39 -0
  38. data/lib/liquid_lint/linter_selector.rb +79 -0
  39. data/lib/liquid_lint/logger.rb +103 -0
  40. data/lib/liquid_lint/matcher/anything.rb +11 -0
  41. data/lib/liquid_lint/matcher/base.rb +21 -0
  42. data/lib/liquid_lint/matcher/capture.rb +32 -0
  43. data/lib/liquid_lint/matcher/nothing.rb +13 -0
  44. data/lib/liquid_lint/options.rb +110 -0
  45. data/lib/liquid_lint/rake_task.rb +125 -0
  46. data/lib/liquid_lint/report.rb +25 -0
  47. data/lib/liquid_lint/reporter/checkstyle_reporter.rb +42 -0
  48. data/lib/liquid_lint/reporter/default_reporter.rb +41 -0
  49. data/lib/liquid_lint/reporter/emacs_reporter.rb +44 -0
  50. data/lib/liquid_lint/reporter/json_reporter.rb +52 -0
  51. data/lib/liquid_lint/reporter.rb +44 -0
  52. data/lib/liquid_lint/ruby_extract_engine.rb +36 -0
  53. data/lib/liquid_lint/ruby_extractor.rb +106 -0
  54. data/lib/liquid_lint/ruby_parser.rb +40 -0
  55. data/lib/liquid_lint/runner.rb +82 -0
  56. data/lib/liquid_lint/sexp.rb +106 -0
  57. data/lib/liquid_lint/sexp_visitor.rb +146 -0
  58. data/lib/liquid_lint/utils.rb +85 -0
  59. data/lib/liquid_lint/version.rb +6 -0
  60. data/lib/liquid_lint.rb +52 -0
  61. metadata +185 -0
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidLint
4
+ # Searches for tab indentation
5
+ class Linter::Tab < Linter
6
+ include LinterRegistry
7
+
8
+ MSG = 'Tab detected'
9
+
10
+ on_start do |_sexp|
11
+ dummy_node = Struct.new(:line)
12
+ document.source_lines.each_with_index do |line, index|
13
+ next unless line =~ /^( *)[\t ]*\t/
14
+
15
+ report_lint(dummy_node.new(index + 1), MSG)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidLint
4
+ # Searches for tags with uppercase characters.
5
+ class Linter::TagCase < Linter
6
+ include LinterRegistry
7
+
8
+ on [:html, :tag] do |sexp|
9
+ _, _, name = sexp
10
+ next unless name[/[A-Z]/]
11
+
12
+ report_lint(sexp, "Tag `#{name}` should be written as `#{name.downcase}`")
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidLint
4
+ # This linter looks for trailing blank lines and a final newline.
5
+ class Linter::TrailingBlankLines < Linter
6
+ include LinterRegistry
7
+
8
+ on_start do |_sexp|
9
+ dummy_node = Struct.new(:line)
10
+ next if document.source.empty?
11
+
12
+ if !document.source.end_with?("\n")
13
+ report_lint(dummy_node.new(document.source_lines.size),
14
+ 'No blank line in the end of file')
15
+ elsif document.source.lines.last.blank?
16
+ report_lint(dummy_node.new(document.source.lines.size),
17
+ 'Multiple empty lines in the end of file')
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidLint
4
+ # Checks for trailing whitespace.
5
+ class Linter::TrailingWhitespace < Linter
6
+ include LinterRegistry
7
+
8
+ on_start do |_sexp|
9
+ dummy_node = Struct.new(:line)
10
+
11
+ document.source_lines.each_with_index do |line, index|
12
+ next unless line =~ /\s+$/
13
+
14
+ report_lint(dummy_node.new(index + 1),
15
+ 'Line contains trailing whitespace')
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidLint
4
+ class Linter::Zwsp < Linter
5
+ include LinterRegistry
6
+
7
+ MSG = 'Remove zero-width space'
8
+
9
+ on_start do |_sexp|
10
+ dummy_node = Struct.new(:line)
11
+ document.source_lines.each_with_index do |line, index|
12
+ next unless line.include?("\u200b")
13
+
14
+ report_lint(dummy_node.new(index + 1), MSG)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidLint
4
+ # Base implementation for all lint checks.
5
+ #
6
+ # @abstract
7
+ class Linter
8
+ # Include definitions for Sexp pattern-matching helpers.
9
+ include SexpVisitor
10
+ extend SexpVisitor::DSL
11
+
12
+ # List of lints reported by this linter.
13
+ #
14
+ # @todo Remove once spec/support/shared_linter_context returns an array of
15
+ # lints for the subject instead of the linter itself.
16
+ attr_reader :lints
17
+
18
+ # Initializes a linter with the specified configuration.
19
+ #
20
+ # @param config [Hash] configuration for this linter
21
+ def initialize(config)
22
+ @config = config
23
+ @lints = []
24
+ end
25
+
26
+ # Runs the linter against the given Liquid document.
27
+ #
28
+ # @param document [LiquidLint::Document]
29
+ def run(document)
30
+ @document = document
31
+ @lints = []
32
+ @disabled_lines = nil
33
+ trigger_pattern_callbacks(document.sexp)
34
+ @lints
35
+ end
36
+
37
+ # Returns the simple name for this linter.
38
+ #
39
+ # @return [String]
40
+ def name
41
+ self.class.name.split('::').last
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :config, :document
47
+
48
+ # Record a lint for reporting back to the user.
49
+ #
50
+ # @param node [#line] node to extract the line number from
51
+ # @param message [String] error/warning to display to the user
52
+ def report_lint(node, message)
53
+ return if disabled_for_line?(node.line)
54
+
55
+ @lints << LiquidLint::Lint.new(self, @document.file, node.line, message)
56
+ end
57
+
58
+ # Parse Ruby code into an abstract syntax tree.
59
+ #
60
+ # @param source [String] Ruby code to parse
61
+ # @return [AST::Node]
62
+ def parse_ruby(source)
63
+ @ruby_parser ||= LiquidLint::RubyParser.new
64
+ @ruby_parser.parse(source)
65
+ end
66
+
67
+ def disabled_for_line?(line)
68
+ disabled_lines.include?(line)
69
+ end
70
+
71
+ def disabled_lines
72
+ @disabled_lines ||= begin
73
+ currently_disabled = false
74
+ @document.source_lines.each_with_index.each_with_object([]) do |pair, lines|
75
+ line = pair[0]
76
+ line_number = pair[1] + 1
77
+
78
+ if line =~ %r{/ liquid-lint:disable #{linter_name}}
79
+ currently_disabled = true
80
+ elsif line =~ %r{/ liquid-lint:enable #{linter_name}}
81
+ currently_disabled = false
82
+ elsif currently_disabled
83
+ lines << line_number
84
+ end
85
+ end
86
+ end
87
+ end
88
+
89
+ def linter_name
90
+ @linter_name ||= self.class.name.split('::').last
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidLint
4
+ class NoSuchLinter < StandardError; end
5
+
6
+ # Stores all defined linters.
7
+ module LinterRegistry
8
+ @linters = []
9
+
10
+ class << self
11
+ # List of all registered linters.
12
+ attr_reader :linters
13
+
14
+ # Executed when a linter includes the {LinterRegistry} module.
15
+ #
16
+ # This results in the linter being registered with the registry.
17
+ #
18
+ # @param subclass [Class]
19
+ def included(subclass)
20
+ @linters << subclass
21
+ end
22
+
23
+ # Return a list of {LiquidLint::Linter} {Class}es corresponding to the
24
+ # specified list of names.
25
+ #
26
+ # @param linter_names [Array<String>]
27
+ # @return [Array<Class>]
28
+ def extract_linters_from(linter_names)
29
+ linter_names.map do |linter_name|
30
+ begin
31
+ LiquidLint::Linter.const_get(linter_name)
32
+ rescue NameError
33
+ raise NoSuchLinter, "Linter #{linter_name} does not exist"
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidLint
4
+ # Chooses the appropriate linters to run given the specified configuration.
5
+ class LinterSelector
6
+ # Creates a selector using the given configuration and additional options.
7
+ #
8
+ # @param config [LiquidLint::Configuration]
9
+ # @param options [Hash]
10
+ def initialize(config, options)
11
+ @config = config
12
+ @options = options
13
+ end
14
+
15
+ # Returns the set of linters to run against the given file.
16
+ #
17
+ # @param file [String]
18
+ # @raise [LiquidLint::Exceptions::NoLintersError] when no linters are enabled
19
+ # @return [Array<LiquidLint::Linter>]
20
+ def linters_for_file(file)
21
+ @linters ||= extract_enabled_linters(@config, @options)
22
+ @linters.select { |linter| run_linter_on_file?(@config, linter, file) }
23
+ end
24
+
25
+ private
26
+
27
+ # Returns a list of linters that are enabled given the specified
28
+ # configuration and additional options.
29
+ #
30
+ # @param config [LiquidLint::Configuration]
31
+ # @param options [Hash]
32
+ # @return [Array<LiquidLint::Linter>]
33
+ def extract_enabled_linters(config, options)
34
+ included_linters =
35
+ LinterRegistry.extract_linters_from(options.fetch(:included_linters, []))
36
+
37
+ included_linters = LinterRegistry.linters if included_linters.empty?
38
+
39
+ excluded_linters =
40
+ LinterRegistry.extract_linters_from(options.fetch(:excluded_linters, []))
41
+
42
+ # After filtering out explicitly included/excluded linters, only include
43
+ # linters which are enabled in the configuration
44
+ linters = (included_linters - excluded_linters).map do |linter_class|
45
+ linter_config = config.for_linter(linter_class)
46
+ linter_class.new(linter_config) if linter_config['enabled']
47
+ end.compact
48
+
49
+ # Highlight condition where all linters were filtered out, as this was
50
+ # likely a mistake on the user's part
51
+ if linters.empty?
52
+ raise LiquidLint::Exceptions::NoLintersError, 'No linters specified'
53
+ end
54
+
55
+ linters
56
+ end
57
+
58
+ # Whether to run the given linter against the specified file.
59
+ #
60
+ # @param config [LiquidLint::Configuration]
61
+ # @param linter [LiquidLint::Linter]
62
+ # @param file [String]
63
+ # @return [Boolean]
64
+ def run_linter_on_file?(config, linter, file)
65
+ linter_config = config.for_linter(linter)
66
+
67
+ if linter_config['include'].any? &&
68
+ !LiquidLint::Utils.any_glob_matches?(linter_config['include'], file)
69
+ return false
70
+ end
71
+
72
+ if LiquidLint::Utils.any_glob_matches?(linter_config['exclude'], file)
73
+ return false
74
+ end
75
+
76
+ true
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidLint
4
+ # Encapsulates all communication to an output source.
5
+ class Logger
6
+ # Whether colored output via ANSI escape sequences is enabled.
7
+ # @return [true,false]
8
+ attr_accessor :color_enabled
9
+
10
+ # Creates a logger which outputs nothing.
11
+ # @return [LiquidLint::Logger]
12
+ def self.silent
13
+ new(File.open('/dev/null', 'w'))
14
+ end
15
+
16
+ # Creates a new {LiquidLint::Logger} instance.
17
+ #
18
+ # @param out [IO] the output destination.
19
+ def initialize(out)
20
+ @out = out
21
+ end
22
+
23
+ # Print the specified output.
24
+ #
25
+ # @param output [String] the output to send
26
+ # @param newline [true,false] whether to append a newline
27
+ def log(output, newline = true)
28
+ @out.print(output)
29
+ @out.print("\n") if newline
30
+ end
31
+
32
+ # Print the specified output in bold face.
33
+ # If output destination is not a TTY, behaves the same as {#log}.
34
+ #
35
+ # @param args [Array<String>]
36
+ def bold(*args)
37
+ color('1', *args)
38
+ end
39
+
40
+ # Print the specified output in a color indicative of error.
41
+ # If output destination is not a TTY, behaves the same as {#log}.
42
+ #
43
+ # @param args [Array<String>]
44
+ def error(*args)
45
+ color(31, *args)
46
+ end
47
+
48
+ # Print the specified output in a bold face and color indicative of error.
49
+ # If output destination is not a TTY, behaves the same as {#log}.
50
+ #
51
+ # @param args [Array<String>]
52
+ def bold_error(*args)
53
+ color('1;31', *args)
54
+ end
55
+
56
+ # Print the specified output in a color indicative of success.
57
+ # If output destination is not a TTY, behaves the same as {#log}.
58
+ #
59
+ # @param args [Array<String>]
60
+ def success(*args)
61
+ color(32, *args)
62
+ end
63
+
64
+ # Print the specified output in a color indicative of a warning.
65
+ # If output destination is not a TTY, behaves the same as {#log}.
66
+ #
67
+ # @param args [Array<String>]
68
+ def warning(*args)
69
+ color(33, *args)
70
+ end
71
+
72
+ # Print the specified output in a color indicating information.
73
+ # If output destination is not a TTY, behaves the same as {#log}.
74
+ #
75
+ # @param args [Array<String>]
76
+ def info(*args)
77
+ color(36, *args)
78
+ end
79
+
80
+ # Print a blank line.
81
+ def newline
82
+ log('')
83
+ end
84
+
85
+ # Whether this logger is outputting to a TTY.
86
+ #
87
+ # @return [true,false]
88
+ def tty?
89
+ @out.respond_to?(:tty?) && @out.tty?
90
+ end
91
+
92
+ private
93
+
94
+ # Print output in the specified color.
95
+ #
96
+ # @param code [Integer,String] ANSI color code
97
+ # @param output [String] output to print
98
+ # @param newline [Boolean] whether to append a newline
99
+ def color(code, output, newline = true)
100
+ log(color_enabled ? "\033[#{code}m#{output}\033[0m" : output, newline)
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidLint::Matcher
4
+ # Will match anything, acting as a wildcard.
5
+ class Anything < Base
6
+ # @see {LiquidLint::Matcher::Base#match?}
7
+ def match?(*)
8
+ true
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidLint::Matcher
4
+ # Represents a Sexp pattern implementing complex matching logic.
5
+ #
6
+ # Subclasses can implement custom logic to create complex matches that can be
7
+ # reused across linters, DRYing up matching code.
8
+ #
9
+ # @abstract
10
+ class Base
11
+ # Whether this matcher matches the specified object.
12
+ #
13
+ # This must be implemented by subclasses.
14
+ #
15
+ # @param other [Object]
16
+ # @return [Boolean]
17
+ def match?(*)
18
+ raise NotImplementedError, 'Matcher must implement `match?`'
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidLint::Matcher
4
+ # Wraps a matcher, taking on the behavior of the wrapped matcher but storing
5
+ # the value that matched so it can be referred to later.
6
+ class Capture < Base
7
+ # @return [LiquidLint::Matcher::Base] matcher that this capture wraps
8
+ attr_accessor :matcher
9
+
10
+ # @return [Object] value that was captured
11
+ attr_accessor :value
12
+
13
+ # Creates a capture that wraps that given matcher.
14
+ #
15
+ # @param matcher [LiquidLint::Matcher::Base]
16
+ # @return [LiquidLint::Matcher::Capture]
17
+ def self.from_matcher(matcher)
18
+ new.tap do |cap_matcher|
19
+ cap_matcher.matcher = matcher
20
+ end
21
+ end
22
+
23
+ # @see {LiquidLint::Matcher::Base#match?}
24
+ def match?(object)
25
+ if result = @matcher.match?(object)
26
+ @value = object
27
+ end
28
+
29
+ result
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidLint::Matcher
4
+ # Does not match anything.
5
+ #
6
+ # This is used in specs.
7
+ class Nothing < Base
8
+ # @see {LiquidLint::Matcher::Base#match?}
9
+ def match?(*)
10
+ false
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module LiquidLint
6
+ # Handles option parsing for the command line application.
7
+ class Options
8
+ # Parses command line options into an options hash.
9
+ #
10
+ # @param args [Array<String>] arguments passed via the command line
11
+ # @return [Hash] parsed options
12
+ def parse(args)
13
+ @options = {}
14
+
15
+ OptionParser.new do |parser|
16
+ parser.banner = "Usage: #{APP_NAME} [options] [file1, file2, ...]"
17
+
18
+ add_linter_options parser
19
+ add_file_options parser
20
+ add_info_options parser
21
+ end.parse!(args)
22
+
23
+ # Any remaining arguments are assumed to be files
24
+ @options[:files] = args
25
+
26
+ @options
27
+ rescue OptionParser::InvalidOption => e
28
+ raise Exceptions::InvalidCLIOption,
29
+ e.message,
30
+ e.backtrace
31
+ end
32
+
33
+ private
34
+
35
+ # Register linter-related flags.
36
+ def add_linter_options(parser)
37
+ parser.on('-i', '--include-linter linter,...', Array,
38
+ 'Specify which linters you want to run') do |linters|
39
+ @options[:included_linters] = linters
40
+ end
41
+
42
+ parser.on('-x', '--exclude-linter linter,...', Array,
43
+ "Specify which linters you don't want to run") do |linters|
44
+ @options[:excluded_linters] = linters
45
+ end
46
+
47
+ parser.on('-r', '--reporter reporter', String,
48
+ 'Specify which reporter you want to use to generate the output') do |reporter|
49
+ @options[:reporter] = load_reporter_class(reporter.capitalize)
50
+ end
51
+ end
52
+
53
+ # Returns the class of the specified Reporter.
54
+ #
55
+ # @param reporter_name [String]
56
+ # @raise [LiquidLint::Exceptions::InvalidCLIOption] if reporter doesn't exist
57
+ # @return [Class]
58
+ def load_reporter_class(reporter_name)
59
+ LiquidLint::Reporter.const_get("#{reporter_name}Reporter")
60
+ rescue NameError
61
+ raise LiquidLint::Exceptions::InvalidCLIOption,
62
+ "#{reporter_name}Reporter does not exist"
63
+ end
64
+
65
+ # Register file-related flags.
66
+ def add_file_options(parser)
67
+ parser.on('-c', '--config config-file', String,
68
+ 'Specify which configuration file you want to use') do |conf_file|
69
+ @options[:config_file] = conf_file
70
+ end
71
+
72
+ parser.on('-e', '--exclude file,...', Array,
73
+ 'List of file names to exclude') do |files|
74
+ @options[:excluded_files] = files
75
+ end
76
+
77
+ parser.on('--stdin-file-path file', String,
78
+ 'Pipe source from STDIN, using file in offense reports.') do |file|
79
+ @options[:stdin_file_path] = file
80
+ end
81
+ end
82
+
83
+ # Register informational flags.
84
+ def add_info_options(parser)
85
+ parser.on('--show-linters', 'Display available linters') do
86
+ @options[:show_linters] = true
87
+ end
88
+
89
+ parser.on('--show-reporters', 'Display available reporters') do
90
+ @options[:show_reporters] = true
91
+ end
92
+
93
+ parser.on('--[no-]color', 'Force output to be colorized') do |color|
94
+ @options[:color] = color
95
+ end
96
+
97
+ parser.on_tail('-h', '--help', 'Display help documentation') do
98
+ @options[:help] = parser.help
99
+ end
100
+
101
+ parser.on_tail('-v', '--version', 'Display version') do
102
+ @options[:version] = true
103
+ end
104
+
105
+ parser.on_tail('-V', '--verbose-version', 'Display verbose version information') do
106
+ @options[:verbose_version] = true
107
+ end
108
+ end
109
+ end
110
+ end