Skip to content

Commit dc6e919

Browse files
authored
Merge pull request #933 from mbj/add/mutant-diff-ranges
Add Mutant::Diff::Ranges
2 parents feefd54 + 42cdb66 commit dc6e919

File tree

5 files changed

+301
-62
lines changed

5 files changed

+301
-62
lines changed

lib/mutant.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,8 @@ module Mutant
180180
require 'mutant/reporter/cli/printer/test_result'
181181
require 'mutant/reporter/cli/format'
182182
require 'mutant/repository'
183+
require 'mutant/repository/diff'
184+
require 'mutant/repository/diff/ranges'
183185
require 'mutant/variable'
184186
require 'mutant/zombifier'
185187
require 'mutant/range'

lib/mutant/repository.rb

Lines changed: 0 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -19,67 +19,5 @@ def call(subject)
1919
end
2020

2121
end # SubjectFilter
22-
23-
# Diff between two objects in repository
24-
class Diff
25-
include Adamantium, Anima.new(:world, :from, :to)
26-
27-
HEAD = 'HEAD'
28-
29-
# Test if diff changes file at line range
30-
#
31-
# @param [Pathname] path
32-
# @param [Range<Integer>] line_range
33-
#
34-
# @return [Boolean]
35-
#
36-
# @raise [RepositoryError]
37-
# when git command failed
38-
def touches?(path, line_range)
39-
return false unless within_working_directory?(path) && tracks?(path)
40-
41-
command = %W[
42-
git log
43-
#{from}...#{to}
44-
--ignore-all-space
45-
-L #{line_range.begin},#{line_range.end}:#{path}
46-
]
47-
48-
stdout, status = world.open3.capture2(*command, binmode: true)
49-
50-
fail RepositoryError, "Command #{command} failed!" unless status.success?
51-
52-
!stdout.empty?
53-
end
54-
55-
private
56-
57-
# Test if path is tracked in repository
58-
#
59-
# FIXME: Cache results, to avoid spending time on producing redundant results.
60-
#
61-
# @param [Pathname] path
62-
#
63-
# @return [Boolean]
64-
def tracks?(path)
65-
command = %W[git ls-files --error-unmatch -- #{path}]
66-
world.kernel.system(
67-
*command,
68-
out: File::NULL,
69-
err: File::NULL
70-
)
71-
end
72-
73-
# Test if the path is within the current working directory
74-
#
75-
# @param [Pathname] path
76-
#
77-
# @return [TrueClass, nil]
78-
def within_working_directory?(path)
79-
working_directory = world.pathname.pwd
80-
path.ascend { |parent| return true if working_directory.eql?(parent) }
81-
end
82-
83-
end # Diff
8422
end # Repository
8523
end # Mutant

lib/mutant/repository/diff.rb

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# frozen_string_literal: true
2+
3+
module Mutant
4+
module Repository
5+
# Diff between two objects in repository
6+
class Diff
7+
include Adamantium, Anima.new(:world, :from, :to)
8+
9+
HEAD = 'HEAD'
10+
11+
# Test if diff changes file at line range
12+
#
13+
# @param [Pathname] path
14+
# @param [Range<Integer>] line_range
15+
#
16+
# @return [Boolean]
17+
#
18+
# @raise [RepositoryError]
19+
# when git command failed
20+
def touches?(path, line_range)
21+
return false unless within_working_directory?(path) && tracks?(path)
22+
23+
command = %W[
24+
git log
25+
#{from}...#{to}
26+
--ignore-all-space
27+
-L #{line_range.begin},#{line_range.end}:#{path}
28+
]
29+
30+
stdout, status = world.open3.capture2(*command, binmode: true)
31+
32+
fail RepositoryError, "Command #{command} failed!" unless status.success?
33+
34+
!stdout.empty?
35+
end
36+
37+
private
38+
39+
# Test if path is tracked in repository
40+
#
41+
# FIXME: Cache results, to avoid spending time on producing redundant results.
42+
#
43+
# @param [Pathname] path
44+
#
45+
# @return [Boolean]
46+
def tracks?(path)
47+
command = %W[git ls-files --error-unmatch -- #{path}]
48+
world.kernel.system(
49+
*command,
50+
out: File::NULL,
51+
err: File::NULL
52+
)
53+
end
54+
55+
# Test if the path is within the current working directory
56+
#
57+
# @param [Pathname] path
58+
#
59+
# @return [TrueClass, nil]
60+
def within_working_directory?(path)
61+
working_directory = world.pathname.pwd
62+
path.ascend { |parent| return true if working_directory.eql?(parent) }
63+
end
64+
65+
end # Diff
66+
end # Repository
67+
end # Mutant

lib/mutant/repository/diff/ranges.rb

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# frozen_string_literal: true
2+
3+
module Mutant
4+
module Repository
5+
class Diff
6+
module Ranges
7+
DECIMAL = /(?:0|[1-9]\d*)/.freeze
8+
REGEXP = /\A@@ -(#{DECIMAL})(?:,(#{DECIMAL}))? \+(#{DECIMAL})(?:,(#{DECIMAL}))? @@/.freeze
9+
10+
private_constant(*constants(false))
11+
12+
# Parse a unified diff into ranges
13+
#
14+
# @param [String]
15+
#
16+
# @return [Set<Range<Integer>>]
17+
def self.parse(diff)
18+
diff.lines.flat_map(&method(:parse_ranges)).to_set
19+
end
20+
21+
# Parse ranges from line
22+
#
23+
# @param [String] line
24+
#
25+
# @return [Array<Range<Integer>>]
26+
def self.parse_ranges(line)
27+
match = REGEXP.match(line) or return EMPTY_ARRAY
28+
29+
match
30+
.captures
31+
.each_slice(2)
32+
.map { |start, offset| mk_range(start, offset) }
33+
.reject { |range| range.end < range.begin }
34+
end
35+
private_class_method :parse_ranges
36+
37+
# Construct a range from start point and offset
38+
#
39+
# @param [String] start
40+
# @param [String, nil] offset
41+
#
42+
# @return [Range<Integer>]
43+
def self.mk_range(start, offset)
44+
start = Integer(start)
45+
46+
::Range.new(start, start + (offset ? Integer(offset).pred : 0))
47+
end
48+
private_class_method :mk_range
49+
end # Ranges
50+
end # Diff
51+
end # Repository
52+
end # Ranges
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
# frozen_string_literal: true
2+
3+
describe Mutant::Repository::Diff::Ranges do
4+
describe '.parse' do
5+
def apply
6+
described_class.parse(diff)
7+
end
8+
9+
let(:diff) do
10+
Tempfile.open('old') do |old_file|
11+
old_file.write(old)
12+
old_file.flush
13+
Tempfile.open('new') do |new_file|
14+
new_file.write(new)
15+
new_file.flush
16+
# rubocop:disable Lint/UnneededSplatExpansion
17+
stdout, status = Open3.capture2(
18+
*%W[
19+
git
20+
diff
21+
--no-index
22+
--unified=0
23+
--
24+
#{old_file.path}
25+
#{new_file.path}
26+
]
27+
)
28+
# rubocop:enable Lint/UnneededSplatExpansion
29+
30+
fail unless [0, 256].include?(status.to_i)
31+
32+
stdout
33+
end
34+
end
35+
end
36+
37+
context 'on empty diff' do
38+
let(:old) { '' }
39+
let(:new) { '' }
40+
41+
it 'returns emtpy set' do
42+
expect(apply).to eql(Set.new)
43+
end
44+
end
45+
46+
context 'on empty old' do
47+
let(:old) { '' }
48+
49+
context 'adding a single line' do
50+
let(:new) do
51+
<<~'STR'
52+
a
53+
STR
54+
end
55+
56+
it 'returns expected set' do
57+
expect(apply).to eql([1..1].to_set)
58+
end
59+
end
60+
61+
context 'adding a multiple lines' do
62+
let(:old) { '' }
63+
64+
let(:new) do
65+
<<~'STR'
66+
a
67+
b
68+
STR
69+
end
70+
71+
it 'returns expected set' do
72+
expect(apply).to eql([1..2].to_set)
73+
end
74+
end
75+
end
76+
77+
context 'on empty new' do
78+
let(:new) { '' }
79+
80+
context 'removing a single line' do
81+
let(:old) do
82+
<<~'STR'
83+
a
84+
STR
85+
end
86+
87+
it 'returns expected set' do
88+
expect(apply).to eql([1..1].to_set)
89+
end
90+
end
91+
92+
context 'removing a multiple lines' do
93+
let(:old) do
94+
<<~'STR'
95+
a
96+
b
97+
STR
98+
end
99+
100+
it 'returns expected set' do
101+
expect(apply).to eql([1..2].to_set)
102+
end
103+
end
104+
end
105+
106+
context 'single line modification' do
107+
let(:old) do
108+
<<~'STR'
109+
a
110+
b
111+
c
112+
a
113+
STR
114+
end
115+
116+
let(:new) do
117+
<<~'STR'
118+
a
119+
b
120+
b
121+
a
122+
STR
123+
end
124+
125+
it 'returns expected set' do
126+
expect(apply).to eql([3..3].to_set)
127+
end
128+
end
129+
130+
context 'nonempty old and new' do
131+
context 'single line addition' do
132+
let(:old) do
133+
<<~'STR'
134+
a
135+
b
136+
a
137+
STR
138+
end
139+
140+
let(:new) do
141+
<<~'STR'
142+
a
143+
b
144+
b
145+
a
146+
STR
147+
end
148+
149+
it 'returns expected set' do
150+
expect(apply).to eql([3..3].to_set)
151+
end
152+
end
153+
context 'multi line modification' do
154+
let(:old) do
155+
<<~'STR'
156+
a
157+
b
158+
c
159+
d
160+
a
161+
STR
162+
end
163+
164+
let(:new) do
165+
<<~'STR'
166+
a
167+
b
168+
b
169+
b
170+
a
171+
STR
172+
end
173+
174+
it 'returns expected set' do
175+
expect(apply).to eql([3..4].to_set)
176+
end
177+
end
178+
end
179+
end
180+
end

0 commit comments

Comments
 (0)