liquid_lint 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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