lint_trappings 0.1.0

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