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