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.
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