slim_lint 0.1.0

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