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,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidLint
4
+ # Temple engine used to generate a {Sexp} parse tree for use by linters.
5
+ #
6
+ # We omit a lot of the filters that are in {Liquid::Engine} because they result
7
+ # in information potentially being removed from the parse tree (since some
8
+ # Sexp abstractions are optimized/removed or otherwise transformed). In order
9
+ # for linters to be useful, they need to operate on the original parse tree.
10
+ #
11
+ # The other key task this engine accomplishes is converting the Array-based
12
+ # S-expressions into {LiquidLint::Sexp} objects, which have a number of helper
13
+ # methods that makes working with them easier. It also annotates these
14
+ # {LiquidLint::Sexp} objects with line numbers so it's easy to cross reference
15
+ # with the original source code.
16
+ class Engine < Temple::Engine
17
+ filter :Encoding
18
+ filter :RemoveBOM
19
+
20
+ # Parse into S-expression using Liquid parser
21
+ use Liquid::Parser
22
+
23
+ # Converts Array-based S-expressions into LiquidLint::Sexp objects
24
+ use LiquidLint::Filters::SexpConverter
25
+
26
+ # Annotates Sexps with line numbers
27
+ use LiquidLint::Filters::InjectLineNumbers
28
+
29
+ # Parses the given source code into a Sexp.
30
+ #
31
+ # @param source [String] source code to parse
32
+ # @return [LiquidLint::Sexp] parsed Sexp
33
+ def parse(source)
34
+ call(source)
35
+ rescue ::Liquid::Parser::SyntaxError => e
36
+ # Convert to our own exception type to isolate from upstream changes
37
+ error = LiquidLint::Exceptions::ParseError.new(e.error,
38
+ e.file,
39
+ e.line,
40
+ e.lineno,
41
+ e.column)
42
+ raise error
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Collection of exceptions that can be raised by the application.
4
+ module LiquidLint::Exceptions
5
+ # Raised when a {Configuration} could not be loaded from a file.
6
+ class ConfigurationError < StandardError; end
7
+
8
+ # Raised when invalid/incompatible command line options are provided.
9
+ class InvalidCLIOption < StandardError; end
10
+
11
+ # Raised when an invalid file path is specified
12
+ class InvalidFilePath < StandardError; end
13
+
14
+ # Raised when the Liquid parser is unable to parse a template.
15
+ class ParseError < ::Liquid::Parser::SyntaxError; end
16
+
17
+ # Raised when attempting to execute `Runner` with options that would result in
18
+ # no linters being enabled.
19
+ class NoLintersError < StandardError; end
20
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'find'
4
+
5
+ module LiquidLint
6
+ # Finds Liquid files that should be linted given a specified list of paths, glob
7
+ # patterns, and configuration.
8
+ class FileFinder
9
+ # List of extensions of files to include under a directory when a directory
10
+ # is specified instead of a file.
11
+ VALID_EXTENSIONS = %w[.liquid].freeze
12
+
13
+ # Create a file finder using the specified configuration.
14
+ #
15
+ # @param config [LiquidLint::Configuration]
16
+ def initialize(config)
17
+ @config = config
18
+ end
19
+
20
+ # Return list of files to lint given the specified set of paths and glob
21
+ # patterns.
22
+ # @param patterns [Array<String>]
23
+ # @param excluded_patterns [Array<String>]
24
+ # @raise [LiquidLint::Exceptions::InvalidFilePath]
25
+ # @return [Array<String>] list of actual files
26
+ def find(patterns, excluded_patterns)
27
+ excluded_patterns = excluded_patterns.map { |pattern| normalize_path(pattern) }
28
+
29
+ extract_files_from(patterns).reject do |file|
30
+ LiquidLint::Utils.any_glob_matches?(excluded_patterns, file)
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ # Extract the list of matching files given the list of glob patterns, file
37
+ # paths, and directories.
38
+ #
39
+ # @param patterns [Array<String>]
40
+ # @return [Array<String>]
41
+ def extract_files_from(patterns) # rubocop:disable Metrics/MethodLength
42
+ files = []
43
+
44
+ patterns.each do |pattern|
45
+ if File.file?(pattern)
46
+ files << pattern
47
+ else
48
+ begin
49
+ ::Find.find(pattern) do |file|
50
+ files << file if liquid_file?(file)
51
+ end
52
+ rescue ::Errno::ENOENT
53
+ # File didn't exist; it might be a file glob pattern
54
+ matches = ::Dir.glob(pattern)
55
+ if matches.any?
56
+ files += matches
57
+ else
58
+ # One of the paths specified does not exist; raise a more
59
+ # descriptive exception so we know which one
60
+ raise LiquidLint::Exceptions::InvalidFilePath,
61
+ "File path '#{pattern}' does not exist"
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ files.uniq.sort.map { |file| normalize_path(file) }
68
+ end
69
+
70
+ # Trim "./" from the front of relative paths.
71
+ #
72
+ # @param path [String]
73
+ # @return [String]
74
+ def normalize_path(path)
75
+ path.start_with?(".#{File::SEPARATOR}") ? path[2..-1] : path
76
+ end
77
+
78
+ # Whether the given file should be treated as a Liquid file.
79
+ #
80
+ # @param file [String]
81
+ # @return [Boolean]
82
+ def liquid_file?(file)
83
+ return false unless ::FileTest.file?(file)
84
+
85
+ VALID_EXTENSIONS.include?(::File.extname(file))
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidLint::Filters
4
+ # A dumbed-down version of {Liquid::CodeAttributes} which doesn't introduce any
5
+ # temporary variables or other cruft.
6
+ class AttributeProcessor < Liquid::Filter
7
+ define_options :merge_attrs
8
+
9
+ # Handle attributes expression `[:html, :attrs, *attrs]`
10
+ #
11
+ # @param attrs [Array]
12
+ # @return [Array]
13
+ def on_html_attrs(*attrs)
14
+ [:multi, *attrs.map { |a| compile(a) }]
15
+ end
16
+
17
+ # Handle attribute expression `[:html, :attr, name, value]`
18
+ #
19
+ # @param name [String] name of the attribute
20
+ # @param value [Array] Sexp representing the value
21
+ def on_html_attr(name, value)
22
+ if value[0] == :liquid && value[1] == :attrvalue
23
+ code = value[3]
24
+ [:code, code]
25
+ else
26
+ @attr = name
27
+ super
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidLint::Filters
4
+ # A dumbed-down version of {Liquid::Controls} which doesn't introduce temporary
5
+ # variables and other cruft (which in the context of extracting Ruby code,
6
+ # results in a lot of weird cops reported by RuboCop).
7
+ class ControlProcessor < Liquid::Filter
8
+ BLOCK_RE = /\A(if|unless)\b|\bdo\s*(\|[^|]*\|)?\s*$/
9
+
10
+ # Handle control expression `[:liquid, :control, code, content]`
11
+ #
12
+ # @param code [String]
13
+ # @param content [Array]
14
+ def on_liquid_control(code, content)
15
+ [:multi,
16
+ [:code, code],
17
+ compile(content)]
18
+ end
19
+
20
+ # Handle output expression `[:liquid, :output, escape, code, content]`
21
+ #
22
+ # @param _escape [Boolean]
23
+ # @param code [String]
24
+ # @param content [Array]
25
+ # @return [Array
26
+ def on_liquid_output(_escape, code, content)
27
+ if code[BLOCK_RE]
28
+ [:multi,
29
+ [:code, code, compile(content)],
30
+ [:code, 'end']]
31
+ else
32
+ [:multi, [:dynamic, code], compile(content)]
33
+ end
34
+ end
35
+
36
+ # Handle text expression `[:liquid, :text, type, content]`
37
+ #
38
+ # @param _type [Symbol]
39
+ # @param content [Array]
40
+ # @return [Array]
41
+ def on_liquid_text(_type, content)
42
+ # Ensures :newline expressions from static output are still represented in
43
+ # the final expression
44
+ compile(content)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidLint::Filters
4
+ # Traverses a Temple S-expression (that has already been converted to
5
+ # {LiquidLint::Sexp} instances) and annotates them with line numbers.
6
+ #
7
+ # This is a hack that allows us to access line information directly from the
8
+ # S-expressions, which makes a lot of other tasks easier.
9
+ class InjectLineNumbers < Temple::Filter
10
+ # {Sexp} representing a newline.
11
+ NEWLINE_SEXP = LiquidLint::Sexp.new([:newline])
12
+
13
+ # Annotates the given {LiquidLint::Sexp} with line number information.
14
+ #
15
+ # @param sexp [LiquidLint::Sexp]
16
+ # @return [LiquidLint::Sexp]
17
+ def call(sexp)
18
+ @line_number = 1
19
+ traverse(sexp)
20
+ sexp
21
+ end
22
+
23
+ private
24
+
25
+ # Traverses an {Sexp}, annotating it with line numbers.
26
+ #
27
+ # @param sexp [LiquidLint::Sexp]
28
+ def traverse(sexp)
29
+ sexp.line = @line_number
30
+
31
+ case sexp
32
+ when LiquidLint::Atom
33
+ @line_number += sexp.strip.count("\n") if sexp.respond_to?(:count)
34
+ when NEWLINE_SEXP
35
+ @line_number += 1
36
+ else
37
+ sexp.each do |nested_sexp|
38
+ traverse(nested_sexp)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidLint::Filters
4
+ # Converts a Temple S-expression comprised of {Array}s into {LiquidLint::Sexp}s.
5
+ #
6
+ # These {LiquidLint::Sexp}s include additional helpers that makes working with
7
+ # them more pleasant.
8
+ class SexpConverter < Temple::Filter
9
+ # Converts the given {Array} to a {LiquidLint::Sexp}.
10
+ #
11
+ # @param array_sexp [Array]
12
+ # @return [LiquidLint::Sexp]
13
+ def call(array_sexp)
14
+ LiquidLint::Sexp.new(array_sexp)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidLint::Filters
4
+ # A dumbed-down version of {Liquid::Splat::Filter} which doesn't introduced
5
+ # temporary variables or other cruft.
6
+ class SplatProcessor < Liquid::Filter
7
+ # Handle liquid splat expressions `[:liquid, :splat, code]`
8
+ #
9
+ # @param code [String]
10
+ # @return [Array]
11
+ def on_liquid_splat(code)
12
+ [:code, code]
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidLint
4
+ # Contains information about a problem or issue with a Liquid document.
5
+ class Lint
6
+ # @return [String] file path to which the lint applies
7
+ attr_reader :filename
8
+
9
+ # @return [String] line number of the file the lint corresponds to
10
+ attr_reader :line
11
+
12
+ # @return [LiquidLint::Linter] linter that reported the lint
13
+ attr_reader :linter
14
+
15
+ # @return [String] error/warning message to display to user
16
+ attr_reader :message
17
+
18
+ # @return [Symbol] whether this lint is a warning or an error
19
+ attr_reader :severity
20
+
21
+ # Creates a new lint.
22
+ #
23
+ # @param linter [LiquidLint::Linter]
24
+ # @param filename [String]
25
+ # @param line [Fixnum]
26
+ # @param message [String]
27
+ # @param severity [Symbol]
28
+ def initialize(linter, filename, line, message, severity = :warning)
29
+ @linter = linter
30
+ @filename = filename
31
+ @line = line || 0
32
+ @message = message
33
+ @severity = severity
34
+ end
35
+
36
+ # Return whether this lint has a severity of error.
37
+ #
38
+ # @return [Boolean]
39
+ def error?
40
+ @severity == :error
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidLint
4
+ # Searches for control statements with only comments.
5
+ class Linter::CommentControlStatement < Linter
6
+ include LinterRegistry
7
+
8
+ on [:liquid, :control] do |sexp|
9
+ _, _, code = sexp
10
+ next unless code[/\A\s*#/]
11
+
12
+ comment = code[/\A\s*#(.*\z)/, 1]
13
+
14
+ next if comment =~ /^\s*rubocop:\w+/
15
+ next if comment =~ /^\s*Template Dependency:/
16
+
17
+ report_lint(sexp,
18
+ "Liquid code comments (`/#{comment}`) are preferred over " \
19
+ "control statement comments (`-##{comment}`)")
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidLint
4
+ # Searches for more than an allowed number of consecutive control code
5
+ # statements that could be condensed into a :ruby filter.
6
+ class Linter::ConsecutiveControlStatements < Linter
7
+ include LinterRegistry
8
+
9
+ on [:multi] do |sexp|
10
+ Utils.for_consecutive_items(sexp,
11
+ method(:flat_control_statement?),
12
+ config['max_consecutive'] + 1) do |group|
13
+ report_lint(group.first,
14
+ "#{group.count} consecutive control statements can be " \
15
+ 'merged into a single `ruby:` filter')
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def flat_control_statement?(sexp)
22
+ sexp.match?([:liquid, :control]) &&
23
+ sexp[3] == [:multi, [:newline]]
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidLint
4
+ # Checks for missing or superfluous spacing before and after control statements.
5
+ class Linter::ControlStatementSpacing < Linter
6
+ include LinterRegistry
7
+
8
+ MESSAGE = 'Please add a space before and after the `=`'
9
+
10
+ on [:html, :tag, anything, [],
11
+ [:liquid, :output, anything, capture(:ruby, anything)]] do |sexp|
12
+ # Fetch original Liquid code that contains an element with a control statement.
13
+ line = document.source_lines[sexp.line - 1]
14
+
15
+ # Remove any Ruby code, because our regexp below must not match inside Ruby.
16
+ ruby = captures[:ruby]
17
+ line = line.sub(ruby, 'x')
18
+
19
+ next if line =~ /[^ ] ==?<?>? [^ ]/
20
+
21
+ report_lint(sexp, MESSAGE)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidLint
4
+ # Checks for forbidden embedded engines.
5
+ class Linter::EmbeddedEngines < Linter
6
+ include LinterRegistry
7
+
8
+ MESSAGE = 'Forbidden embedded engine `%s` found'
9
+
10
+ on_start do |_sexp|
11
+ forbidden_engines = config['forbidden_engines']
12
+ dummy_node = Struct.new(:line)
13
+ document.source_lines.each_with_index do |line, index|
14
+ forbidden_engines.each do |forbidden_engine|
15
+ next unless line =~ /^#{forbidden_engine}.*:\s*$/
16
+
17
+ report_lint(dummy_node.new(index + 1), MESSAGE % forbidden_engine)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidLint
4
+ # Searches for control statements with no code.
5
+ class Linter::EmptyControlStatement < Linter
6
+ include LinterRegistry
7
+
8
+ on [:liquid, :control] do |sexp|
9
+ _, _, code = sexp
10
+ next unless code[/\A\s*\Z/]
11
+
12
+ report_lint(sexp, 'Empty control statement can be removed')
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidLint
4
+ # This linter checks for two or more consecutive blank lines
5
+ # and for the first blank line in file.
6
+ class Linter::EmptyLines < Linter
7
+ include LinterRegistry
8
+
9
+ on_start do |_sexp|
10
+ dummy_node = Struct.new(:line)
11
+
12
+ was_empty = true
13
+ document.source.lines.each_with_index do |line, i|
14
+ if line.blank?
15
+ if was_empty
16
+ report_lint(dummy_node.new(i + 1),
17
+ 'Extra empty line detected')
18
+ end
19
+ was_empty = true
20
+ else
21
+ was_empty = false
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidLint
4
+ # Checks for file longer than a maximum number of lines.
5
+ class Linter::FileLength < Linter
6
+ include LinterRegistry
7
+
8
+ MSG = 'File is too long. [%d/%d]'
9
+
10
+ on_start do |_sexp|
11
+ max_length = config['max']
12
+ dummy_node = Struct.new(:line)
13
+
14
+ count = document.source_lines.size
15
+ if count > max_length
16
+ report_lint(dummy_node.new(1), format(MSG, count, max_length))
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidLint
4
+ # Checks for lines longer than a maximum number of columns.
5
+ class Linter::LineLength < Linter
6
+ include LinterRegistry
7
+
8
+ MSG = 'Line is too long. [%d/%d]'
9
+
10
+ on_start do |_sexp|
11
+ max_length = config['max']
12
+ dummy_node = Struct.new(:line)
13
+
14
+ document.source_lines.each_with_index do |line, index|
15
+ next if line.length <= max_length
16
+
17
+ report_lint(dummy_node.new(index + 1), format(MSG, line.length, max_length))
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidLint
4
+ # Checks for unnecessary uses of the `div` tag where a class name or ID
5
+ # already implies a div.
6
+ class Linter::RedundantDiv < Linter
7
+ include LinterRegistry
8
+
9
+ MESSAGE = '`div` is redundant when %s attribute shortcut is present'
10
+
11
+ on [:html, :tag, 'div',
12
+ [:html, :attrs,
13
+ [:html, :attr,
14
+ capture(:attr_name, anything),
15
+ [:static]]]] do |sexp|
16
+ attr = captures[:attr_name]
17
+ next unless %w[class id].include?(attr)
18
+
19
+ report_lint(sexp, MESSAGE % attr)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'liquid_lint/ruby_extractor'
4
+ require 'liquid_lint/ruby_extract_engine'
5
+ require 'rubocop'
6
+
7
+ module LiquidLint
8
+ # Runs RuboCop on Ruby code extracted from Liquid templates.
9
+ class Linter::RuboCop < Linter
10
+ include LinterRegistry
11
+
12
+ on_start do |_sexp|
13
+ processed_sexp = LiquidLint::RubyExtractEngine.new.call(document.source)
14
+
15
+ extractor = LiquidLint::RubyExtractor.new
16
+ extracted_source = extractor.extract(processed_sexp)
17
+
18
+ next if extracted_source.source.empty?
19
+
20
+ find_lints(extracted_source.source, extracted_source.source_map)
21
+ end
22
+
23
+ private
24
+
25
+ # Executes RuboCop against the given Ruby code and records the offenses as
26
+ # lints.
27
+ #
28
+ # @param ruby [String] Ruby code
29
+ # @param source_map [Hash] map of Ruby code line numbers to original line
30
+ # numbers in the template
31
+ def find_lints(ruby, source_map)
32
+ rubocop = ::RuboCop::CLI.new
33
+
34
+ filename = document.file ? "#{document.file}.rb" : 'ruby_script.rb'
35
+
36
+ with_ruby_from_stdin(ruby) do
37
+ extract_lints_from_offenses(lint_file(rubocop, filename), source_map)
38
+ end
39
+ end
40
+
41
+ # Defined so we can stub the results in tests
42
+ #
43
+ # @param rubocop [RuboCop::CLI]
44
+ # @param file [String]
45
+ # @return [Array<RuboCop::Cop::Offense>]
46
+ def lint_file(rubocop, file)
47
+ rubocop.run(rubocop_flags << file)
48
+ OffenseCollector.offenses
49
+ end
50
+
51
+ # Aggregates RuboCop offenses and converts them to {LiquidLint::Lint}s
52
+ # suitable for reporting.
53
+ #
54
+ # @param offenses [Array<RuboCop::Cop::Offense>]
55
+ # @param source_map [Hash]
56
+ def extract_lints_from_offenses(offenses, source_map)
57
+ offenses.select { |offense| !config['ignored_cops'].include?(offense.cop_name) }
58
+ .each do |offense|
59
+ @lints << Lint.new(self,
60
+ document.file,
61
+ source_map[offense.line],
62
+ offense.message)
63
+ end
64
+ end
65
+
66
+ # Returns flags that will be passed to RuboCop CLI.
67
+ #
68
+ # @return [Array<String>]
69
+ def rubocop_flags
70
+ flags = %w[--format LiquidLint::OffenseCollector]
71
+ flags += ['--config', ENV['LIQUID_LINT_RUBOCOP_CONF']] if ENV['LIQUID_LINT_RUBOCOP_CONF']
72
+ flags += ['--stdin']
73
+ flags
74
+ end
75
+
76
+ # Overrides the global stdin to allow RuboCop to read Ruby code from it.
77
+ #
78
+ # @param ruby [String] the Ruby code to write to the overridden stdin
79
+ # @param _block [Block] the block to perform with the overridden stdin
80
+ # @return [void]
81
+ def with_ruby_from_stdin(ruby, &_block)
82
+ original_stdin = $stdin
83
+ stdin = StringIO.new
84
+ stdin.write(ruby)
85
+ stdin.rewind
86
+ $stdin = stdin
87
+ yield
88
+ ensure
89
+ $stdin = original_stdin
90
+ end
91
+ end
92
+
93
+ # Collects offenses detected by RuboCop.
94
+ class OffenseCollector < ::RuboCop::Formatter::BaseFormatter
95
+ class << self
96
+ # List of offenses reported by RuboCop.
97
+ attr_accessor :offenses
98
+ end
99
+
100
+ # Executed when RuboCop begins linting.
101
+ #
102
+ # @param _target_files [Array<String>]
103
+ def started(_target_files)
104
+ self.class.offenses = []
105
+ end
106
+
107
+ # Executed when a file has been scanned by RuboCop, adding the reported
108
+ # offenses to our collection.
109
+ #
110
+ # @param _file [String]
111
+ # @param offenses [Array<RuboCop::Cop::Offense>]
112
+ def file_finished(_file, offenses)
113
+ self.class.offenses += offenses
114
+ end
115
+ end
116
+ end