scully 0.0.1 → 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.
@@ -0,0 +1,386 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+
5
+ module Scully
6
+ module Frameworks
7
+ # Turns an RSpec file into TestCases: examples inside describe/context/shared groups, with
8
+ # their status, the setup of every enclosing group, and the assertions in their bodies.
9
+ class RSpec
10
+ GROUPS = %i[describe context feature example_group shared_examples shared_examples_for shared_context
11
+ fdescribe fcontext ffeature].freeze
12
+ SKIPPED_GROUPS = %i[xdescribe xcontext xfeature].freeze
13
+ EXAMPLES = %i[it specify example scenario fit fspecify fexample fscenario].freeze
14
+ SKIPPED_EXAMPLES = %i[xit xspecify xexample xscenario].freeze
15
+ # `pending "..." do` and `skip "..." do` at group level declare examples; inside an
16
+ # example, `pending` and `skip` calls mark it.
17
+ MARKERS = { pending: :pending, skip: :skipped }.freeze
18
+ HOOKS = { before: :before, prepend_before: :before, append_before: :before, around: :before,
19
+ after: :teardown, prepend_after: :teardown, append_after: :teardown }.freeze
20
+ LETS = { let: :let, let!: :let, subject: :subject, subject!: :subject }.freeze
21
+ SETUP = HOOKS.merge(LETS).freeze
22
+
23
+ # The names, setup, bindings and setup doubles visible inside a group, and the status a
24
+ # skipped or pending group gives its examples.
25
+ Context = Data.define(:names, :setup, :bindings, :doubles, :marked)
26
+ # `is_expected` and a bare `should` check the subject.
27
+ SUBJECT_READERS = %i[is_expected should should_not].freeze
28
+
29
+ include Syntax
30
+
31
+ def self.call(source) = new(source).test_cases
32
+
33
+ def initialize(source)
34
+ @source = source
35
+ @helpers = Helpers.new(source.result.value, assertion: ->(call) { !Expectations.fields(call).nil? })
36
+ end
37
+
38
+ def test_cases
39
+ @tests = []
40
+ walk(@source.result.value.statements,
41
+ Context.new(names: [], setup: [], bindings: {}, doubles: [], marked: nil))
42
+ @tests
43
+ end
44
+
45
+ private
46
+
47
+ def walk(node, context)
48
+ statements(node).each { statement(it, context) }
49
+ end
50
+
51
+ def statement(node, context)
52
+ case node
53
+ when Prism::ModuleNode, Prism::ClassNode then walk(node.body, context)
54
+ when Prism::ItLocalVariableReadNode then bare_example(node, context)
55
+ when Prism::CallNode then call_statement(node, context)
56
+ end
57
+ end
58
+
59
+ # Examples generated in loops (`%w[a b].each do ... it ... end`) belong to the group.
60
+ def call_statement(call, context)
61
+ if group?(call) then walk(call.block.body, group_context(call, context))
62
+ elsif example?(call) then example(call, context)
63
+ elsif call.block.is_a?(Prism::BlockNode) && !setup_call?(call) then walk(call.block.body, context)
64
+ end
65
+ end
66
+
67
+ def group?(call)
68
+ (call.receiver.nil? || Detector.rspec_receiver?(call.receiver)) && call.block.is_a?(Prism::BlockNode) &&
69
+ (GROUPS.include?(call.name) || SKIPPED_GROUPS.include?(call.name))
70
+ end
71
+
72
+ def example?(call)
73
+ return false unless call.receiver.nil?
74
+ return true if EXAMPLES.include?(call.name) || SKIPPED_EXAMPLES.include?(call.name)
75
+
76
+ MARKERS.key?(call.name) && !(call.block || description(call)).nil?
77
+ end
78
+
79
+ def setup_call?(call) = call.receiver.nil? && SETUP.key?(call.name)
80
+
81
+ def group_context(call, context)
82
+ own = own_setup(call.block.body)
83
+ context.with(names: [*context.names, *description(call)], marked: context.marked || Status.group(call),
84
+ **setup_from(own, context))
85
+ end
86
+
87
+ def setup_from(own, context)
88
+ { setup: context.setup + own.map { setup_step(it) },
89
+ bindings: context.bindings.merge(*own.map { Scope.bindings(it) }),
90
+ doubles: context.doubles + own.flat_map { Scope.doubles(it, Doubles.new(@source, :setup)) } }
91
+ end
92
+
93
+ def own_setup(body) = statements(body).select { it.is_a?(Prism::CallNode) && setup_call?(it) }
94
+
95
+ def setup_step(call) = SetupStep.new(kind: SETUP[call.name], span: span(call.location))
96
+
97
+ def example(call, context)
98
+ add(context, name: name(context, description(call), call), definition: definition(call),
99
+ extent: call.location, status: Status.of(call, context), **contents(call.block&.body, context))
100
+ end
101
+
102
+ # Bindings, assertions, helper calls and doubles of an example body.
103
+ def contents(body, context)
104
+ bindings = context.bindings.merge(assignments(body).to_h)
105
+ assertions, helper_calls = inventory(context).scan(body)
106
+ { bindings:, helper_calls:, assertions: assertions.map { Scope.read_subject(it, bindings) },
107
+ doubles: context.doubles + Stubs.scan(body, Doubles.new(@source, :body)) }
108
+ end
109
+
110
+ # A bare `it` inside a group is a pending example; Ruby reads it as the block's `it` parameter.
111
+ def bare_example(node, context)
112
+ add(context, name: name(context, nil, node), definition: node.location, extent: node.location,
113
+ status: :pending, assertions: [], helper_calls: [], bindings: context.bindings,
114
+ doubles: context.doubles)
115
+ end
116
+
117
+ def add(context, definition:, extent:, **fields)
118
+ @tests << TestCase.new(span: span(definition), extent: span(extent), framework: :rspec, setup: context.setup,
119
+ **fields)
120
+ end
121
+
122
+ def name(context, description, node)
123
+ [*context.names, description || "example at line #{node.location.start_line}"].join(' ')
124
+ end
125
+
126
+ def span(location) = @source.span(location)
127
+
128
+ def inventory(context)
129
+ Inventory.new(source: @source, helpers: @helpers, assertion: Expectations.method(:fields),
130
+ ignored: ->(name) { Expectations::VOCABULARY.include?(name) || context.bindings.include?(name) })
131
+ end
132
+
133
+ # Decides whether an example is active, pending or skipped.
134
+ module Status
135
+ module_function
136
+
137
+ def of(call, context)
138
+ return context.marked if context.marked
139
+ return :skipped if SKIPPED_EXAMPLES.include?(call.name)
140
+
141
+ MARKERS[call.name] || metadata(call) || (call.block ? body(call.block.body) || :active : :pending)
142
+ end
143
+
144
+ # A skipped or pending group marks every example inside it the same way.
145
+ def group(call) = SKIPPED_GROUPS.include?(call.name) ? :skipped : metadata(call)
146
+
147
+ # `:skip`, `:pending`, `skip: ...` or `pending: ...` metadata; a false or nil value does not count.
148
+ def metadata(call)
149
+ (call.arguments&.arguments || []).each do |argument|
150
+ status = case argument
151
+ when Prism::SymbolNode then MARKERS[argument.unescaped.to_sym]
152
+ when Prism::KeywordHashNode then keyword(argument)
153
+ end
154
+ return status if status
155
+ end
156
+ nil
157
+ end
158
+
159
+ def keyword(hash)
160
+ hash.elements.each do |element|
161
+ next unless element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode)
162
+ next if element.value.is_a?(Prism::FalseNode) || element.value.is_a?(Prism::NilNode)
163
+
164
+ status = MARKERS[element.key.unescaped.to_sym]
165
+ return status if status
166
+ end
167
+ nil
168
+ end
169
+
170
+ # A `skip` or `pending` call anywhere in the body marks the example.
171
+ def body(node)
172
+ node&.compact_child_nodes&.each do |child|
173
+ next if child.is_a?(Prism::DefNode)
174
+
175
+ status = marker(child) || body(child)
176
+ return status if status
177
+ end
178
+ nil
179
+ end
180
+
181
+ def marker(node) = node.is_a?(Prism::CallNode) && node.receiver.nil? ? MARKERS[node.name] : nil
182
+ end
183
+
184
+ # What a group's setup gives its examples: bindings from `let`/`subject` and doubles.
185
+ module Scope
186
+ module_function
187
+
188
+ # `let(:tax) { ... }` binds `tax`; a named subject also binds `subject`.
189
+ def bindings(call)
190
+ return {} unless LETS.key?(call.name)
191
+
192
+ value = Syntax.block_value(call)
193
+ name = Syntax.binding_name(call)
194
+ call.name.start_with?('subject') ? { name => value, subject: value } : { name => value }
195
+ end
196
+
197
+ # Doubles from a `let`/`subject` value, and stubs in `before` hooks; teardown hooks run
198
+ # too late to matter.
199
+ def doubles(call, doubles)
200
+ if LETS.key?(call.name)
201
+ Stubs.attributes(Syntax.block_value(call), Syntax.binding_name(call), doubles) || []
202
+ elsif HOOKS[call.name] == :before
203
+ Stubs.scan(call.block&.body, doubles)
204
+ else
205
+ []
206
+ end
207
+ end
208
+
209
+ # `is_expected.to eq(x)` checks the value `subject` is bound to.
210
+ def read_subject(assertion, bindings)
211
+ reader = assertion.subject
212
+ return assertion unless reader.is_a?(Prism::CallNode) && reader.receiver.nil? &&
213
+ SUBJECT_READERS.include?(reader.name)
214
+
215
+ assertion.with(subject: bindings[:subject])
216
+ end
217
+ end
218
+
219
+ # Finds RSpec stubs (`allow(r).to receive(:m).and_return(v)`, `receive_messages`), message
220
+ # expectations that also return a value, and doubles created with preset attributes.
221
+ module Stubs
222
+ # Stub target => whether it is a stub source. `expect(...).to receive` can fail on its own.
223
+ TARGETS = { allow: true, allow_any_instance_of: true, expect: false, expect_any_instance_of: false }.freeze
224
+ DOUBLES = %i[double instance_double class_double object_double spy instance_spy class_spy object_spy].freeze
225
+
226
+ module_function
227
+
228
+ # The TestDoubles in +node+, in source order. +doubles+ is a Frameworks::Doubles.
229
+ def scan(node, doubles, found = [])
230
+ node&.compact_child_nodes&.each do |child|
231
+ next if child.is_a?(Prism::DefNode)
232
+
233
+ matched = stub(child, doubles) || local_double(child, doubles)
234
+ matched ? found.concat(matched) : scan(child, doubles, found)
235
+ end
236
+ found
237
+ end
238
+
239
+ # `allow(r).to receive(:m).and_return(v)`: the matcher chain is inside-out, so the
240
+ # innermost call names the method and an `and_return` around it gives the value.
241
+ def stub(node, doubles)
242
+ target = target(node) or return
243
+ receiver = Syntax.arguments(target).first or return
244
+
245
+ matcher = Syntax.chain(Syntax.arguments(node).first)
246
+ stubbed(matcher, node.location, doubles, receiver:, stub_source: TARGETS[target.name])
247
+ end
248
+
249
+ # The `allow(r)` or `expect(r)` a `.to` call is made on, or nil.
250
+ def target(node)
251
+ target = node.receiver if node.is_a?(Prism::CallNode) && node.name == :to
252
+ target if target.is_a?(Prism::CallNode) && target.receiver.nil? && TARGETS.key?(target.name)
253
+ end
254
+
255
+ def stubbed(chain, location, doubles, **fields)
256
+ case chain.last&.name
257
+ when :receive
258
+ name = Syntax.stubbed_name(chain.last) or return
259
+ [doubles.build(location, method_name: name, returns: returned(chain), **fields)]
260
+ when :receive_messages
261
+ Syntax.keyword_pairs(chain.last).map do |name, value|
262
+ doubles.build(location, method_name: name, returns: value, **fields)
263
+ end
264
+ end
265
+ end
266
+
267
+ # The single value given to `and_return`; several values are a sequence, not one value.
268
+ def returned(chain)
269
+ and_return = chain.find { it.name == :and_return } or return
270
+ values = Syntax.arguments(and_return)
271
+ values.first if values.size == 1
272
+ end
273
+
274
+ # `tax = double(rate: 0.2)`
275
+ def local_double(node, doubles)
276
+ attributes(node.value, node.name, doubles) if node.is_a?(Prism::LocalVariableWriteNode)
277
+ end
278
+
279
+ # The preset attributes of a double bound to +name+, one TestDouble each; nil when +value+
280
+ # does not create a double.
281
+ def attributes(value, name, doubles)
282
+ return unless value.is_a?(Prism::CallNode) && value.receiver.nil? && DOUBLES.include?(value.name)
283
+
284
+ receiver = Syntax.identifier(name)
285
+ Syntax.keyword_pairs(value).map do |method_name, returns|
286
+ doubles.build(value.location, receiver:, method_name:, returns:)
287
+ end
288
+ end
289
+ end
290
+
291
+ # Recognises RSpec expectations and classifies their matchers.
292
+ module Expectations
293
+ TARGETS = %i[expect is_expected are_expected expect_any_instance_of].freeze
294
+ RUNNERS = %i[to not_to to_not].freeze
295
+ SHOULD = %i[should should_not].freeze
296
+ COMPARISONS = { eq: :equality, eql: :equality, equal: :identity }.freeze
297
+ RAISE_CHECKS = %i[raise_error raise_exception].freeze
298
+ MESSAGE_EXPECTATIONS = %i[receive receive_messages receive_message_chain have_received].freeze
299
+ # Minitest assertions that rspec-rails mixes into examples, and Capybara's
300
+ # `page.assert_selector`-style matchers, with or without a receiver.
301
+ MINITEST = /\A(assert|refute)(_|\z)/
302
+
303
+ # Receiverless calls that are RSpec or Ruby vocabulary, never assertion helpers.
304
+ VOCABULARY = Set[
305
+ *GROUPS, *SKIPPED_GROUPS, *EXAMPLES, *SKIPPED_EXAMPLES, *MARKERS.keys, *SETUP.keys, *TARGETS,
306
+ *MESSAGE_EXPECTATIONS, :allow, :allow_any_instance_of, :double, :instance_double, :class_double,
307
+ :object_double, :spy, :instance_spy, :class_spy, :object_spy, :described_class, :aggregate_failures,
308
+ :anything, :any_args, :no_args, :hash_including, :array_including, :instance_of, :kind_of,
309
+ :include_examples, :include_context, :it_behaves_like, :it_should_behave_like,
310
+ :puts, :p, :pp, :print, :raise, :fail, :lambda, :proc, :loop, :sleep, :require, :require_relative, :format
311
+ ].freeze
312
+
313
+ module_function
314
+
315
+ # Assertion fields for an expectation, or nil when +call+ is not one.
316
+ def fields(call)
317
+ if runner?(call)
318
+ matcher(argument(call), subject(call.receiver), negated: call.name != :to)
319
+ elsif SHOULD.include?(call.name) then should(call)
320
+ elsif should_operator?(call)
321
+ { kind: :comparison, comparison: :equality, subject: call.receiver.receiver, expected: argument(call) }
322
+ elsif MINITEST.match?(call.name) then minitest(call)
323
+ end
324
+ end
325
+
326
+ # Receiverless `assert_equal expected, actual` reads as in Minitest; receiver forms
327
+ # (`page.assert_selector`) are matchers that check nothing comparable.
328
+ def minitest(call)
329
+ return { kind: :other, comparison: nil, subject: nil, expected: nil } if call.receiver
330
+
331
+ Frameworks::Minitest::Assertions.assertion(call)
332
+ end
333
+
334
+ # `x.should eq(1)`; a bare `should` stands in for the subject until the normalizer resolves it.
335
+ def should(call) = matcher(argument(call), call.receiver || call, negated: call.name == :should_not)
336
+
337
+ # `expect(x).to`, `is_expected.not_to`, `expect { }.to`
338
+ def runner?(call)
339
+ target = call.receiver
340
+ RUNNERS.include?(call.name) && target.is_a?(Prism::CallNode) && target.receiver.nil? &&
341
+ TARGETS.include?(target.name)
342
+ end
343
+
344
+ # `expect(x)` checks x. `is_expected` stands in for the subject until the normalizer
345
+ # resolves it; block expectations have no subject expression.
346
+ def subject(target)
347
+ case target.name
348
+ when :expect then argument(target)
349
+ when :is_expected then target
350
+ end
351
+ end
352
+
353
+ # `x.should == 1`
354
+ def should_operator?(call)
355
+ call.name == :== && call.receiver.is_a?(Prism::CallNode) && call.receiver.name == :should
356
+ end
357
+
358
+ def argument(call) = call.arguments&.arguments&.first
359
+
360
+ # Matchers chain inside-out (`receive(:m).with(1)`): the innermost call names the matcher.
361
+ def matcher(node, subject, negated:)
362
+ node = node.receiver while node.is_a?(Prism::CallNode) && node.receiver.is_a?(Prism::CallNode)
363
+ kind, comparison = classify(node.is_a?(Prism::CallNode) ? node : nil)
364
+ comparison = nil if negated
365
+ { kind:, comparison:, subject:, expected: comparison && argument(node) }
366
+ end
367
+
368
+ def classify(matcher)
369
+ name = matcher&.name
370
+ return [:comparison, COMPARISONS[name]] if COMPARISONS.key?(name)
371
+ return matcher.arguments ? %i[comparison identity] : [:predicate, nil] if name == :be
372
+
373
+ [kind(name), nil]
374
+ end
375
+
376
+ def kind(name)
377
+ if RAISE_CHECKS.include?(name) then :raise_check
378
+ elsif MESSAGE_EXPECTATIONS.include?(name) then :message_expectation
379
+ elsif name&.start_with?('be_', 'have_') then :predicate
380
+ else :other
381
+ end
382
+ end
383
+ end
384
+ end
385
+ end
386
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Scully
4
+ # A region of a source file. Lines and columns are 1-based; columns count characters.
5
+ # The end column is exclusive, so a zero-length span has start_column == end_column.
6
+ Span = Data.define(:path, :start_line, :start_column, :end_line, :end_column) do
7
+ def sort_key = [path.b, start_line, start_column]
8
+ end
9
+
10
+ # A span with the text printed next to its marker.
11
+ Label = Data.define(:span, :text)
12
+
13
+ # A setup or teardown step from an enclosing group: :before, :setup, :let, :subject, :teardown.
14
+ SetupStep = Data.define(:kind, :span)
15
+
16
+ # An assertion found in a test body. +subject+ and +expected+ are opaque expressions.
17
+ Assertion = Data.define(:span, :kind, :comparison, :subject, :expected)
18
+
19
+ # A stub, double, or message expectation.
20
+ TestDouble = Data.define(:span, :receiver, :method_name, :returns, :origin, :scope, :stub_source)
21
+
22
+ # A call in a test body that may be an assertion helper.
23
+ HelperCall = Data.define(:name, :span, :resolution)
24
+
25
+ # One test, normalized so that rules never depend on the framework. +span+ is the definition
26
+ # (`it "..."`, `def test_x`) that findings point at; +extent+ is the whole test, through its `end`.
27
+ TestCase = Data.define(
28
+ :name, :span, :extent, :framework, :status, :setup, :assertions, :doubles, :bindings, :helper_calls
29
+ )
30
+
31
+ # Severities a rule may declare, in report order.
32
+ SEVERITIES = %i[error warning].freeze
33
+
34
+ # A problem reported by a rule.
35
+ Finding = Data.define(:rule_id, :severity, :primary, :secondary, :test_name, :facts, :evidence) do
36
+ def sort_key = [SEVERITIES.index(severity), *primary.span.sort_key, rule_id.to_s]
37
+ end
38
+
39
+ # A syntax error reported by the parser.
40
+ ParseError = Data.define(:span, :message)
41
+
42
+ # The analysis of one file: status is :complete, :incomplete or :skipped.
43
+ FileReport = Data.define(:path, :framework, :status, :skip_reason, :parse_errors, :test_cases)
44
+
45
+ # The result of a run. +counts+ has the keys
46
+ # :files, :incomplete, :skipped, :tests, :not_analyzed, :errors, :warnings.
47
+ RunReport = Data.define(:files, :findings, :counts, :exit_status)
48
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Scully
4
+ module Render
5
+ # Renders a RunReport as the plain-text report printed by `scully [PATH ...]`.
6
+ class Report
7
+ GROUPS = { error: 'errors', warning: 'warnings' }.freeze
8
+ EVIDENCE = 'evidence: static analysis only; coverage and mutation testing not run'
9
+ FOLLOW_UP = 'Use `scully why <location>` for more info about each finding:'
10
+ COMMENT_GAP = ' '
11
+
12
+ # +messages+ maps rule ids to their header messages; +sources+ maps paths to objects
13
+ # that respond to line(number); +terminal+ adds colour when it is on.
14
+ def self.render(report, messages:, sources:, terminal: Terminal::PLAIN)
15
+ new(report, messages, sources, terminal).render
16
+ end
17
+
18
+ # One `scully why <path>:<line> # <rule_id>` line per finding, commands padded to the longest.
19
+ def self.commands(findings)
20
+ commands = findings.map do
21
+ span = it.primary.span
22
+ ["scully why #{span.path}:#{span.start_line}", it.rule_id]
23
+ end
24
+ width = commands.map { it.first.size }.max
25
+ commands.map { |command, rule_id| "#{command.ljust(width)}#{COMMENT_GAP}# #{rule_id}" }
26
+ end
27
+
28
+ # `skipped: <path>: <reason>` for a skipped FileReport, ending in a newline.
29
+ def self.skip_line(file) = "skipped: #{file.path}: #{file.skip_reason}\n"
30
+
31
+ def initialize(report, messages, sources, terminal)
32
+ @report = report
33
+ @terminal = terminal
34
+ @messages = messages
35
+ @sources = sources
36
+ end
37
+
38
+ def render
39
+ sections = [*finding_groups, skipped_group].compact
40
+ text = sections.map { "#{it}\n" }.join
41
+ text << summary << "\n" << EVIDENCE << "\n"
42
+ text << follow_up unless @report.findings.empty?
43
+ text
44
+ end
45
+
46
+ private
47
+
48
+ def finding_groups
49
+ GROUPS.filter_map do |severity, title|
50
+ findings = @report.findings.select { it.severity == severity }
51
+ next if findings.empty?
52
+
53
+ "#{title} (#{findings.size})\n\n#{findings.map { block(it) }.join("\n")}"
54
+ end
55
+ end
56
+
57
+ def block(finding)
58
+ Snippet.render(finding, message: @messages.fetch(finding.rule_id),
59
+ source: @sources.fetch(finding.primary.span.path), terminal: @terminal)
60
+ end
61
+
62
+ def skipped_group
63
+ skipped = @report.files.select { it.status == :skipped }
64
+ return if skipped.empty?
65
+
66
+ "skipped (#{skipped.size})\n\n#{skipped.map { self.class.skip_line(it) }.join}"
67
+ end
68
+
69
+ def summary
70
+ counts = @report.counts
71
+ not_analyzed = counts[:not_analyzed].zero? ? '' : " (#{counts[:not_analyzed]} not analyzed)"
72
+ "summary: #{counts[:files]} files (#{counts[:incomplete]} incomplete, #{counts[:skipped]} skipped), " \
73
+ "#{counts[:tests]} tests#{not_analyzed}, #{counts[:errors]} errors, #{counts[:warnings]} warnings"
74
+ end
75
+
76
+ def follow_up = "\n#{FOLLOW_UP}\n\n#{self.class.commands(@report.findings).map { "#{it}\n" }.join}"
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Scully
4
+ module Render
5
+ # Renders a finding as a block: header, location, and the source lines its labels point at.
6
+ #
7
+ # error[tautology]: assertion checks a value the test stubbed
8
+ # --> spec/tautology_spec.rb:7:5
9
+ # |
10
+ # 2 | let(:tax) { double(rate: 0.2) }
11
+ # | ----------------- stubbed in setup to return `0.2`
12
+ # ...
13
+ # 7 | expect(tax.rate).to eq(0.2)
14
+ # | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ compares the stub with its own return value
15
+ class Snippet
16
+ TAB = ' '
17
+ PRIMARY = '^'
18
+ SECONDARY = '-'
19
+ INCONCLUSIVE = ' (incomplete file: inconclusive)'
20
+
21
+ # +source+ responds to line(number) and returns the line's text without its terminator.
22
+ # +terminal+ colours the severity word and markers when colour is on.
23
+ # Returns the block as a String ending in a newline.
24
+ def self.render(finding, message:, source:, terminal: Terminal::PLAIN)
25
+ new(finding, source, terminal).render(message)
26
+ end
27
+
28
+ def initialize(finding, source, terminal)
29
+ @finding = finding
30
+ @terminal = terminal
31
+ @source = source
32
+ @labels = [[finding.primary, PRIMARY, finding.severity], *finding.secondary.map { [it, SECONDARY, :secondary] }]
33
+ @numbers = @labels.map { it.first.span.start_line }.uniq.sort
34
+ @gutter = ' ' * @numbers.max.to_s.size
35
+ end
36
+
37
+ def render(message)
38
+ lines = [header(message), location, "#{@gutter} |", *body]
39
+ "#{lines.map(&:rstrip).join("\n")}\n"
40
+ end
41
+
42
+ private
43
+
44
+ def header(message) = "#{@terminal.paint(@finding.severity, @finding.severity)}[#{@finding.rule_id}]: #{message}"
45
+
46
+ def location
47
+ span = @finding.primary.span
48
+ suffix = INCONCLUSIVE if @finding.evidence == :inconclusive
49
+ "#{@gutter}--> #{span.path}:#{span.start_line}:#{span.start_column}#{suffix}"
50
+ end
51
+
52
+ def body
53
+ @numbers.each_with_index.flat_map do |number, index|
54
+ [*(index.positive? ? gap(@numbers[index - 1], number) : []), numbered(number), *markers(number)]
55
+ end
56
+ end
57
+
58
+ # One skipped line is shown as source; more than one collapses to `...`.
59
+ def gap(previous, number)
60
+ case number - previous - 1
61
+ when 0 then []
62
+ when 1 then [numbered(previous + 1)]
63
+ else ['...']
64
+ end
65
+ end
66
+
67
+ def numbered(number) = "#{number.to_s.rjust(@gutter.size)} | #{expand(@source.line(number))}"
68
+
69
+ def markers(number)
70
+ @labels.select { it.first.span.start_line == number }
71
+ .sort_by { |label, *| label.span.start_column }
72
+ .map { |label, marker, style| marker_line(label, marker, style) }
73
+ end
74
+
75
+ def marker_line(label, marker, style)
76
+ start, width = columns(label.span)
77
+ "#{@gutter} | #{" " * start}#{@terminal.paint(marker * width, style)} #{label.text}"
78
+ end
79
+
80
+ # Display start and marker width. Multi-line spans run to the end of their first line;
81
+ # zero-length spans get one marker.
82
+ def columns(span)
83
+ text = @source.line(span.start_line)
84
+ start = expand(text[0, span.start_column - 1]).size
85
+ finish = expand(span.end_line > span.start_line ? text : text[0, span.end_column - 1]).size
86
+ [start, [finish - start, 1].max]
87
+ end
88
+
89
+ def expand(text) = text.to_s.gsub("\t", TAB)
90
+ end
91
+ end
92
+ end