undercover 0.8.5 → 0.9.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.
- checksums.yaml +4 -4
- data/.github/FUNDING.yml +15 -0
- data/.github/workflows/ruby.yml +2 -2
- data/.rubocop.yml +1 -1
- data/CHANGELOG.md +20 -1
- data/Gemfile +3 -1
- data/README.md +92 -9
- data/bin/undercover +1 -9
- data/lib/undercover/ast_branch_annotator.rb +44 -32
- data/lib/undercover/changeset.rb +29 -5
- data/lib/undercover/cli.rb +96 -17
- data/lib/undercover/coverage_index.rb +19 -0
- data/lib/undercover/coverage_root.rb +73 -0
- data/lib/undercover/erb_extractor.rb +32 -0
- data/lib/undercover/erb_node.rb +20 -0
- data/lib/undercover/erb_visitor.rb +42 -0
- data/lib/undercover/file_loader.rb +51 -0
- data/lib/undercover/filter_set.rb +34 -9
- data/lib/undercover/json_formatter.rb +1 -1
- data/lib/undercover/lcov_parser.rb +35 -16
- data/lib/undercover/node.rb +78 -0
- data/lib/undercover/node_visitor.rb +86 -0
- data/lib/undercover/options.rb +38 -17
- data/lib/undercover/result.rb +33 -23
- data/lib/undercover/simplecov_formatter.rb +50 -19
- data/lib/undercover/simplecov_result_adapter.rb +42 -11
- data/lib/undercover/version.rb +1 -1
- data/lib/undercover.rb +72 -35
- data/undercover.gemspec +7 -5
- metadata +29 -15
- data/lib/undercover/root_to_relative_paths.rb +0 -24
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
|
|
5
|
+
module Undercover
|
|
6
|
+
# Coverage reports record paths relative to SimpleCov.root, while a changeset records
|
|
7
|
+
# them relative to the git repository root. In a monorepo the two differ by a directory
|
|
8
|
+
# prefix, e.g. coverage says "main.rb" where git says "apps/dash/main.rb".
|
|
9
|
+
#
|
|
10
|
+
# SimpleCov.root is stored in the report, but as an absolute path from whichever machine
|
|
11
|
+
# ran the tests, which need not exist where undercover runs. Its tail still names the
|
|
12
|
+
# directory though, so match the longest tail that exists in the repository.
|
|
13
|
+
module CoverageRoot
|
|
14
|
+
NONE = ''
|
|
15
|
+
|
|
16
|
+
# Coverage keys checked when confirming a candidate. A handful is enough to tell a
|
|
17
|
+
# real match from a directory that merely shares a name.
|
|
18
|
+
SAMPLE_SIZE = 20
|
|
19
|
+
|
|
20
|
+
# @param git_root [String] absolute path to the repository working directory
|
|
21
|
+
# @param simplecov_root [String, nil] SimpleCov.root as recorded in the report
|
|
22
|
+
# @param coverage_keys [Array<String>] paths relative to SimpleCov.root, used to
|
|
23
|
+
# confirm a candidate actually holds the covered files
|
|
24
|
+
# @param exists [#call, nil] whether a path relative to git_root exists in the
|
|
25
|
+
# repository; checks the disk by default
|
|
26
|
+
# @return [String] prefix that joins a coverage key onto a repository path,
|
|
27
|
+
# NONE when coverage was recorded at the repository root or cannot be placed
|
|
28
|
+
def self.derive(git_root, simplecov_root, coverage_keys = nil, exists: nil)
|
|
29
|
+
return NONE if git_root.nil? || simplecov_root.nil? || simplecov_root.empty?
|
|
30
|
+
|
|
31
|
+
exists ||= ->(path) { File.exist?(File.join(git_root, path)) }
|
|
32
|
+
candidates(simplecov_root, exists).find do |prefix|
|
|
33
|
+
holds_covered_files?(prefix, coverage_keys, exists)
|
|
34
|
+
end || NONE
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [String] filepath relative to the coverage root
|
|
38
|
+
def self.strip(filepath, root)
|
|
39
|
+
return filepath if root.nil? || root.empty?
|
|
40
|
+
|
|
41
|
+
filepath.delete_prefix("#{root}/")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Boolean] whether a repository path lives under the coverage root
|
|
45
|
+
def self.under?(filepath, root)
|
|
46
|
+
return true if root.nil? || root.empty?
|
|
47
|
+
|
|
48
|
+
filepath.start_with?("#{root}/")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Tails of SimpleCov.root that exist in the repository, longest first. "/build/x/app"
|
|
52
|
+
# offers "app", then "x/app", then "build/x/app"; the longest that exists wins, so a
|
|
53
|
+
# nested app beats a directory that happens to share its leaf name.
|
|
54
|
+
def self.candidates(simplecov_root, exists)
|
|
55
|
+
parts = Pathname.new(simplecov_root).each_filename.to_a
|
|
56
|
+
(1..parts.length)
|
|
57
|
+
.map { |depth| parts.last(depth).join('/') }
|
|
58
|
+
.select { |prefix| exists.call(prefix) }
|
|
59
|
+
.sort_by { |prefix| -prefix.length }
|
|
60
|
+
end
|
|
61
|
+
private_class_method :candidates
|
|
62
|
+
|
|
63
|
+
# Guards against a coincidence: the repository may contain a directory named like
|
|
64
|
+
# SimpleCov.root's leaf without being the place the coverage came from.
|
|
65
|
+
def self.holds_covered_files?(prefix, coverage_keys, exists)
|
|
66
|
+
keys = Array(coverage_keys).reject { |key| key.nil? || key.empty? }
|
|
67
|
+
return true if keys.empty?
|
|
68
|
+
|
|
69
|
+
keys.first(SAMPLE_SIZE).any? { |key| exists.call(File.join(prefix, key)) }
|
|
70
|
+
end
|
|
71
|
+
private_class_method :holds_covered_files?
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'erubi'
|
|
4
|
+
|
|
5
|
+
module Undercover
|
|
6
|
+
# Extracts a Ruby string from an ERB template, preserving line positions so
|
|
7
|
+
# Prism produces a syntax tree whose line numbers map 1:1 back to the
|
|
8
|
+
# original .erb file.
|
|
9
|
+
#
|
|
10
|
+
# Uses erubi (pure Ruby, no native extension).
|
|
11
|
+
module ErbExtractor
|
|
12
|
+
# Mirrors ActionView's Erubi handler: `<%= helper do |x| %>` opens a block,
|
|
13
|
+
# so it must not be wrapped in `( ... ).to_s` like other expressions.
|
|
14
|
+
class Engine < ::Erubi::Engine
|
|
15
|
+
BLOCK_EXPR = /((\s|\))do|\{)(\s*\|[^|]*\|)?\s*\Z/
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def add_expression(indicator, code)
|
|
20
|
+
return super unless BLOCK_EXPR.match?(code)
|
|
21
|
+
|
|
22
|
+
src << " #{bufvar}.append= " << code
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
def call(erb_source)
|
|
29
|
+
Engine.new(erb_source, trim: true).src
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Undercover
|
|
4
|
+
# A Ruby construct parsed from an ERB template (if, unless, while, until, case, block)
|
|
5
|
+
class ErbNode < Node
|
|
6
|
+
KINDS = %i[block if unless while until case].freeze
|
|
7
|
+
|
|
8
|
+
# ErbExtractor keeps the template's line numbers, so nodes print the template's own lines.
|
|
9
|
+
# partial_script allows a top-level yield, as in a layout.
|
|
10
|
+
def self.parse(template, file_path)
|
|
11
|
+
Node.parse(ErbExtractor.call(template), file_path, visitor_class: ErbVisitor,
|
|
12
|
+
source_lines: template.lines.map(&:chomp),
|
|
13
|
+
partial_script: true)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def human_name
|
|
17
|
+
"#{kind} block"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Undercover
|
|
4
|
+
# Also reports the control flow of ERB templates, where whole sections of markup sit in
|
|
5
|
+
# an if or a loop. An if is one node with its elsif branches.
|
|
6
|
+
class ErbVisitor < NodeVisitor
|
|
7
|
+
def visit_if_node(node)
|
|
8
|
+
add(:if, 'if', node, node.statements) { visit_if_branches(node) }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def visit_unless_node(node)
|
|
12
|
+
add(:unless, 'unless', node, node.statements) { super }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def visit_while_node(node)
|
|
16
|
+
add(:while, 'while', node, node.statements) { super }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def visit_until_node(node)
|
|
20
|
+
add(:until, 'until', node, node.statements) { super }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def visit_case_node(node)
|
|
24
|
+
add(:case, 'case', node, node.conditions) { super }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def visit_if_branches(node)
|
|
30
|
+
node.predicate.accept(self)
|
|
31
|
+
node.statements&.accept(self)
|
|
32
|
+
branch = node.subsequent
|
|
33
|
+
branch.is_a?(Prism::IfNode) ? visit_if_branches(branch) : branch&.accept(self)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def build(kind, name, prism_node, empty:)
|
|
37
|
+
return super unless ErbNode::KINDS.include?(kind)
|
|
38
|
+
|
|
39
|
+
ErbNode.new(kind, name, prism_node, @file_path, @source_lines, empty:)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Undercover
|
|
4
|
+
# Parses a changed file into Undercover::Result nodes. ERB templates go through
|
|
5
|
+
# ErbExtractor first, which turns a template into Ruby with line positions preserved
|
|
6
|
+
# so the parser can place nodes back onto the original template's line numbers.
|
|
7
|
+
#
|
|
8
|
+
# Expects the host to provide #code_dir, #coverage_adapter, #loaded_files and #erb_parse_failures.
|
|
9
|
+
module FileLoader
|
|
10
|
+
ERB_EXTENSION = '.erb'
|
|
11
|
+
|
|
12
|
+
def load_and_parse_file(filepath)
|
|
13
|
+
key = filepath.gsub(/^\.\//, '')
|
|
14
|
+
return if loaded_files.key?(key)
|
|
15
|
+
|
|
16
|
+
loaded_files[key] = nil
|
|
17
|
+
nodes = filepath.end_with?(ERB_EXTENSION) ? load_erb_file(key, filepath) : load_ruby_file(key, filepath)
|
|
18
|
+
loaded_files[key] = build_results(nodes, filepath) if nodes&.any?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def load_erb_file(key, filepath)
|
|
24
|
+
return unless coverage_adapter.erb_coverage?
|
|
25
|
+
|
|
26
|
+
full_path = File.join(code_dir, filepath)
|
|
27
|
+
return unless File.exist?(full_path)
|
|
28
|
+
|
|
29
|
+
all_nodes(ErbNode.parse(File.read(full_path), full_path))
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
erb_parse_failures << key
|
|
32
|
+
warn "#{key}: ERB parsing failed (#{e.message})"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def load_ruby_file(key, filepath)
|
|
36
|
+
all_nodes(Node.parse_file(File.join(code_dir, filepath)))
|
|
37
|
+
rescue Node::ParseError => e
|
|
38
|
+
warn "#{key}: Ruby parsing failed (#{e.message})"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def all_nodes(roots)
|
|
42
|
+
roots.flat_map { _1.find_all(->(_node) { true }) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Indexes the file's coverage once for all of its nodes
|
|
46
|
+
def build_results(nodes, filepath)
|
|
47
|
+
index = CoverageIndex.new(coverage_adapter.coverage(filepath))
|
|
48
|
+
nodes.map { |node| Result.new(node, coverage_adapter, filepath, coverage_index: index) }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -2,40 +2,65 @@
|
|
|
2
2
|
|
|
3
3
|
module Undercover
|
|
4
4
|
class FilterSet
|
|
5
|
-
attr_reader :allow_filters, :reject_filters, :simplecov_filters
|
|
5
|
+
attr_reader :allow_filters, :reject_filters, :simplecov_filters, :coverage_root
|
|
6
6
|
|
|
7
|
-
def initialize(allow_filters, reject_filters, simplecov_filters)
|
|
7
|
+
def initialize(allow_filters, reject_filters, simplecov_filters, coverage_root: CoverageRoot::NONE)
|
|
8
8
|
@allow_filters = allow_filters || []
|
|
9
9
|
@reject_filters = reject_filters || []
|
|
10
10
|
@simplecov_filters = simplecov_filters || []
|
|
11
|
+
@coverage_root = coverage_root || CoverageRoot::NONE
|
|
11
12
|
end
|
|
12
13
|
|
|
14
|
+
# @param filepath[String] path relative to the git repository root
|
|
13
15
|
def include?(filepath)
|
|
14
|
-
|
|
16
|
+
# Changes outside the coverage root belong to a different project in the same
|
|
17
|
+
# repository and have no coverage data to judge them by.
|
|
18
|
+
return false unless CoverageRoot.under?(filepath, coverage_root)
|
|
19
|
+
|
|
20
|
+
# Every filter below is written relative to SimpleCov.root, so compare there.
|
|
21
|
+
relative = CoverageRoot.strip(filepath, coverage_root)
|
|
15
22
|
|
|
16
23
|
# Check if file was ignored by SimpleCov filters
|
|
17
|
-
return false if ignored_by_simplecov?(
|
|
24
|
+
return false if ignored_by_simplecov?(relative)
|
|
18
25
|
|
|
19
26
|
# Apply Undercover's own filters
|
|
20
|
-
|
|
27
|
+
matches_globs?(relative)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# A file undercover would otherwise have reported on, dropped only by --include-files
|
|
31
|
+
# or --exclude-files. Used to tell a misconfigured glob apart from a clean run.
|
|
32
|
+
# @param filepath[String] path relative to the git repository root
|
|
33
|
+
def rejected_by_globs?(filepath)
|
|
34
|
+
return false unless CoverageRoot.under?(filepath, coverage_root)
|
|
35
|
+
|
|
36
|
+
relative = CoverageRoot.strip(filepath, coverage_root)
|
|
37
|
+
return false if ignored_by_simplecov?(relative)
|
|
38
|
+
|
|
39
|
+
!matches_globs?(relative)
|
|
21
40
|
end
|
|
22
41
|
|
|
23
42
|
private
|
|
24
43
|
|
|
44
|
+
def matches_globs?(relative)
|
|
45
|
+
fnmatch = proc { |glob| File.fnmatch(glob, relative, File::FNM_EXTGLOB) }
|
|
46
|
+
allow_filters.any?(fnmatch) && reject_filters.none?(fnmatch)
|
|
47
|
+
end
|
|
48
|
+
|
|
25
49
|
def ignored_by_simplecov?(filepath)
|
|
26
50
|
simplecov_filters.any? do |filter|
|
|
27
51
|
filter = filter.transform_keys(&:to_sym)
|
|
28
52
|
if filter[:string]
|
|
29
|
-
|
|
53
|
+
normalize_slash(filepath).include?(filter[:string])
|
|
30
54
|
elsif filter[:regex]
|
|
31
|
-
|
|
55
|
+
normalize_slash(filepath).match?(Regexp.new(filter[:regex]))
|
|
32
56
|
elsif filter[:file]
|
|
33
|
-
filepath == filter[:file]
|
|
57
|
+
filepath == filter[:file]
|
|
34
58
|
end
|
|
35
59
|
end
|
|
36
60
|
end
|
|
37
61
|
|
|
38
|
-
|
|
62
|
+
# SimpleCov's 'rails' profile adds regex filters that start with a slash by default. Let's be compatible.
|
|
63
|
+
def normalize_slash(filepath)
|
|
39
64
|
filepath.start_with?('/') ? filepath : "/#{filepath}"
|
|
40
65
|
end
|
|
41
66
|
end
|
|
@@ -63,7 +63,7 @@ module Undercover
|
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
def annotated_branches(result) # rubocop:disable Metrics/AbcSize
|
|
66
|
-
ast_info = AstBranchAnnotator.call(result.node.
|
|
66
|
+
ast_info = AstBranchAnnotator.call(result.node.prism_node)
|
|
67
67
|
counts_per_line = result.branches.each_with_object(Hash.new(0)) { |b, h| h[b[:line]] += 1 }
|
|
68
68
|
result.branches.map do |entry|
|
|
69
69
|
arm = result.branch_label(result.file_path, entry[:branch]) if counts_per_line[entry[:line]] > 1
|
|
@@ -1,25 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'undercover/
|
|
3
|
+
require 'undercover/coverage_root'
|
|
4
4
|
|
|
5
5
|
module Undercover
|
|
6
6
|
class LcovParseError < StandardError
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
class LcovParser
|
|
10
|
-
include RootToRelativePaths
|
|
11
|
-
|
|
12
10
|
attr_reader :io, :source_files
|
|
11
|
+
attr_accessor :coverage_root
|
|
13
12
|
|
|
14
|
-
def initialize(lcov_io,
|
|
13
|
+
def initialize(lcov_io, _opts = nil, only_files: nil)
|
|
15
14
|
@io = lcov_io
|
|
16
15
|
@source_files = {}
|
|
17
|
-
@
|
|
16
|
+
@only_files = only_files&.to_set
|
|
17
|
+
@coverage_root = CoverageRoot::NONE
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
def self.parse(lcov_report_path, opts = nil)
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
def self.parse(lcov_report_path, opts = nil, only_files: nil)
|
|
21
|
+
File.open(lcov_report_path) do |lcov_io|
|
|
22
|
+
return new(lcov_io, opts, only_files: only_files).parse
|
|
23
|
+
end
|
|
23
24
|
end
|
|
24
25
|
|
|
25
26
|
def parse
|
|
@@ -28,11 +29,24 @@ module Undercover
|
|
|
28
29
|
self
|
|
29
30
|
end
|
|
30
31
|
|
|
32
|
+
# @return Array paths relative to SimpleCov.root
|
|
33
|
+
def coverage_keys
|
|
34
|
+
source_files.keys
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return Boolean whether the report covers any ERB template, before only_files filtering
|
|
38
|
+
def erb_coverage?
|
|
39
|
+
@erb_coverage || false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# LCOV carries no SimpleCov.root, so coverage is always taken to sit at the repository
|
|
43
|
+
# root. Reports covering a subdirectory need the SimpleCov JSON format.
|
|
44
|
+
def simplecov_root
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
31
48
|
def coverage(filepath)
|
|
32
|
-
|
|
33
|
-
relative_path == fix_relative_filepath(filepath)
|
|
34
|
-
end
|
|
35
|
-
coverage || []
|
|
49
|
+
source_files[CoverageRoot.strip(filepath, coverage_root)] || []
|
|
36
50
|
end
|
|
37
51
|
|
|
38
52
|
def total_coverage
|
|
@@ -69,19 +83,25 @@ module Undercover
|
|
|
69
83
|
|
|
70
84
|
private
|
|
71
85
|
|
|
72
|
-
# rubocop:disable Metrics/MethodLength, Style/SpecialGlobalVars, Metrics/AbcSize
|
|
86
|
+
# rubocop:disable-next Metrics/MethodLength, Metrics/CyclomaticComplexity, Style/SpecialGlobalVars, Metrics/AbcSize
|
|
73
87
|
def parse_line(line)
|
|
74
88
|
case line
|
|
75
89
|
when /^SF:(.+)/
|
|
76
|
-
|
|
77
|
-
|
|
90
|
+
filename = $~[1].gsub(/^\.\//, '')
|
|
91
|
+
@erb_coverage ||= filename.end_with?('.erb')
|
|
92
|
+
@current_filename = @only_files.nil? || @only_files.include?(filename) ? filename : nil
|
|
93
|
+
source_files[@current_filename] = [] if @current_filename
|
|
78
94
|
when /^DA:(\d+),(\d+)/
|
|
95
|
+
return unless @current_filename
|
|
96
|
+
|
|
79
97
|
line_no = $~[1]
|
|
80
98
|
covered = $~[2]
|
|
81
99
|
source_files[@current_filename] << [line_no.to_i, covered.to_i]
|
|
82
100
|
when /^(BRF|BRH):(\d+)/
|
|
83
101
|
# branches found/hit; no-op
|
|
84
102
|
when /^BRDA:(\d+),(\d+),(\d+),(-|\d+)/
|
|
103
|
+
return unless @current_filename
|
|
104
|
+
|
|
85
105
|
line_no = $~[1]
|
|
86
106
|
block_no = $~[2]
|
|
87
107
|
branch_no = $~[3]
|
|
@@ -94,6 +114,5 @@ module Undercover
|
|
|
94
114
|
raise LcovParseError, "could not recognise '#{line}' as valid LCOV"
|
|
95
115
|
end
|
|
96
116
|
end
|
|
97
|
-
# rubocop:enable Metrics/MethodLength, Style/SpecialGlobalVars, Metrics/AbcSize
|
|
98
117
|
end
|
|
99
118
|
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'prism'
|
|
4
|
+
|
|
5
|
+
module Undercover
|
|
6
|
+
# A class, module, method or block parsed from a Ruby file, with the line range
|
|
7
|
+
# undercover checks coverage for. Blocks span their whole method call, receiver included.
|
|
8
|
+
class Node
|
|
9
|
+
class ParseError < StandardError; end
|
|
10
|
+
|
|
11
|
+
HUMAN_NAMES = {
|
|
12
|
+
class: 'class',
|
|
13
|
+
module: 'module',
|
|
14
|
+
instance_method: 'instance method',
|
|
15
|
+
class_method: 'class method',
|
|
16
|
+
block: 'block'
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :kind, :name, :prism_node, :file_path, :children
|
|
20
|
+
|
|
21
|
+
# @param source_lines [Array<String>] the lines nodes print, when not the parsed source's own
|
|
22
|
+
# @return [Array<Node>] top-level nodes, each with its nested nodes as children
|
|
23
|
+
def self.parse(source, file_path, visitor_class: NodeVisitor, source_lines: source.lines.map(&:chomp),
|
|
24
|
+
partial_script: false)
|
|
25
|
+
result = Prism.parse(source, filepath: file_path, partial_script:)
|
|
26
|
+
if result.failure?
|
|
27
|
+
error = result.errors.first
|
|
28
|
+
raise ParseError, "#{error.message} on line #{error.location.start_line}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
visitor_class.new(file_path, source_lines).tap { result.value.accept(_1) }.roots
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.parse_file(path)
|
|
35
|
+
parse(File.read(path), path)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(kind, name, prism_node, file_path, source_lines, empty:) # rubocop:disable Metrics/ParameterLists
|
|
39
|
+
@kind = kind
|
|
40
|
+
@name = name
|
|
41
|
+
@prism_node = prism_node
|
|
42
|
+
@file_path = file_path
|
|
43
|
+
@file_lines = source_lines
|
|
44
|
+
@empty = empty
|
|
45
|
+
@children = []
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def human_name
|
|
49
|
+
HUMAN_NAMES.fetch(kind)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def first_line
|
|
53
|
+
prism_node.location.start_line
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def last_line
|
|
57
|
+
prism_node.location.end_line
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def empty_def?
|
|
61
|
+
@empty
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def source_lines
|
|
65
|
+
@file_lines[(first_line - 1)..(last_line - 1)]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def source_lines_with_numbers
|
|
69
|
+
(first_line..last_line).zip(source_lines)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def find_all(matcher, ret = [])
|
|
73
|
+
ret << self if matcher.call(self)
|
|
74
|
+
children.each { |child| child.find_all(matcher, ret) }
|
|
75
|
+
ret
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'prism'
|
|
4
|
+
|
|
5
|
+
module Undercover
|
|
6
|
+
# Builds the Node tree of a Prism syntax tree
|
|
7
|
+
class NodeVisitor < Prism::Visitor
|
|
8
|
+
attr_reader :roots
|
|
9
|
+
|
|
10
|
+
def initialize(file_path, source_lines)
|
|
11
|
+
super()
|
|
12
|
+
@file_path = file_path
|
|
13
|
+
@source_lines = source_lines
|
|
14
|
+
@roots = []
|
|
15
|
+
@parents = []
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def visit_class_node(node)
|
|
19
|
+
add(:class, node.name.to_s, node, node.body) { super }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def visit_module_node(node)
|
|
23
|
+
add(:module, node.name.to_s, node, node.body) { super }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def visit_def_node(node)
|
|
27
|
+
add(node.receiver ? :class_method : :instance_method, node.name_loc.slice, node, node.body) { super }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def visit_call_node(node)
|
|
31
|
+
visit_block_call(node) { super }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def visit_super_node(node)
|
|
35
|
+
visit_block_call(node) { super }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def visit_forwarding_super_node(node)
|
|
39
|
+
visit_block_call(node) { super }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def visit_lambda_node(node)
|
|
43
|
+
add(:block, block_name(node.parameters), node, node.body) { super }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def visit_block_call(node, &)
|
|
49
|
+
block = node.block
|
|
50
|
+
return yield unless block.is_a?(Prism::BlockNode)
|
|
51
|
+
|
|
52
|
+
add(:block, block_name(block.parameters), node, block.body, &)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def add(kind, name, prism_node, body)
|
|
56
|
+
new_node = build(kind, name, prism_node, empty: body.nil?)
|
|
57
|
+
(@parents.last&.children || roots) << new_node
|
|
58
|
+
@parents.push(new_node)
|
|
59
|
+
yield
|
|
60
|
+
@parents.pop
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def build(kind, name, prism_node, empty:)
|
|
64
|
+
Node.new(kind, name, prism_node, @file_path, @source_lines, empty:)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Lists explicit parameters only: `it` and numbered parameter blocks are a plain "block"
|
|
68
|
+
def block_name(parameters)
|
|
69
|
+
names = parameters.is_a?(Prism::BlockParametersNode) ? parameter_names(parameters) : []
|
|
70
|
+
names.empty? ? 'block' : "block (#{names.join(', ')})"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def parameter_names(block_parameters)
|
|
74
|
+
[*ordered_parameters(block_parameters.parameters), *block_parameters.locals].map do |param|
|
|
75
|
+
param.is_a?(Prism::MultiTargetNode) ? param.slice : (param.name if param.respond_to?(:name))
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def ordered_parameters(params)
|
|
80
|
+
return [] unless params
|
|
81
|
+
|
|
82
|
+
[*params.requireds, *params.optionals, params.rest, *params.posts, *params.keywords, params.keyword_rest,
|
|
83
|
+
params.block].compact.grep_v(Prism::ImplicitRestNode)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|