slim_lint 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.
@@ -0,0 +1,73 @@
1
+ require 'slim_lint/ruby_extractor'
2
+ require 'slim_lint/ruby_extract_engine'
3
+ require 'rubocop'
4
+ require 'tempfile'
5
+
6
+ module SlimLint
7
+ # Runs RuboCop on Ruby code extracted from Slim templates.
8
+ class Linter::RuboCop < Linter
9
+ include LinterRegistry
10
+
11
+ on_start do |_sexp|
12
+ processed_sexp = SlimLint::RubyExtractEngine.new.call(document.source)
13
+
14
+ extractor = SlimLint::RubyExtractor.new
15
+ extracted_ruby = extractor.extract(processed_sexp)
16
+
17
+ find_lints(extractor, extracted_ruby) unless extracted_ruby.empty?
18
+ end
19
+
20
+ private
21
+
22
+ def find_lints(extractor, ruby)
23
+ rubocop = ::RuboCop::CLI.new
24
+
25
+ original_filename = document.file || 'ruby_script'
26
+ filename = "#{File.basename(original_filename)}.slim_lint.tmp"
27
+ directory = File.dirname(original_filename)
28
+
29
+ Tempfile.open(filename, directory) do |f|
30
+ begin
31
+ f.write(ruby)
32
+ f.close
33
+ extract_lints_from_offences(lint_file(rubocop, f.path), extractor)
34
+ ensure
35
+ f.unlink
36
+ end
37
+ end
38
+ end
39
+
40
+ # Defined so we can stub the results in tests
41
+ def lint_file(rubocop, file)
42
+ rubocop.run(%w[--format SlimLint::OffenceCollector] << file)
43
+ OffenceCollector.offences
44
+ end
45
+
46
+ def extract_lints_from_offences(offences, extractor)
47
+ offences.select { |offence| !config['ignored_cops'].include?(offence.cop_name) }
48
+ .each do |offence|
49
+ @lints << Lint.new(self,
50
+ document.file,
51
+ extractor.source_map[offence.line],
52
+ "#{offence.cop_name}: #{offence.message}")
53
+ end
54
+ end
55
+ end
56
+
57
+ # Collects offences detected by RuboCop.
58
+ class OffenceCollector < ::RuboCop::Formatter::BaseFormatter
59
+ attr_accessor :offences
60
+
61
+ class << self
62
+ attr_accessor :offences
63
+ end
64
+
65
+ def started(_target_files)
66
+ self.class.offences = []
67
+ end
68
+
69
+ def file_finished(_file, offences)
70
+ self.class.offences += offences
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,17 @@
1
+ module SlimLint
2
+ # Checks for trailing whitespace.
3
+ class Linter::TrailingWhitespace < Linter
4
+ include LinterRegistry
5
+
6
+ on_start do |_sexp|
7
+ dummy_node = Struct.new(:line)
8
+
9
+ document.source_lines.each_with_index do |line, index|
10
+ next unless line =~ /\s+$/
11
+
12
+ report_lint(dummy_node.new(index + 1),
13
+ 'Line contains trailing whitespace')
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,49 @@
1
+ module SlimLint
2
+ # Base implementation for all lint checks.
3
+ class Linter
4
+ # Include definitions for Sexp pattern-matching helpers.
5
+ include SexpVisitor
6
+ extend SexpVisitor::DSL
7
+
8
+ # TODO: Remove once spec support returns an array of lints instead of a
9
+ # linter
10
+ attr_reader :lints
11
+
12
+ # @param config [Hash] configuration for this linter
13
+ def initialize(config)
14
+ @config = config
15
+ @lints = []
16
+ @ruby_parser = nil
17
+ end
18
+
19
+ # Runs the linter against the specified Sexp
20
+ def run(document)
21
+ @document = document
22
+ @lints = []
23
+ trigger_pattern_callbacks(document.sexp)
24
+ @lints
25
+ end
26
+
27
+ # Returns the simple name for this linter.
28
+ def name
29
+ self.class.name.split('::').last
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :config, :document
35
+
36
+ # Record a lint for reporting back to the user.
37
+ def report_lint(sexp, message)
38
+ @lints << SlimLint::Lint.new(self, @document.file, sexp.line, message)
39
+ end
40
+
41
+ # Parse Ruby code into an abstract syntax tree.
42
+ #
43
+ # @return [AST::Node]
44
+ def parse_ruby(source)
45
+ @ruby_parser ||= SlimLint::RubyParser.new
46
+ @ruby_parser.parse(source)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,26 @@
1
+ module SlimLint
2
+ class NoSuchLinter < StandardError; end
3
+
4
+ # Stores all defined linters.
5
+ module LinterRegistry
6
+ @linters = []
7
+
8
+ class << self
9
+ attr_reader :linters
10
+
11
+ def included(base)
12
+ @linters << base
13
+ end
14
+
15
+ def extract_linters_from(linter_names)
16
+ linter_names.map do |linter_name|
17
+ begin
18
+ SlimLint::Linter.const_get(linter_name)
19
+ rescue NameError
20
+ raise NoSuchLinter, "Linter #{linter_name} does not exist"
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,107 @@
1
+ module SlimLint
2
+ # Encapsulates all communication to an output source.
3
+ class Logger
4
+ # Whether colored output via ANSI escape sequences is enabled.
5
+ # @return [true,false]
6
+ attr_accessor :color_enabled
7
+
8
+ # Creates a logger which outputs nothing.
9
+ # @return [SlimLint::Logger]
10
+ def self.silent
11
+ new(File.open('/dev/null', 'w'))
12
+ end
13
+
14
+ # Creates a new {SlimLint::Logger} instance.
15
+ #
16
+ # @param out [IO] the output destination.
17
+ def initialize(out)
18
+ @out = out
19
+ end
20
+
21
+ # Print the specified output.
22
+ #
23
+ # @param output [String] the output to send
24
+ # @param newline [true,false] whether to append a newline
25
+ # @return [nil]
26
+ def log(output, newline = true)
27
+ @out.print(output)
28
+ @out.print("\n") if newline
29
+ end
30
+
31
+ # Print the specified output in bold face.
32
+ # If output destination is not a TTY, behaves the same as {#log}.
33
+ #
34
+ # @param args [Array<String>]
35
+ # @return [nil]
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
+ # @return [nil]
45
+ def error(*args)
46
+ color(31, *args)
47
+ end
48
+
49
+ # Print the specified output in a bold face and color indicative of error.
50
+ # If output destination is not a TTY, behaves the same as {#log}.
51
+ #
52
+ # @param args [Array<String>]
53
+ # @return [nil]
54
+ def bold_error(*args)
55
+ color('1;31', *args)
56
+ end
57
+
58
+ # Print the specified output in a color indicative of success.
59
+ # If output destination is not a TTY, behaves the same as {#log}.
60
+ #
61
+ # @param args [Array<String>]
62
+ # @return [nil]
63
+ def success(*args)
64
+ color(32, *args)
65
+ end
66
+
67
+ # Print the specified output in a color indicative of a warning.
68
+ # If output destination is not a TTY, behaves the same as {#log}.
69
+ #
70
+ # @param args [Array<String>]
71
+ # @return [nil]
72
+ def warning(*args)
73
+ color(33, *args)
74
+ end
75
+
76
+ # Print specified output in bold face in a color indicative of a warning.
77
+ # If output destination is not a TTY, behaves the same as {#log}.
78
+ #
79
+ # @param args [Array<String>]
80
+ # @return [nil]
81
+ def bold_warning(*args)
82
+ color('1;33', *args)
83
+ end
84
+
85
+ # Print the specified output in a color indicating information.
86
+ # If output destination is not a TTY, behaves the same as {#log}.
87
+ #
88
+ # @param args [Array<String>]
89
+ # @return [nil]
90
+ def info(*args)
91
+ color(36, *args)
92
+ end
93
+
94
+ # Whether this logger is outputting to a TTY.
95
+ #
96
+ # @return [true,false]
97
+ def tty?
98
+ @out.respond_to?(:tty?) && @out.tty?
99
+ end
100
+
101
+ private
102
+
103
+ def color(code, output, newline = true)
104
+ log(color_enabled ? "\033[#{code}m#{output}\033[0m" : output, newline)
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,89 @@
1
+ require 'optparse'
2
+
3
+ module SlimLint
4
+ # Handles option parsing for the command line application.
5
+ class Options
6
+ # Parses command line options into an options hash.
7
+ #
8
+ # @param args [Array<String>] arguments passed via the command line
9
+ # @return [Hash] parsed options
10
+ def parse(args)
11
+ @options = {}
12
+
13
+ OptionParser.new do |parser|
14
+ parser.banner = "Usage: #{APP_NAME} [options] [file1, file2, ...]"
15
+
16
+ add_linter_options parser
17
+ add_file_options parser
18
+ add_info_options parser
19
+ end.parse!(args)
20
+
21
+ # Any remaining arguments are assumed to be files
22
+ @options[:files] = args
23
+
24
+ @options
25
+ rescue OptionParser::InvalidOption => ex
26
+ raise Exceptions::InvalidCLIOption,
27
+ ex.message,
28
+ ex.backtrace
29
+ end
30
+
31
+ private
32
+
33
+ def add_linter_options(parser)
34
+ parser.on('-e', '--exclude file,...', Array,
35
+ 'List of file names to exclude') do |files|
36
+ @options[:excluded_files] = files
37
+ end
38
+
39
+ parser.on('-i', '--include-linter linter,...', Array,
40
+ 'Specify which linters you want to run') do |linters|
41
+ @options[:included_linters] = linters
42
+ end
43
+
44
+ parser.on('-x', '--exclude-linter linter,...', Array,
45
+ "Specify which linters you don't want to run") do |linters|
46
+ @options[:excluded_linters] = linters
47
+ end
48
+
49
+ parser.on('-r', '--reporter reporter', String,
50
+ 'Specify which reporter you want to use to generate the output') do |reporter|
51
+ @options[:reporter] = SlimLint::Reporter.const_get("#{reporter.capitalize}Reporter")
52
+ end
53
+ end
54
+
55
+ def add_file_options(parser)
56
+ parser.on('-c', '--config config-file', String,
57
+ 'Specify which configuration file you want to use') do |conf_file|
58
+ @options[:config_file] = conf_file
59
+ end
60
+
61
+ parser.on('-e', '--exclude file,...', Array,
62
+ 'List of file names to exclude') do |files|
63
+ @options[:excluded_files] = files
64
+ end
65
+ end
66
+
67
+ def add_info_options(parser)
68
+ parser.on('--show-linters', 'Display available linters') do
69
+ @options[:show_linters] = true
70
+ end
71
+
72
+ parser.on('--show-reporters', 'Display available reporters') do
73
+ @options[:show_reporters] = true
74
+ end
75
+
76
+ parser.on('--[no-]color', 'Force output to be colorized') do |color|
77
+ @options[:color] = color
78
+ end
79
+
80
+ parser.on_tail('-h', '--help', 'Display help documentation') do
81
+ @options[:help] = parser.help
82
+ end
83
+
84
+ parser.on_tail('-v', '--version', 'Display version') do
85
+ @options[:version] = true
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,107 @@
1
+ require 'rake'
2
+ require 'rake/tasklib'
3
+
4
+ module SlimLint
5
+ # Rake task interface for slim-lint command line interface.
6
+ #
7
+ # @example
8
+ # # Add the following to your Rakefile...
9
+ # require 'slim_lint/rake_task'
10
+ #
11
+ # SlimLint::RakeTask.new do |t|
12
+ # t.config = 'path/to/custom/slim-lint.yml'
13
+ # t.files = %w[app/views/**/*.slim custom/*.slim]
14
+ # t.quiet = true # Don't display output from slim-lint
15
+ # end
16
+ #
17
+ # # ...and then execute from the command line:
18
+ # rake slim_lint
19
+ #
20
+ # You can also specify the list of files as explicit task arguments:
21
+ #
22
+ # @example
23
+ # # Add the following to your Rakefile...
24
+ # require 'slim_lint/rake_task'
25
+ #
26
+ # SlimLint::RakeTask.new
27
+ #
28
+ # # ...and then execute from the command line (single quotes prevent shell
29
+ # # glob expansion and allow us to have a space after commas):
30
+ # rake 'slim_lint[app/views/**/*.slim, other_files/**/*.slim]'
31
+ #
32
+ class RakeTask < Rake::TaskLib
33
+ # Name of the task.
34
+ # @return [String]
35
+ attr_accessor :name
36
+
37
+ # Configuration file to use.
38
+ # @return [String]
39
+ attr_accessor :config
40
+
41
+ # List of files to lint (can contain shell globs).
42
+ #
43
+ # Note that this will be ignored if you explicitly pass a list of files as
44
+ # task arguments via the command line or a task definition.
45
+ # @return [Array<String>]
46
+ attr_accessor :files
47
+
48
+ # Whether output from slim-lint should not be displayed to the standard out
49
+ # stream.
50
+ # @return [true,false]
51
+ attr_accessor :quiet
52
+
53
+ # Create the task so it exists in the current namespace.
54
+ def initialize(name = :slim_lint)
55
+ @name = name
56
+ @files = ['.'] # Search for everything under current directory by default
57
+ @quiet = false
58
+
59
+ yield self if block_given?
60
+
61
+ define
62
+ end
63
+
64
+ private
65
+
66
+ def define
67
+ desc default_description unless ::Rake.application.last_description
68
+
69
+ task(name, [:files]) do |_task, task_args|
70
+ # Lazy-load so task doesn't affect Rakefile load time
71
+ require 'slim_lint'
72
+ require 'slim_lint/cli'
73
+
74
+ run_cli(task_args)
75
+ end
76
+ end
77
+
78
+ def run_cli(task_args)
79
+ cli_args = ['--config', config] if config
80
+
81
+ logger = quiet ? SlimLint::Logger.silent : SlimLint::Logger.new(STDOUT)
82
+ result = SlimLint::CLI.new(logger).run(Array(cli_args) + files_to_lint(task_args))
83
+
84
+ fail "#{SlimLint::APP_NAME} failed with exit code #{result}" unless result == 0
85
+ end
86
+
87
+ def files_to_lint(task_args)
88
+ # Note: we're abusing Rake's argument handling a bit here. We call the
89
+ # first argument `files` but it's actually only the first file--we pull
90
+ # the rest out of the `extras` from the task arguments. This is so we
91
+ # can specify an arbitrary list of files separated by commas on the
92
+ # command line or in a custom task definition.
93
+ explicit_files = Array(task_args[:files]) + Array(task_args.extras)
94
+
95
+ explicit_files.any? ? explicit_files : files
96
+ end
97
+
98
+ # Friendly description that shows the full command that will be executed.
99
+ def default_description
100
+ description = "Run `#{SlimLint::APP_NAME}"
101
+ description += " --config #{config}" if config
102
+ description += " #{files.join(' ')}" if files.any?
103
+ description += ' [files...]`'
104
+ description
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,16 @@
1
+ module SlimLint
2
+ # Contains information about all lints detected during a scan.
3
+ class Report
4
+ attr_accessor :lints
5
+ attr_reader :files
6
+
7
+ def initialize(lints, files)
8
+ @lints = lints.sort_by { |l| [l.filename, l.line] }
9
+ @files = files
10
+ end
11
+
12
+ def failed?
13
+ @lints.any?
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,39 @@
1
+ module SlimLint
2
+ # Outputs lints in a simple format with the filename, line number, and lint
3
+ # message.
4
+ class Reporter::DefaultReporter < Reporter
5
+ def report_lints
6
+ sorted_lints = lints.sort_by { |l| [l.filename, l.line] }
7
+
8
+ sorted_lints.each do |lint|
9
+ print_location(lint)
10
+ print_type(lint)
11
+ print_message(lint)
12
+ end
13
+ end
14
+
15
+ private
16
+
17
+ def print_location(lint)
18
+ log.info lint.filename, false
19
+ log.log ':', false
20
+ log.bold lint.line, false
21
+ end
22
+
23
+ def print_type(lint)
24
+ if lint.error?
25
+ log.error ' [E] ', false
26
+ else
27
+ log.warning ' [W] ', false
28
+ end
29
+ end
30
+
31
+ def print_message(lint)
32
+ if lint.linter
33
+ log.success("#{lint.linter.name}: ", false)
34
+ end
35
+
36
+ log.log lint.message
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,44 @@
1
+ module SlimLint
2
+ # Outputs report as a JSON document.
3
+ class Reporter::JsonReporter < Reporter
4
+ def report_lints
5
+ grouped = lints.group_by(&:filename)
6
+
7
+ report = {
8
+ metadata: {
9
+ slim_lint_version: SlimLint::VERSION,
10
+ ruby_engine: RUBY_ENGINE,
11
+ ruby_patchlevel: RUBY_PATCHLEVEL.to_s,
12
+ ruby_platform: RUBY_PLATFORM,
13
+ },
14
+ files: grouped.map { |l| map_file(l) },
15
+ summary: {
16
+ offense_count: lints.length,
17
+ target_file_count: grouped.length,
18
+ inspected_file_count: files.length,
19
+ },
20
+ }
21
+
22
+ log.log report.to_json
23
+ end
24
+
25
+ private
26
+
27
+ def map_file(file)
28
+ {
29
+ path: file.first,
30
+ offenses: file.last.map { |o| map_offense(o) },
31
+ }
32
+ end
33
+
34
+ def map_offense(offense)
35
+ {
36
+ severity: offense.severity,
37
+ message: offense.message,
38
+ location: {
39
+ line: offense.line,
40
+ },
41
+ }
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,36 @@
1
+ module SlimLint
2
+ # Abstract lint reporter. Subclass and override {#report_lints} to
3
+ # implement a custom lint reporter.
4
+ #
5
+ # @abstract
6
+ class Reporter
7
+ attr_reader :lints
8
+ attr_reader :files
9
+
10
+ # @param logger [SlimLint::Logger]
11
+ # @param report [SlimLint::Report]
12
+ def initialize(logger, report)
13
+ @log = logger
14
+ @lints = report.lints
15
+ @files = report.files
16
+ end
17
+
18
+ # Implemented by subclasses to display lints from a {SlimLint::Report}.
19
+ def report_lints
20
+ raise NotImplementedError
21
+ end
22
+
23
+ # Keep tracking all the descendants of this class for the list of available reporters
24
+ def self.descendants
25
+ @descendants ||= []
26
+ end
27
+
28
+ def self.inherited(descendant)
29
+ descendants << descendant
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :log
35
+ end
36
+ end
@@ -0,0 +1,43 @@
1
+ module SlimLint
2
+ # Generates a {SlimLint::Sexp} suitable for consumption by the
3
+ # {RubyExtractor}.
4
+ #
5
+ # This is mostly copied from Slim::Engine, with some filters and generators
6
+ # omitted.
7
+ class RubyExtractEngine < Temple::Engine
8
+ define_options sort_attrs: true,
9
+ format: :xhtml,
10
+ attr_quote: '"',
11
+ merge_attrs: { 'class' => ' ' },
12
+ default_tag: 'div'
13
+
14
+ filter :Encoding
15
+ filter :RemoveBOM
16
+
17
+ # Parse into S-expression using Slim parser
18
+ use Slim::Parser
19
+
20
+ # Perform additional processing so extracting Ruby code in {RubyExtractor}
21
+ # is easier. We don't do this for regular linters because some information
22
+ # about the original syntax tree is lost in the process, but that doesn't
23
+ # matter in this case.
24
+ use Slim::Embedded
25
+ use Slim::Interpolation
26
+ use Slim::Splat::Filter
27
+ use Slim::DoInserter
28
+ use Slim::EndInserter
29
+ use Slim::Controls
30
+ html :AttributeSorter
31
+ html :AttributeMerger
32
+ use Slim::CodeAttributes
33
+ filter :ControlFlow
34
+ filter :MultiFlattener
35
+ filter :StaticMerger
36
+
37
+ # Converts Array-based S-expressions into SlimLint::Sexp objects, and gives
38
+ # them line numbers so we can easily map from the Ruby source to the
39
+ # original source
40
+ use SlimLint::Filters::SexpConverter
41
+ use SlimLint::Filters::InjectLineNumbers
42
+ end
43
+ end