slim_lint 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: ea825ed5f8f37b9f5c0f65af4cce47ee9289227c
4
+ data.tar.gz: 31f794679bd7928f0e7990e3802319e2aeaa1764
5
+ SHA512:
6
+ metadata.gz: 60c07b39dab5f98178fa7a01318e42c97e64676810309a03932b836fe1800bb9265203da373a601aa9882d53afc5b23d15c487efcab2d0a74871d7fcbe6f15b3
7
+ data.tar.gz: 8a65468ddb8de4b2834178f5e89ce90203281678d3d1cb2c0eeb42a22ffc6d6f10ef115ad65e7ab3d237043d2730f9ed4d5969add7f465becbd0ae99b485606a
data/bin/slim-lint ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'slim_lint'
4
+ require 'slim_lint/cli'
5
+
6
+ logger = SlimLint::Logger.new(STDOUT)
7
+ exit SlimLint::CLI.new(logger).run(ARGV)
@@ -0,0 +1,38 @@
1
+ # Default application configuration that all configurations inherit from.
2
+ #
3
+ # This is an opinionated list of which hooks are valuable to run and what their
4
+ # out of the box settings should be.
5
+
6
+ # Whether to ignore frontmatter at the beginning of Slim documents for
7
+ # frameworks such as Jekyll/Middleman
8
+ skip_frontmatter: false
9
+
10
+ linters:
11
+ ExplicitDiv:
12
+ enabled: true
13
+
14
+ LineLength:
15
+ enabled: true
16
+ max: 80
17
+
18
+ RuboCop:
19
+ enabled: true
20
+ # These cops are incredibly noisy since the Ruby we extract from Slim
21
+ # templates isn't well-formatted, so we ignore them.
22
+ ignored_cops:
23
+ - Lint/BlockAlignment
24
+ - Lint/EndAlignment
25
+ - Lint/Void
26
+ - Metrics/LineLength
27
+ - Style/AlignParameters
28
+ - Style/BlockNesting
29
+ - Style/FileName
30
+ - Style/IfUnlessModifier
31
+ - Style/IndentationWidth
32
+ - Style/Next
33
+ - Style/TrailingBlankLines
34
+ - Style/TrailingWhitespace
35
+ - Style/WhileUntilModifier
36
+
37
+ TrailingWhitespace:
38
+ enabled: true
@@ -0,0 +1,122 @@
1
+ require 'slim_lint/options'
2
+
3
+ require 'sysexits'
4
+
5
+ module SlimLint
6
+ # Command line application interface.
7
+ class CLI
8
+ attr_accessor :options
9
+
10
+ # @param logger [SlimLint::Logger]
11
+ def initialize(logger)
12
+ @log = logger
13
+ end
14
+
15
+ # Parses the given command-line arguments and executes appropriate logic
16
+ # based on those arguments.
17
+ #
18
+ # @param args [Array<String>] command line arguments
19
+ # @return [Fixnum] exit status returned by the application
20
+ def run(args)
21
+ options = SlimLint::Options.new.parse(args)
22
+ act_on_options(options)
23
+ rescue => ex
24
+ handle_exception(ex)
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :log
30
+
31
+ def act_on_options(options)
32
+ log.color_enabled = options.fetch(:color, log.tty?)
33
+
34
+ if options[:help]
35
+ print_help(options)
36
+ Sysexits::EX_OK
37
+ elsif options[:version]
38
+ print_version
39
+ Sysexits::EX_OK
40
+ elsif options[:show_linters]
41
+ print_available_linters
42
+ Sysexits::EX_OK
43
+ elsif options[:show_reporters]
44
+ print_available_reporters
45
+ Sysexits::EX_OK
46
+ else
47
+ scan_for_lints(options)
48
+ end
49
+ end
50
+
51
+ def handle_exception(ex)
52
+ case ex
53
+ when SlimLint::Exceptions::ConfigurationError
54
+ log.error ex.message
55
+ Sysexits::EX_CONFIG
56
+ when SlimLint::Exceptions::InvalidCLIOption
57
+ log.error ex.message
58
+ log.log "Run `#{APP_NAME}` --help for usage documentation"
59
+ Sysexits::EX_USAGE
60
+ when SlimLint::Exceptions::InvalidFilePath
61
+ log.error ex.message
62
+ Sysexits::EX_NOINPUT
63
+ when SlimLint::Exceptions::NoLintersError
64
+ log.error ex.message
65
+ Sysexits::EX_NOINPUT
66
+ else
67
+ print_unexpected_exception(ex)
68
+ Sysexits::EX_SOFTWARE
69
+ end
70
+ end
71
+
72
+ def scan_for_lints(options)
73
+ report = Runner.new.run(options)
74
+ print_report(report, options)
75
+ report.failed? ? Sysexits::EX_DATAERR : Sysexits::EX_OK
76
+ end
77
+
78
+ def print_report(report, options)
79
+ reporter = options.fetch(:reporter, Reporter::DefaultReporter).new(log, report)
80
+ reporter.report_lints
81
+ end
82
+
83
+ def print_available_linters
84
+ log.info 'Available linters:'
85
+
86
+ linter_names = LinterRegistry.linters.map do |linter|
87
+ linter.name.split('::').last
88
+ end
89
+
90
+ linter_names.sort.each do |linter_name|
91
+ log.log " - #{linter_name}"
92
+ end
93
+ end
94
+
95
+ def print_available_reporters
96
+ log.info 'Available reporters:'
97
+
98
+ reporter_names = Reporter.descendants.map do |reporter|
99
+ reporter.name.split('::').last.sub(/Reporter$/, '').downcase
100
+ end
101
+
102
+ reporter_names.sort.each do |reporter_name|
103
+ log.log " - #{reporter_name}"
104
+ end
105
+ end
106
+
107
+ def print_help(options)
108
+ log.log options[:help]
109
+ end
110
+
111
+ def print_version
112
+ log.log "#{APP_NAME} #{SlimLint::VERSION}"
113
+ end
114
+
115
+ def print_unexpected_exception(ex)
116
+ log.bold_error ex.message
117
+ log.error ex.backtrace.join("\n")
118
+ log.warning 'Report this bug at ', false
119
+ log.info SlimLint::BUG_REPORT_URL
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,101 @@
1
+ module SlimLint
2
+ # Stores runtime configuration for the application.
3
+ class Configuration
4
+ attr_reader :hash
5
+
6
+ # Creates a configuration from the given options hash.
7
+ #
8
+ # @param options [Hash]
9
+ def initialize(options)
10
+ @hash = options
11
+ validate
12
+ end
13
+
14
+ # @param key [String]
15
+ # @return [Array,Hash,Number,String]
16
+ def [](key)
17
+ @hash[key]
18
+ end
19
+
20
+ # Compares this configuration with another.
21
+ #
22
+ # @param other [SlimLint::Configuration]
23
+ # @return [true,false] whether the given configuration is equivalent
24
+ def ==(other)
25
+ super || @hash == other.hash
26
+ end
27
+ alias_method :eql?, :==
28
+
29
+ # Returns a non-modifiable configuration for the specified linter.
30
+ #
31
+ # @param linter [SlimLint::Linter,Class]
32
+ def for_linter(linter)
33
+ linter_name =
34
+ case linter
35
+ when Class
36
+ linter.name.split('::').last
37
+ when SlimLint::Linter
38
+ linter.name
39
+ else
40
+ linter.to_s
41
+ end
42
+
43
+ smart_merge(@hash['linters']['ALL'],
44
+ @hash['linters'].fetch(linter_name, {})).freeze
45
+ end
46
+
47
+ # Returns whether the specified linter is enabled by this configuration.
48
+ #
49
+ # @param linter [SlimLint::Linter,String]
50
+ def linter_enabled?(linter)
51
+ for_linter(linter)['enabled'] != false
52
+ end
53
+
54
+ # Merges the given configuration with this one, returning a new
55
+ # {Configuration}. The provided configuration will either add to or replace
56
+ # any options defined in this configuration.
57
+ #
58
+ # @param config [SlimLint::Configuration]
59
+ def merge(config)
60
+ self.class.new(smart_merge(@hash, config.hash))
61
+ end
62
+
63
+ private
64
+
65
+ # Validates the configuration for any invalid options, normalizing it where
66
+ # possible.
67
+ def validate
68
+ @hash = convert_nils_to_empty_hashes(@hash)
69
+ ensure_linter_section_exists(@hash)
70
+ end
71
+
72
+ def smart_merge(parent, child)
73
+ parent.merge(child) do |_key, old, new|
74
+ case old
75
+ when Hash
76
+ smart_merge(old, new)
77
+ else
78
+ new
79
+ end
80
+ end
81
+ end
82
+
83
+ def ensure_linter_section_exists(hash)
84
+ hash['linters'] ||= {}
85
+ hash['linters']['ALL'] ||= {}
86
+ end
87
+
88
+ def convert_nils_to_empty_hashes(hash)
89
+ hash.each_with_object({}) do |(key, value), h|
90
+ h[key] =
91
+ case value
92
+ when nil then {}
93
+ when Hash then convert_nils_to_empty_hashes(value)
94
+ else
95
+ value
96
+ end
97
+ h
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,68 @@
1
+ require 'pathname'
2
+ require 'yaml'
3
+
4
+ module SlimLint
5
+ # Manages configuration file loading.
6
+ class ConfigurationLoader
7
+ DEFAULT_CONFIG_PATH = File.join(SlimLint::HOME, 'config', 'default.yml')
8
+ CONFIG_FILE_NAME = '.slim-lint.yml'
9
+
10
+ class << self
11
+ def load_applicable_config
12
+ directory = File.expand_path(Dir.pwd)
13
+ config_file = possible_config_files(directory).find(&:file?)
14
+
15
+ if config_file
16
+ load_file(config_file.to_path)
17
+ else
18
+ default_configuration
19
+ end
20
+ end
21
+
22
+ def default_configuration
23
+ @default_config ||= load_from_file(DEFAULT_CONFIG_PATH)
24
+ end
25
+
26
+ # Loads a configuration, ensuring it extends the default configuration.
27
+ def load_file(file)
28
+ config = load_from_file(file)
29
+
30
+ default_configuration.merge(config)
31
+ rescue => error
32
+ raise SlimLint::Exceptions::ConfigurationError,
33
+ "Unable to load configuration from '#{file}': #{error}",
34
+ error.backtrace
35
+ end
36
+
37
+ def load_hash(hash)
38
+ config = SlimLint::Configuration.new(hash)
39
+
40
+ default_configuration.merge(config)
41
+ rescue => error
42
+ raise SlimLint::Exceptions::ConfigurationError,
43
+ "Unable to load configuration from '#{file}': #{error}",
44
+ error.backtrace
45
+ end
46
+
47
+ private
48
+
49
+ def load_from_file(file)
50
+ hash =
51
+ if yaml = YAML.load_file(file)
52
+ yaml.to_hash
53
+ else
54
+ {}
55
+ end
56
+
57
+ SlimLint::Configuration.new(hash)
58
+ end
59
+
60
+ def possible_config_files(directory)
61
+ files = Pathname.new(directory)
62
+ .enum_for(:ascend)
63
+ .map { |path| path + CONFIG_FILE_NAME }
64
+ files << Pathname.new(CONFIG_FILE_NAME)
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,8 @@
1
+ # Global application constants.
2
+ module SlimLint
3
+ HOME = File.expand_path(File.join(File.dirname(__FILE__), '..', '..'))
4
+ APP_NAME = 'slim-lint'
5
+
6
+ REPO_URL = 'https://github.com/sds/slim-lint'
7
+ BUG_REPORT_URL = "#{REPO_URL}/issues"
8
+ end
@@ -0,0 +1,52 @@
1
+ module SlimLint
2
+ # Represents a parsed Slim document and its associated metadata.
3
+ class Document
4
+ attr_reader :config, :file, :sexp, :source, :source_lines
5
+
6
+ # Parses the specified Slim code into a {Document}.
7
+ #
8
+ # @param source [String] Slim code to parse
9
+ # @param options [Hash]
10
+ # @option file [String] file name of document that was parsed
11
+ # @raise [Slim::Parser::Error] if there was a problem parsing the document
12
+ def initialize(source, options)
13
+ @config = options[:config]
14
+ @file = options.fetch(:file, '(string)')
15
+
16
+ process_source(source)
17
+ end
18
+
19
+ private
20
+
21
+ # @param source [String] Slim code to parse
22
+ # @raise [Slim::Parser::Error] if there was a problem parsing the document
23
+ def process_source(source)
24
+ @source = strip_frontmatter(source)
25
+ @source_lines = @source.split("\n")
26
+
27
+ @engine = SlimLint::Engine.new(file: @file)
28
+ @sexp = @engine.call(source)
29
+ end
30
+
31
+ # Removes YAML frontmatter
32
+ def strip_frontmatter(source)
33
+ if config['skip_frontmatter'] &&
34
+ source =~ /
35
+ # From the start of the string
36
+ \A
37
+ # First-capture match --- followed by optional whitespace up
38
+ # to a newline then 0 or more chars followed by an optional newline.
39
+ # This matches the --- and the contents of the frontmatter
40
+ (---\s*\n.*?\n?)
41
+ # From the start of the line
42
+ ^
43
+ # Second capture match --- or ... followed by optional whitespace
44
+ # and newline. This matches the closing --- for the frontmatter.
45
+ (---|\.\.\.)\s*$\n?/mx
46
+ source = $POSTMATCH
47
+ end
48
+
49
+ source
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,27 @@
1
+ module SlimLint
2
+ # Temple engine used to generate a {Sexp} parse tree for use by linters.
3
+ #
4
+ # We omit a lot of the filters that are in {Slim::Engine} because they result
5
+ # in information potentially being removed from the parse tree (since some
6
+ # Sexp abstractions are optimized/removed or otherwise transformed). In order
7
+ # for linters to be useful, they need to operate on the original parse tree.
8
+ #
9
+ # The other key task this engine accomplishes is converting the Array-based
10
+ # S-expressions into {SlimLint::Sexp} objects, which have a number of helper
11
+ # methods that makes working with them easier. It also annotates these
12
+ # {SlimLint::Sexp} objects with line numbers so it's easy to cross reference
13
+ # with the original source code.
14
+ class Engine < Temple::Engine
15
+ filter :Encoding
16
+ filter :RemoveBOM
17
+
18
+ # Parse into S-expression using Slim parser
19
+ use Slim::Parser
20
+
21
+ # Converts Array-based S-expressions into SlimLint::Sexp objects
22
+ use SlimLint::Filters::SexpConverter
23
+
24
+ # Annotates Sexps with line numbers
25
+ use SlimLint::Filters::InjectLineNumbers
26
+ end
27
+ end
@@ -0,0 +1,15 @@
1
+ # Collection of exceptions that can be raised by the HAML Lint application.
2
+ module SlimLint::Exceptions
3
+ # Raised when a {Configuration} could not be loaded from a file.
4
+ class ConfigurationError < StandardError; end
5
+
6
+ # Raised when invalid/incompatible command line options are provided.
7
+ class InvalidCLIOption < StandardError; end
8
+
9
+ # Raised when an invalid file path is specified
10
+ class InvalidFilePath < StandardError; end
11
+
12
+ # Raised when attempting to execute `Runner` with options that would result in
13
+ # no linters being enabled.
14
+ class NoLintersError < StandardError; end
15
+ end
@@ -0,0 +1,69 @@
1
+ require 'find'
2
+
3
+ module SlimLint
4
+ # Finds Slim files that should be linted given a specified list of paths, glob
5
+ # patterns, and configuration.
6
+ class FileFinder
7
+ # List of extensions of files to include under a directory when a directory
8
+ # is specified instead of a file.
9
+ VALID_EXTENSIONS = %w[.slim]
10
+
11
+ # @param config [SlimLint::Configuration]
12
+ def initialize(config)
13
+ @config = config
14
+ end
15
+
16
+ # Return list of files to lint given the specified set of paths and glob
17
+ # patterns.
18
+ # @param patterns [Array<String>]
19
+ # @param excluded_patterns [Array<String>]
20
+ # @raise [SlimLint::Exceptions::InvalidFilePath]
21
+ # @return [Array<String>] list of actual files
22
+ def find(patterns, excluded_patterns)
23
+ extract_files_from(patterns).reject do |file|
24
+ excluded_patterns.any? do |exclusion_glob|
25
+ ::File.fnmatch?(exclusion_glob, file,
26
+ ::File::FNM_PATHNAME | # Wildcards don't match path separators
27
+ ::File::FNM_DOTMATCH) # `*` wildcard matches dotfiles
28
+ end
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def extract_files_from(patterns) # rubocop:disable MethodLength
35
+ files = []
36
+
37
+ patterns.each do |pattern|
38
+ if File.file?(pattern)
39
+ files << pattern
40
+ else
41
+ begin
42
+ ::Find.find(pattern) do |file|
43
+ files << file if slim_file?(file)
44
+ end
45
+ rescue ::Errno::ENOENT
46
+ # File didn't exist; it might be a file glob pattern
47
+ matches = ::Dir.glob(pattern)
48
+ if matches.any?
49
+ files += matches
50
+ else
51
+ # One of the paths specified does not exist; raise a more
52
+ # descriptive exception so we know which one
53
+ raise SlimLint::Exceptions::InvalidFilePath,
54
+ "File path '#{pattern}' does not exist"
55
+ end
56
+ end
57
+ end
58
+ end
59
+
60
+ files.uniq
61
+ end
62
+
63
+ def slim_file?(file)
64
+ return false unless ::FileTest.file?(file)
65
+
66
+ VALID_EXTENSIONS.include?(::File.extname(file))
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,35 @@
1
+ module SlimLint::Filters
2
+ # Traverses a Temple S-expression (that has already been converted to
3
+ # {SlimLint::Sexp} instances) and annotates them with line numbers.
4
+ #
5
+ # This is a hack that allows us to access line information directly from the
6
+ # S-expressions, which makes a lot of other tasks easier.
7
+ class InjectLineNumbers < Temple::Filter
8
+ NEWLINE_SEXP = [:newline]
9
+
10
+ def call(sexp)
11
+ @line_number = 1
12
+ traverse(sexp)
13
+ sexp
14
+ end
15
+
16
+ private
17
+
18
+ # Traverses an {Sexp}, annotating it with line numbers by searching for
19
+ # :newline abstractions within it.
20
+ #
21
+ # @param sexp [SlimLint::Sexp]
22
+ def traverse(sexp)
23
+ sexp.line = @line_number
24
+
25
+ if sexp == NEWLINE_SEXP
26
+ @line_number += 1
27
+ return
28
+ end
29
+
30
+ sexp.each do |nested_sexp|
31
+ traverse(nested_sexp) if nested_sexp.is_a?(SlimLint::Sexp)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,11 @@
1
+ module SlimLint::Filters
2
+ # Converts a Temple S-expression comprised of {Array}s into {SlimLint::Sexp}s.
3
+ #
4
+ # These {SlimLint::Sexp}s include additional helpers that makes working with
5
+ # them more pleasant.
6
+ class SexpConverter < Temple::Filter
7
+ def call(array_sexp)
8
+ SlimLint::Sexp.new(array_sexp)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,25 @@
1
+ module SlimLint
2
+ # Contains information about a problem or issue with a HAML document.
3
+ class Lint
4
+ attr_reader :filename, :line, :linter, :message, :severity
5
+
6
+ # Creates a new lint.
7
+ #
8
+ # @param linter [SlimLint::Linter]
9
+ # @param filename [String]
10
+ # @param line [Fixnum]
11
+ # @param message [String]
12
+ # @param severity [Symbol]
13
+ def initialize(linter, filename, line, message, severity = :warning)
14
+ @linter = linter
15
+ @filename = filename
16
+ @line = line || 0
17
+ @message = message
18
+ @severity = severity
19
+ end
20
+
21
+ def error?
22
+ @severity == :error
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,19 @@
1
+ module SlimLint
2
+ # Checks for lines longer than a maximum number of columns.
3
+ class Linter::LineLength < Linter
4
+ include LinterRegistry
5
+
6
+ MSG = 'Line is too long. [%d/%d]'
7
+
8
+ on_start do |_sexp|
9
+ max_length = config['max']
10
+ dummy_node = Struct.new(:line)
11
+
12
+ document.source_lines.each_with_index do |line, index|
13
+ next if line.length <= max_length
14
+
15
+ report_lint(dummy_node.new(index + 1), format(MSG, line.length, max_length))
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,17 @@
1
+ module SlimLint
2
+ # Checks for unnecessary uses of the `div` tag where a class name or ID
3
+ # already implies a div.
4
+ class Linter::RedundantDiv < Linter
5
+ include LinterRegistry
6
+
7
+ MESSAGE = '`div` is redundant when %s attribute shortcut is present'
8
+
9
+ on [:html, :tag, 'div', [:html, :attrs, [:html, :attr, 'class', [:static]]]] do |sexp|
10
+ report_lint(sexp, MESSAGE % 'class')
11
+ end
12
+
13
+ on [:html, :tag, 'div', [:html, :attrs, [:html, :attr, 'id', [:static]]]] do |sexp|
14
+ report_lint(sexp, MESSAGE % 'id')
15
+ end
16
+ end
17
+ end