lint_trappings 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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.md +21 -0
  3. data/lib/lint_trappings.rb +17 -0
  4. data/lib/lint_trappings/application.rb +138 -0
  5. data/lib/lint_trappings/arguments_parser.rb +145 -0
  6. data/lib/lint_trappings/cli.rb +61 -0
  7. data/lib/lint_trappings/command/base.rb +36 -0
  8. data/lib/lint_trappings/command/display_documentation.rb +65 -0
  9. data/lib/lint_trappings/command/display_formatters.rb +14 -0
  10. data/lib/lint_trappings/command/display_help.rb +8 -0
  11. data/lib/lint_trappings/command/display_linters.rb +24 -0
  12. data/lib/lint_trappings/command/display_version.rb +14 -0
  13. data/lib/lint_trappings/command/scan.rb +19 -0
  14. data/lib/lint_trappings/configuration.rb +94 -0
  15. data/lib/lint_trappings/configuration_loader.rb +98 -0
  16. data/lib/lint_trappings/configuration_resolver.rb +49 -0
  17. data/lib/lint_trappings/document.rb +45 -0
  18. data/lib/lint_trappings/errors.rb +127 -0
  19. data/lib/lint_trappings/executable.rb +26 -0
  20. data/lib/lint_trappings/file_finder.rb +171 -0
  21. data/lib/lint_trappings/formatter/base.rb +67 -0
  22. data/lib/lint_trappings/formatter/checkstyle.rb +34 -0
  23. data/lib/lint_trappings/formatter/default.rb +99 -0
  24. data/lib/lint_trappings/formatter/json.rb +62 -0
  25. data/lib/lint_trappings/formatter_forwarder.rb +23 -0
  26. data/lib/lint_trappings/formatter_loader.rb +45 -0
  27. data/lib/lint_trappings/lint.rb +37 -0
  28. data/lib/lint_trappings/linter.rb +182 -0
  29. data/lib/lint_trappings/linter_configuration_validator.rb +42 -0
  30. data/lib/lint_trappings/linter_loader.rb +44 -0
  31. data/lib/lint_trappings/linter_plugin.rb +35 -0
  32. data/lib/lint_trappings/linter_selector.rb +120 -0
  33. data/lib/lint_trappings/location.rb +39 -0
  34. data/lib/lint_trappings/output.rb +118 -0
  35. data/lib/lint_trappings/preprocessor.rb +41 -0
  36. data/lib/lint_trappings/rake_task.rb +145 -0
  37. data/lib/lint_trappings/report.rb +58 -0
  38. data/lib/lint_trappings/runner.rb +161 -0
  39. data/lib/lint_trappings/spec.rb +12 -0
  40. data/lib/lint_trappings/spec/directory_helpers.rb +22 -0
  41. data/lib/lint_trappings/spec/indentation_helpers.rb +7 -0
  42. data/lib/lint_trappings/spec/matchers/report_lint_matcher.rb +169 -0
  43. data/lib/lint_trappings/spec/shared_contexts/linter_shared_context.rb +35 -0
  44. data/lib/lint_trappings/utils.rb +123 -0
  45. data/lib/lint_trappings/version.rb +4 -0
  46. metadata +117 -0
@@ -0,0 +1,23 @@
1
+ module LintTrappings
2
+ # Contains a collection of formatters and their output destinations, exposing
3
+ # them a single formatter.
4
+ #
5
+ # This quacks like a Formatter so that it can be used in place of a single
6
+ # formatter, but fans out the calls to all formatters in the collection.
7
+ class FormatterForwarder
8
+ def initialize(formatters)
9
+ @formatters = formatters
10
+ end
11
+
12
+ %i[
13
+ started
14
+ job_started
15
+ job_finished
16
+ finished
17
+ ].each do |method_sym|
18
+ define_method method_sym do |*args|
19
+ @formatters.each { |formatter| formatter.send(method_sym, *args) }
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,45 @@
1
+ module LintTrappings
2
+ # Loads the configured formatters.
3
+ class FormatterLoader
4
+ def initialize(application, config, output)
5
+ @application = application
6
+ @config = config
7
+ @output = output
8
+ end
9
+
10
+ def load(options)
11
+ outputs = options.fetch(:formatters, [{ 'Default' => :stdout }])
12
+
13
+ outputs.map do |output_specification|
14
+ output_specification.map do |formatter_name, output_path|
15
+ load_formatter(formatter_name)
16
+ create_formatter(formatter_name, output_path, options)
17
+ end
18
+ end.flatten
19
+ end
20
+
21
+ private
22
+
23
+ def load_formatter(formatter_name)
24
+ formatter_path = File.join('lint_trappings', 'formatter', Utils.snake_case(formatter_name))
25
+ require formatter_path
26
+ rescue LoadError, SyntaxError => ex
27
+ raise FormatterLoadError,
28
+ "Unable to load formatter `#{formatter_name}`: #{ex.message}"
29
+ end
30
+
31
+ def create_formatter(formatter_name, output_path, options)
32
+ output_dest =
33
+ if output_path == :stdout
34
+ @output
35
+ else
36
+ Output.new(File.open(output_path, File::CREAT | File::WRONLY))
37
+ end
38
+
39
+ Formatter.const_get(formatter_name).new(@application, @config, options, output_dest)
40
+ rescue NameError => ex
41
+ raise FormatterLoadError,
42
+ "Unable to create formatter `#{formatter_name}`: #{ex.message}"
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,37 @@
1
+ module LintTrappings
2
+ # Contains information about a problem or issue with a document.
3
+ class Lint
4
+ # @return [LintTrappings::Linter, nil] linter that reported the lint (if applicable)
5
+ attr_reader :linter
6
+
7
+ # @return [String] file path to which the lint applies
8
+ attr_reader :path
9
+
10
+ # @return [Range<LintTrappings::Location>] source range of the problem within the file
11
+ attr_reader :source_range
12
+
13
+ # @return [String] message describing the lint
14
+ attr_reader :message
15
+
16
+ # @return [Symbol] whether this lint is a warning or an error
17
+ attr_reader :severity
18
+
19
+ # @return [Exception] the exception that was raised, if any
20
+ attr_reader :exception
21
+
22
+ # @param options [Hash]
23
+ # @option options :linter [LintTrappings::Linter] optional
24
+ # @option options :path [String]
25
+ # @option options :source_range [Range<LintTrappings::Location>]
26
+ # @option options :message [String]
27
+ # @option options :severity [Symbol]
28
+ def initialize(options)
29
+ @linter = options[:linter]
30
+ @path = options.fetch(:path)
31
+ @source_range = options.fetch(:source_range)
32
+ @message = options.fetch(:message)
33
+ @severity = options.fetch(:severity)
34
+ @exception = options[:exception]
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,182 @@
1
+ require 'lint_trappings/linter_configuration_validator'
2
+ require 'ostruct'
3
+
4
+ module LintTrappings
5
+ # Base implementation for all lint checks.
6
+ #
7
+ # @abstract
8
+ class Linter
9
+ class << self
10
+ # Return all subclasses.
11
+ #
12
+ # @return [Array<Class>]
13
+ def descendants
14
+ ObjectSpace.each_object(Class).select { |klass| klass < self }
15
+ end
16
+
17
+ # Returns the canonical name for this linter class.
18
+ #
19
+ # The canonical name is used as the key for configuring the linter in the
20
+ # configuration file, or when referring to it from the command line.
21
+ #
22
+ # This uses the "Linter" module as an indicator of when to start removing
23
+ # unnecessary module prefixes.
24
+ #
25
+ # @example
26
+ # LintTrappings::Linter::MyLinter
27
+ # => "MyLinter"
28
+ #
29
+ # @example
30
+ # MyCustomNamespace::MyLinter
31
+ # => "MyCustomNamespace::MyLinter"
32
+ #
33
+ # @example
34
+ # MyModule::Linter::MyCustomNamespace::MyLinter
35
+ # => "MyCustomNamespace::MyLinter"
36
+ #
37
+ # @return [String]
38
+ def canonical_name
39
+ @canonical_name ||=
40
+ begin
41
+ full_name = name.to_s.split('::')
42
+
43
+ if linter_class_index = full_name.index('Linter')
44
+ # Otherwise, the name follows the `Linter` module
45
+ linter_class_index += 1
46
+ else
47
+ # If not found, include the full name
48
+ linter_class_index = 0
49
+ end
50
+
51
+ full_name[linter_class_index..-1].join('::')
52
+ end
53
+ end
54
+
55
+ def description(*args)
56
+ if args.any?
57
+ @description = args.first
58
+ else
59
+ @description
60
+ end
61
+ end
62
+
63
+ def option(name, options)
64
+ options = options.dup
65
+
66
+ @options_spec ||= {}
67
+ opt = @options_spec[name] = {}
68
+ %i[type default description].each do |option_sym|
69
+ opt[option_sym] = options.delete(option_sym) if options[option_sym]
70
+ end
71
+
72
+ if options.keys.any?
73
+ raise InvalidOptionSpecificationError,
74
+ "Unknown key `#{options.keys.first}` for `#{name}` option " \
75
+ "specification on linter #{self}"
76
+ end
77
+ end
78
+
79
+ def options
80
+ @options_spec || {}
81
+ end
82
+
83
+ attr_accessor :options_struct_class
84
+ end
85
+
86
+ # Initializes a linter with the specified configuration.
87
+ #
88
+ # @param config [Hash] configuration for this linter
89
+ def initialize(config)
90
+ @orig_hash_config = @config = config
91
+ validate_options_specification
92
+ @config = convert_config_hash_to_struct(@config)
93
+ @lints = []
94
+ end
95
+
96
+ # Runs the linter against the given Slim document.
97
+ #
98
+ # @param document [LintTrappings::Document]
99
+ def run(document)
100
+ @document = document
101
+ @lints = []
102
+ scan
103
+ @lints
104
+ end
105
+
106
+ # Returns the canonical name of this linter's class.
107
+ #
108
+ # @see {LintTrappings::Linter.canonical_name}
109
+ #
110
+ # @return [String]
111
+ def canonical_name
112
+ self.class.canonical_name
113
+ end
114
+
115
+ private
116
+
117
+ attr_reader :config, :document, :lints
118
+
119
+ # Scans the document for lints.
120
+ def scan
121
+ raise NotImplementedError, 'Subclass must implement #scan'
122
+ end
123
+
124
+ def validate_options_specification
125
+ LinterConfigurationValidator.new.validate(self, @config, self.class.options)
126
+ end
127
+
128
+ # List of built-in hook options which are available to every hook
129
+ BUILT_IN_HOOK_OPTIONS = %w[enabled severity include exclude]
130
+
131
+ # Converts a configuration hash to a struct so configuration values are
132
+ # accessed via method calls. This is valuable as it provides faster feedback
133
+ # in the event of a typo (you get an error instead of a `nil` value).
134
+ #
135
+ # @return [Struct]
136
+ def convert_config_hash_to_struct(hash)
137
+ option_names = self.class.options.keys
138
+ return OpenStruct.new unless option_names.any?
139
+ self.class.options_struct_class ||= Struct.new(*option_names)
140
+
141
+ unknown_keys = (hash.keys - option_names.map(&:to_s) - BUILT_IN_HOOK_OPTIONS)
142
+ if unknown_keys.any?
143
+ option_plural = Utils.pluralize('option', unknown_keys.count)
144
+ raise LinterConfigurationError,
145
+ "Unknown configuration #{option_plural} for #{canonical_name}: " \
146
+ "#{unknown_keys.join(', ')}\n" \
147
+ "Available options: #{(BUILT_IN_HOOK_OPTIONS + option_names).join(', ')}"
148
+ end
149
+
150
+ values = option_names.map { |option_name| hash[option_name.to_s] }
151
+ self.class.options_struct_class.new(*values)
152
+ end
153
+
154
+ # Record a lint for reporting back to the user.
155
+ #
156
+ # @param range [Range<LintTrappings::Location>,#source_range] source range of lint
157
+ # @param message [String] error/warning to display to the user
158
+ def report_lint(range_or_obj, message)
159
+ unless range_or_obj.is_a?(Range) || range_or_obj.respond_to?(:source_range)
160
+ raise LinterError,
161
+ '`report_lint` must be given a Range or an object ' \
162
+ "that responds to `source_range`, but was given: #{range_or_obj.inspect}"
163
+ end
164
+
165
+ @lints << Lint.new(
166
+ linter: self,
167
+ path: @document.path,
168
+ source_range: range_or_obj.is_a?(Range) ? range_or_obj : range_or_obj.source_range,
169
+ message: message,
170
+ severity: @orig_hash_config.fetch('severity'),
171
+ )
172
+ end
173
+
174
+ # Shortcut for creating a range for a single location.
175
+ #
176
+ # @return [Range<LintTrappings::Location>]
177
+ def location(*args)
178
+ loc = Location.new(*args)
179
+ loc..loc
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,42 @@
1
+ module LintTrappings
2
+ # Validates a linter's configuration options.
3
+ class LinterConfigurationValidator
4
+ # Verifies the configuration passed to this linter satisfies the options
5
+ # specifications declared in the linter class.
6
+ #
7
+ # @param linter [LintTrappings::Linter]
8
+ # @param config [Hash]
9
+ # @param options_specs [Hash]
10
+ def validate(linter, config, options_specs)
11
+ insert_default_values(config, options_specs)
12
+ check_option_types(linter, config, options_specs)
13
+ end
14
+
15
+ private
16
+
17
+ def check_option_types(linter, config, options_specs)
18
+ options_specs.select do |option_name, option_spec|
19
+ expected_class = option_spec[:type]
20
+ actual_value = config[option_name.to_s]
21
+ actual_class = actual_value.class
22
+
23
+ # If the class isn't the same or a subclass, it's different
24
+ next if actual_class <= expected_class
25
+
26
+ raise LinterConfigurationError,
27
+ "Option `#{option_name}` for linter " \
28
+ "#{linter.canonical_name} must be of " \
29
+ "type #{expected_class}, but was #{actual_class} (#{actual_value.inspect})!"
30
+ end
31
+ end
32
+
33
+ def insert_default_values(config, options_specs)
34
+ options_specs.select do |option_name, option_spec|
35
+ option_name_str = option_name.to_s
36
+ next unless option_spec.key?(:default) && !config.key?(option_name_str)
37
+
38
+ config[option_name_str] = option_spec[:default]
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,44 @@
1
+ module LintTrappings
2
+ # Loads linters so they can be run.
3
+ class LinterLoader
4
+ def initialize(application, config)
5
+ @application = application
6
+ @config = config
7
+ end
8
+
9
+ # Load linters into memory so they can be instantiated.
10
+ #
11
+ # @param options [Hash]
12
+ #
13
+ # @raise [LinterLoadError] problem loading a linter file/library
14
+ def load(options)
15
+ load_directory(@application.linters_directory)
16
+
17
+ directories = Array(@config['linter_directories']) + Array(options[:linter_directories])
18
+ directories.each do |directory|
19
+ load_directory(directory)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ # Recursively load all files in a directory and its subdirectories.
26
+ def load_directory(directory)
27
+ # NOTE: While it might seem inefficient to load ALL linters (rather than
28
+ # only ones which are enabled), the reality is that the difference is
29
+ # negligible with respect to the application's startup time. It's also
30
+ # very difficult to do, as you can't infer the file name from the linter
31
+ # name (since the user can use any naming scheme they desire)
32
+ Dir[File.join(directory, '**', '*.rb')].each do |path|
33
+ load_path(path)
34
+ end
35
+ end
36
+
37
+ def load_path(path)
38
+ require path
39
+ rescue LoadError, SyntaxError => ex
40
+ raise LinterLoadError,
41
+ "Unable to load linter file '#{path}': #{ex.message}"
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,35 @@
1
+ module LintTrappings
2
+ # Represents a collection of linters/configuration which are loaded from a
3
+ # gem.
4
+ #
5
+ # This is just a wrapper to make accessing files in the gem easier.
6
+ class LinterPlugin
7
+ # @param require_path [String] name of the gem (must be the same as the path
8
+ # to `require`!)
9
+ def initialize(require_path)
10
+ @require_path = require_path
11
+ end
12
+
13
+ def load
14
+ require @require_path
15
+ rescue LoadError, SyntaxError => ex
16
+ raise LinterLoadError,
17
+ "Unable to load linter plugin at path '#{@require_path}': #{ex.message}"
18
+ end
19
+
20
+ # Returns path to the configuration file that ships with this linter plugin.
21
+ #
22
+ # Note that this may not exist if no configuration is shipped with the gem.
23
+ #
24
+ # @return [String]
25
+ def config_file_path
26
+ File.join(gem_dir, 'config.yaml')
27
+ end
28
+
29
+ private
30
+
31
+ def gem_dir
32
+ Gem::Specification.find_by_name(@require_path).gem_dir
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,120 @@
1
+ module LintTrappings
2
+ # Chooses the appropriate linters to run against a file.
3
+ #
4
+ # All linter inclusion/exclusion based on command line flags or configuration
5
+ # is handled here. This is utilized by the runner to generate linter/file
6
+ # tuples representing jobs to execute (i.e. run the linter X against file Y).
7
+ class LinterSelector
8
+ # @param application [LintTrappings::Application]
9
+ # @param config [LintTrappings::Configuration]
10
+ # @param options [Hash]
11
+ #
12
+ # @raise [LintTrappings::NoLintersError] when no linters are enabled
13
+ def initialize(application, config, options)
14
+ @application = application
15
+ @config = config
16
+ @options = options
17
+
18
+ # Pre-compute this as it is expensive to calculate and used many times.
19
+ # This forces any errors in the configuration to be surfaced ahead of time.
20
+ @enabled_linter_classes = enabled_linter_classes
21
+ end
22
+
23
+ # Return all loaded linter classes for this application.
24
+ # @return [Array<Class>]
25
+ def all_linter_classes
26
+ @application.linter_base_class.descendants
27
+ end
28
+
29
+ # Returns initialized linter instances to run against a given file.
30
+ #
31
+ # @param path [String]
32
+ #
33
+ # @return [Array<LintTrappings::Linter>]
34
+ def linters_for_file(path)
35
+ @enabled_linter_classes.map do |linter_class|
36
+ linter_conf = @config.for_linter(linter_class)
37
+ next unless run_linter_on_file?(linter_conf, path)
38
+
39
+ linter_class.new(linter_conf)
40
+ end.compact
41
+ end
42
+
43
+ # Returns a list of linters that are enabled given the specified
44
+ # configuration and additional options.
45
+ #
46
+ # @return [Array<LintTrappings::Linter>]
47
+ def enabled_linter_classes
48
+ # Include the explicit list of linters if a list was specified
49
+ explicitly_included = included_linter_classes =
50
+ linter_classes_from_names(@options.fetch(:included_linters, []))
51
+
52
+ if included_linter_classes.empty?
53
+ # Otherwise use the list of enabled linters specified by the config.
54
+ # Note: this means that a linter which is disabled in the configuration
55
+ # can still be run if it is explicitly specified in `included_linters`
56
+ included_linter_classes = all_linter_classes.select do |linter_class|
57
+ linter_enabled?(linter_class)
58
+ end
59
+ end
60
+
61
+ excluded_linter_classes =
62
+ linter_classes_from_names(@options.fetch(:excluded_linters, []))
63
+
64
+ linter_classes = included_linter_classes - excluded_linter_classes
65
+
66
+ # Highlight conditions where all linters were filtered out, as this was
67
+ # likely a mistake on the user's part
68
+ if linter_classes.empty?
69
+ if explicitly_included.any?
70
+ raise NoLintersError,
71
+ 'All specified linters were explicitly excluded!'
72
+ elsif included_linter_classes.empty?
73
+ raise NoLintersError,
74
+ 'All linters are disabled. Enable some in your configuration!'
75
+ else
76
+ raise NoLintersError,
77
+ 'All enabled linters were explicitly excluded!'
78
+ end
79
+ end
80
+
81
+ linter_classes
82
+ end
83
+
84
+ private
85
+
86
+ # Whether to run the given linter against the specified file.
87
+ #
88
+ # @param linter_conf [Hash]
89
+ # @param path [String]
90
+ #
91
+ # @return [Boolean]
92
+ def run_linter_on_file?(linter_conf, path)
93
+ if linter_conf['include'] &&
94
+ !Utils.any_glob_matches?(linter_conf['include'], path)
95
+ return false
96
+ end
97
+
98
+ if Utils.any_glob_matches?(linter_conf['exclude'], path)
99
+ return false
100
+ end
101
+
102
+ true
103
+ end
104
+
105
+ def linter_enabled?(linter_class)
106
+ @config.for_linter(linter_class)['enabled']
107
+ end
108
+
109
+ def linter_classes_from_names(linter_names)
110
+ linter_names.map do |linter_name|
111
+ begin
112
+ @application.linter_base_class.const_get(linter_name)
113
+ rescue NameError
114
+ raise NoSuchLinter,
115
+ "Linter #{linter_name} does not exist! Are you sure you spelt it correctly?"
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end