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,39 @@
1
+ module LintTrappings
2
+ # Store location of a {Lint} in a document.
3
+ class Location
4
+ include Comparable
5
+
6
+ attr_reader :line, :column
7
+
8
+ # @param line [Integer] One-based index
9
+ # @param column [Integer] One-based index
10
+ def initialize(line = 1, column = 1)
11
+ raise ArgumentError, "Line must be >= 0, but was #{line}" if line < 0
12
+ raise ArgumentError, "Column must be >= 0, but was #{column}" if column < 0
13
+
14
+ @line = line
15
+ @column = column
16
+ end
17
+
18
+ def ==(other)
19
+ [:line, :column].all? do |attr|
20
+ send(attr) == other.send(attr)
21
+ end
22
+ end
23
+
24
+ alias eql? ==
25
+
26
+ def <=>(other)
27
+ [:line, :column].each do |attr|
28
+ result = send(attr) <=> other.send(attr)
29
+ return result unless result == 0
30
+ end
31
+
32
+ 0
33
+ end
34
+
35
+ def to_s
36
+ "(#{line},#{column})"
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,118 @@
1
+ module LintTrappings
2
+ # Encapsulates all communication to an output source.
3
+ class Output
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(File::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
+ @color_enabled = tty?
20
+ end
21
+
22
+ # Print the specified output.
23
+ #
24
+ # @param output [String] the output to send
25
+ # @param newline [true,false] whether to append a newline
26
+ def puts(output, newline = true)
27
+ @out.print(output)
28
+ @out.print("\n") if newline
29
+ end
30
+
31
+ # Print the specified output without a newline.
32
+ #
33
+ # @param output [String] the output to send
34
+ def print(output)
35
+ puts(output, false)
36
+ end
37
+
38
+ # Print the specified output in bold face.
39
+ # If output destination is not a TTY, behaves the same as {#log}.
40
+ #
41
+ # @param args [Array<String>]
42
+ def bold(*args)
43
+ color('1', *args)
44
+ end
45
+
46
+ # Print the specified output in a color indicative of error.
47
+ # If output destination is not a TTY, behaves the same as {#log}.
48
+ #
49
+ # @param args [Array<String>]
50
+ def error(*args)
51
+ color(31, *args)
52
+ end
53
+
54
+ # Print the specified output in a bold face and color indicative of error.
55
+ # If output destination is not a TTY, behaves the same as {#log}.
56
+ #
57
+ # @param args [Array<String>]
58
+ def bold_error(*args)
59
+ color('1;31', *args)
60
+ end
61
+
62
+ # Print the specified output in a color indicative of success.
63
+ # If output destination is not a TTY, behaves the same as {#log}.
64
+ #
65
+ # @param args [Array<String>]
66
+ def success(*args)
67
+ color(32, *args)
68
+ end
69
+
70
+ # Print the specified output in a color indicative of a warning.
71
+ # If output destination is not a TTY, behaves the same as {#log}.
72
+ #
73
+ # @param args [Array<String>]
74
+ def warning(*args)
75
+ color(33, *args)
76
+ end
77
+
78
+ # Print the specified output in a color indicating something worthy of
79
+ # notice.
80
+ # If output destination is not a TTY, behaves the same as {#log}.
81
+ #
82
+ # @param args [Array<String>]
83
+ def notice(*args)
84
+ color(35, *args)
85
+ end
86
+
87
+ # Print the specified output in a color indicating information.
88
+ # If output destination is not a TTY, behaves the same as {#log}.
89
+ #
90
+ # @param args [Array<String>]
91
+ def info(*args)
92
+ color(36, *args)
93
+ end
94
+
95
+ # Print a blank line.
96
+ def newline
97
+ puts('')
98
+ end
99
+
100
+ # Whether this logger is outputting to a TTY.
101
+ #
102
+ # @return [true,false]
103
+ def tty?
104
+ @out.respond_to?(:tty?) && @out.tty?
105
+ end
106
+
107
+ private
108
+
109
+ # Print output in the specified color.
110
+ #
111
+ # @param code [Integer,String] ANSI color code
112
+ # @param output [String] output to print
113
+ # @param newline [Boolean] whether to append a newline
114
+ def color(code, output, newline = true)
115
+ puts(color_enabled ? "\033[#{code}m#{output}\033[0m" : output, newline)
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,41 @@
1
+ require 'open3'
2
+ require 'stringio'
3
+
4
+ module LintTrappings
5
+ # Processes a collection of streams with the specified command.
6
+ class Preprocessor
7
+ def initialize(config)
8
+ @config = config
9
+ @command = @config['preprocess_command']
10
+ @preprocess_files = @config['preprocess_files']
11
+ end
12
+
13
+ def preprocess_files(files_to_lint)
14
+ return unless @command
15
+
16
+ files_to_lint.each do |file_to_lint|
17
+ preprocess(file_to_lint) if preprocess_file?(file_to_lint.path)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def preprocess(file_to_lint)
24
+ contents, status = Open3.capture2(@command, stdin_data: file_to_lint.io.read)
25
+
26
+ unless status.success?
27
+ raise PreprocessorError,
28
+ "Preprocess command `#{@command}` failed when passed the " \
29
+ "contents of '#{file_to_lint.path}', returning an exit " \
30
+ "status of #{status.exitstatus}."
31
+ end
32
+
33
+ file_to_lint.io = StringIO.new(contents)
34
+ end
35
+
36
+ def preprocess_file?(file)
37
+ return true unless @preprocess_files
38
+ Utils.any_glob_matches?(@preprocess_files, file)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,145 @@
1
+ require 'rake'
2
+ require 'rake/tasklib'
3
+
4
+ module LintTrappings
5
+ # Rake task interface factory for a LintTrappings application.
6
+ #
7
+ # In your application, define your Rake task factory class as:
8
+ #
9
+ # require 'lint_trappings/rake_task'
10
+ # require 'my_app'
11
+ #
12
+ # module MyApp
13
+ # class RakeTask < LintTrappings::RakeTask
14
+ # def initialize(name = :my_app)
15
+ # @application_class = MyApp::Application
16
+ # super
17
+ # end
18
+ # end
19
+ # end
20
+ #
21
+ # Then developers can follow the instructions below (swapping out MyApp/my_app
22
+ # with the appropriate name of your application) to invoke your application
23
+ # via Rake.
24
+ #
25
+ # @example
26
+ # # Add the following to your Rakefile...
27
+ # require 'my_app/rake_task'
28
+ #
29
+ # MyApp::RakeTask.new do |t|
30
+ # t.config = 'path/to/custom/config.yml'
31
+ # t.files = %w[app/views/**/*.txt custom/*.txt]
32
+ # t.quiet = true # Don't display output from app
33
+ # end
34
+ #
35
+ # # ...and then execute from the command line:
36
+ # rake my_app
37
+ #
38
+ # You can also specify the list of files as explicit task arguments:
39
+ #
40
+ # @example
41
+ # # Add the following to your Rakefile...
42
+ # require 'my_app/rake_task'
43
+ #
44
+ # MyApp::RakeTask.new
45
+ #
46
+ # # ...and then execute from the command line:
47
+ # rake my_app[some/directory, some/specific/file.txt]
48
+ #
49
+ class RakeTask < Rake::TaskLib
50
+ # Name of the task.
51
+ # @return [String]
52
+ attr_accessor :name
53
+
54
+ # Path of the configuration file to use.
55
+ # @return [String]
56
+ attr_accessor :config
57
+
58
+ # List of files to lint.
59
+ #
60
+ # Note that this will be ignored if you explicitly pass a list of files as
61
+ # task arguments via the command line.
62
+ # @return [Array<String>]
63
+ attr_accessor :files
64
+
65
+ # Whether output from application should not be displayed to the standard
66
+ # out stream.
67
+ # @return [true,false]
68
+ attr_accessor :quiet
69
+
70
+ # Create the task so it exists in the current namespace.
71
+ #
72
+ # @param name [Symbol] task name
73
+ def initialize(name)
74
+ @name = name
75
+ @files = []
76
+ @quiet = false
77
+
78
+ # Allow custom configuration to be defined in a block passed to constructor
79
+ yield self if block_given?
80
+
81
+ define
82
+ end
83
+
84
+ private
85
+
86
+ # Defines the Rake task.
87
+ def define
88
+ desc default_description unless ::Rake.application.last_description
89
+
90
+ task(name, [:files]) do |_task, task_args|
91
+ run_cli(task_args)
92
+ end
93
+ end
94
+
95
+ # Executes the CLI given the specified task arguments.
96
+ #
97
+ # @param task_args [Rake::TaskArguments]
98
+ def run_cli(task_args)
99
+ raise ArgumentError, '@application_class must be defined!' unless @application_class
100
+
101
+ output = quiet ? LintTrappings::Output.silent : LintTrappings::Output.new(STDOUT)
102
+ app = @application_class.new(output)
103
+
104
+ options = {}.tap do |opts|
105
+ opts[:command] = :scan
106
+ opts[:config_file] = @config if @config
107
+ opts[:included_paths] = files_to_lint(task_args)
108
+ end
109
+
110
+ begin
111
+ app.run(options) # Will raise exception on failure
112
+ rescue LintTrappings::ScanWarned
113
+ puts "#{app.name} reported warnings"
114
+ end
115
+ end
116
+
117
+ # Returns the list of files that should be linted given the specified task
118
+ # arguments.
119
+ #
120
+ # @param task_args [Rake::TaskArguments]
121
+ def files_to_lint(task_args)
122
+ # Note: we're abusing Rake's argument handling a bit here. We call the
123
+ # first argument `files` but it's actually only the first file--we pull
124
+ # the rest out of the `extras` from the task arguments. This is so we
125
+ # can specify an arbitrary list of files separated by commas on the
126
+ # command line or in a custom task definition.
127
+ explicit_files = Array(task_args[:files]) + Array(task_args.extras)
128
+
129
+ explicit_files.any? ? explicit_files : @files
130
+ end
131
+
132
+ # Friendly description that shows up in Rake task listing.
133
+ #
134
+ # This allows us to change the information displayed by `rake --tasks` based
135
+ # on the options passed to the constructor which defined the task.
136
+ #
137
+ # @return [String]
138
+ def default_description
139
+ description = "Run `#{@application_class.name}`"
140
+ description << ' quietly' if @quiet
141
+ description << " using config file #{@config}" if @config
142
+ description
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,58 @@
1
+ module LintTrappings
2
+ # Contains information about all lints detected during a scan.
3
+ class Report
4
+ # List of lints that were found.
5
+ # @return [Array<LintTrappings::Lint]
6
+ attr_accessor :lints
7
+
8
+ # @return [Array<String>] List of files that were inspected.
9
+ attr_reader :documents_inspected
10
+
11
+ # @param config [LintTrappings::Configuration]
12
+ # @param lints [Array<LintTrappings::Lint>] lints that were found
13
+ # @param documents [Array<Document>] files that were linted
14
+ def initialize(config, lints, documents)
15
+ @config = config
16
+ @lints = lints.sort_by { |lint| [lint.path, lint.source_range.begin.line] }
17
+ @documents_inspected = documents
18
+ end
19
+
20
+ def severities
21
+ @severities ||= @config.fetch('severities', error: 'fail', warning: 'warn')
22
+ end
23
+
24
+ def fail_severities
25
+ @fail_severities ||= severities.select { |_severity, action| action == 'fail' }.keys
26
+ end
27
+
28
+ def warn_severities
29
+ @warn_severities ||= severities.select { |_severity, action| action == 'warn' }.keys
30
+ end
31
+
32
+ def failures?
33
+ failures.any?
34
+ end
35
+
36
+ def failures
37
+ @failures ||=
38
+ begin
39
+ @lints.select { |lint| fail_severities.include?(lint.severity) }
40
+ end
41
+ end
42
+
43
+ def warnings?
44
+ warnings.any?
45
+ end
46
+
47
+ def warnings
48
+ @warnings ||=
49
+ begin
50
+ @lints.select { |lint| warn_severities.include?(lint.severity) }
51
+ end
52
+ end
53
+
54
+ def success?
55
+ lints.empty?
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,161 @@
1
+ require 'lint_trappings/formatter_forwarder'
2
+ require 'lint_trappings/formatter_loader'
3
+ require 'lint_trappings/preprocessor'
4
+
5
+ module LintTrappings
6
+ # Linter runner.
7
+ #
8
+ # Runs linters against a set of files, ensuring the appropriate linters are
9
+ # run against the relevant files based on configuration.
10
+ class Runner
11
+ def initialize(application, config, output)
12
+ @application = application
13
+ @config = config
14
+ @output = output
15
+ end
16
+
17
+ # A individual unit of work which can be processed by a worker.
18
+ Job = Struct.new(:linter, :path)
19
+
20
+ # Runs the appropriate linters against the set of specified files, return a
21
+ # report of all lints found.
22
+ #
23
+ # @param options [Hash]
24
+ #
25
+ # @return [LintTrappings::Report] report of all lints found and other statistics
26
+ def run(options = {})
27
+ @options = options
28
+
29
+ # Coalesce formatters into a single formatter which will forward calls
30
+ formatters = FormatterLoader.new(@application, @config, @output).load(options)
31
+ @formatter = FormatterForwarder.new(formatters)
32
+
33
+ # We store the documents in a map so that if we're parallelizing the run
34
+ # we don't need to pass serialized Document objects via IPC, just the path
35
+ # string. Since forking will use copy-on-write semantics, we'll be able
36
+ # to reuse the memory storing those documents for all workers, since we're
37
+ # just reading.
38
+ @paths_to_documents_map, parse_lints = load_documents_to_lint(options)
39
+
40
+ # Extract all jobs we want to run as file/linter pairs
41
+ linter_selector = LinterSelector.new(@application, @config, options)
42
+ jobs = @paths_to_documents_map.keys.map do |path|
43
+ linter_selector.linters_for_file(path).map { |linter| Job.new(linter, path) }
44
+ end.flatten
45
+
46
+ lints = find_all_lints(jobs) + parse_lints
47
+ report = Report.new(@config, lints, @paths_to_documents_map.values)
48
+
49
+ @formatter.finished(report)
50
+
51
+ report
52
+ end
53
+
54
+ private
55
+
56
+ # A file to be linted.
57
+ FileToLint = Struct.new(:io, :path)
58
+
59
+ def determine_files_to_lint(options)
60
+ if options[:stdin_file_path]
61
+ [FileToLint.new(options[:stdin], options[:stdin_file_path])]
62
+ else
63
+ find_files(options).map do |path|
64
+ FileToLint.new(File.open(path), path)
65
+ end
66
+ end
67
+ rescue Errno::ENOENT => err
68
+ raise InvalidFilePathError, err.message
69
+ end
70
+
71
+ def find_files(options)
72
+ opts = {}
73
+ opts[:allowed_extensions] = @config.fetch(:file_extensions, @application.file_extensions)
74
+
75
+ opts[:included_paths] = options.fetch(:included_paths, @config.fetch(:included_paths, []))
76
+ opts[:excluded_paths] = @config.fetch(:excluded_paths, []) +
77
+ options.fetch(:excluded_paths, [])
78
+
79
+ opts[:included_patterns] = @config.fetch(:include) do
80
+ if opts[:included_paths].any?
81
+ # Don't specify default inclusion pattern since include paths were
82
+ # explicitly specified
83
+ []
84
+ else
85
+ # Otherwise, we want the default behavior to lint all files with the
86
+ # default file extensions
87
+ opts[:allowed_extensions].map { |ext| "**/*.#{ext}" }
88
+ end
89
+ end
90
+ opts[:excluded_patterns] = @config.fetch(:exclude, [])
91
+
92
+ FileFinder.find(opts)
93
+ end
94
+
95
+ def load_documents_to_lint(options)
96
+ documents = {}
97
+ parse_lints = []
98
+
99
+ files_to_lint = determine_files_to_lint(options)
100
+ Preprocessor.new(@config).preprocess_files(files_to_lint)
101
+ @formatter.started(files_to_lint)
102
+
103
+ files_to_lint.each do |file_to_lint|
104
+ begin
105
+ documents[file_to_lint.path] =
106
+ @application.document_class.new(file_to_lint.io.read,
107
+ @config,
108
+ path: file_to_lint.path)
109
+ rescue ParseError => err
110
+ parse_lints << Lint.new(
111
+ path: file_to_lint.path,
112
+ source_range: err.source_range,
113
+ message: "Error occurred while parsing #{file_to_lint.path}: #{err.message}",
114
+ severity: :error,
115
+ )
116
+ end
117
+ end
118
+
119
+ [documents, parse_lints]
120
+ end
121
+
122
+ def scan_document(job)
123
+ @formatter.job_started(job)
124
+
125
+ document = @paths_to_documents_map[job.path]
126
+ reported_lints = job.linter.run(document)
127
+
128
+ @formatter.job_finished(job, reported_lints)
129
+
130
+ reported_lints
131
+ rescue => err
132
+ loc = Location.new(1)
133
+ message = "Error occurred while linting #{job.path}: #{err.message}"
134
+
135
+ lints = [Lint.new(
136
+ linter: job.linter,
137
+ path: job.path,
138
+ source_range: loc..loc,
139
+ message: message,
140
+ severity: :error,
141
+ exception: err,
142
+ )]
143
+
144
+ @formatter.job_finished(job, lints)
145
+ lints
146
+ end
147
+
148
+ def find_all_lints(jobs)
149
+ lints =
150
+ if workers = @options[:concurrency]
151
+ require 'parallel'
152
+ workers = workers == 'auto' ? Parallel.processor_count : Integer(workers)
153
+ ::Parallel.map(jobs, { in_processes: workers }, &method(:scan_document))
154
+ else
155
+ jobs.map(&method(:scan_document))
156
+ end
157
+
158
+ lints.flatten
159
+ end
160
+ end
161
+ end