cucumber-cucumber-expressions 8.3.1
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 +7 -0
- data/.github/ISSUE_TEMPLATE.md +5 -0
- data/.github/PULL_REQUEST_TEMPLATE.md +5 -0
- data/.rspec +1 -0
- data/.rsync +4 -0
- data/.subrepo +1 -0
- data/Gemfile +3 -0
- data/LICENSE +21 -0
- data/Makefile +1 -0
- data/README.md +5 -0
- data/Rakefile +27 -0
- data/cucumber-cucumber-expressions.gemspec +33 -0
- data/default.mk +70 -0
- data/examples.txt +31 -0
- data/lib/cucumber/cucumber_expressions/argument.rb +37 -0
- data/lib/cucumber/cucumber_expressions/combinatorial_generated_expression_factory.rb +49 -0
- data/lib/cucumber/cucumber_expressions/cucumber_expression.rb +119 -0
- data/lib/cucumber/cucumber_expressions/cucumber_expression_generator.rb +105 -0
- data/lib/cucumber/cucumber_expressions/errors.rb +40 -0
- data/lib/cucumber/cucumber_expressions/generated_expression.rb +31 -0
- data/lib/cucumber/cucumber_expressions/group.rb +18 -0
- data/lib/cucumber/cucumber_expressions/group_builder.rb +42 -0
- data/lib/cucumber/cucumber_expressions/parameter_type.rb +81 -0
- data/lib/cucumber/cucumber_expressions/parameter_type_matcher.rb +59 -0
- data/lib/cucumber/cucumber_expressions/parameter_type_registry.rb +70 -0
- data/lib/cucumber/cucumber_expressions/regular_expression.rb +48 -0
- data/lib/cucumber/cucumber_expressions/tree_regexp.rb +76 -0
- data/scripts/update-gemspec +32 -0
- data/spec/capture_warnings.rb +74 -0
- data/spec/coverage.rb +7 -0
- data/spec/cucumber/cucumber_expressions/argument_spec.rb +17 -0
- data/spec/cucumber/cucumber_expressions/combinatorial_generated_expression_factory_test.rb +43 -0
- data/spec/cucumber/cucumber_expressions/cucumber_expression_generator_spec.rb +231 -0
- data/spec/cucumber/cucumber_expressions/cucumber_expression_regexp_spec.rb +57 -0
- data/spec/cucumber/cucumber_expressions/cucumber_expression_spec.rb +212 -0
- data/spec/cucumber/cucumber_expressions/custom_parameter_type_spec.rb +202 -0
- data/spec/cucumber/cucumber_expressions/expression_examples_spec.rb +30 -0
- data/spec/cucumber/cucumber_expressions/parameter_type_registry_spec.rb +86 -0
- data/spec/cucumber/cucumber_expressions/parameter_type_spec.rb +15 -0
- data/spec/cucumber/cucumber_expressions/regular_expression_spec.rb +80 -0
- data/spec/cucumber/cucumber_expressions/tree_regexp_spec.rb +133 -0
- metadata +162 -0
@@ -0,0 +1,105 @@
|
|
1
|
+
require 'cucumber/cucumber_expressions/parameter_type_matcher'
|
2
|
+
require 'cucumber/cucumber_expressions/generated_expression'
|
3
|
+
require 'cucumber/cucumber_expressions/combinatorial_generated_expression_factory'
|
4
|
+
|
5
|
+
module Cucumber
|
6
|
+
module CucumberExpressions
|
7
|
+
class CucumberExpressionGenerator
|
8
|
+
def initialize(parameter_type_registry)
|
9
|
+
@parameter_type_registry = parameter_type_registry
|
10
|
+
end
|
11
|
+
|
12
|
+
def generate_expression(text)
|
13
|
+
generate_expressions(text)[0]
|
14
|
+
end
|
15
|
+
|
16
|
+
def generate_expressions(text)
|
17
|
+
parameter_type_combinations = []
|
18
|
+
parameter_type_matchers = create_parameter_type_matchers(text)
|
19
|
+
expression_template = ""
|
20
|
+
pos = 0
|
21
|
+
|
22
|
+
loop do
|
23
|
+
matching_parameter_type_matchers = []
|
24
|
+
parameter_type_matchers.each do |parameter_type_matcher|
|
25
|
+
advanced_parameter_type_matcher = parameter_type_matcher.advance_to(pos)
|
26
|
+
if advanced_parameter_type_matcher.find
|
27
|
+
matching_parameter_type_matchers.push(advanced_parameter_type_matcher)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
if matching_parameter_type_matchers.any?
|
32
|
+
matching_parameter_type_matchers = matching_parameter_type_matchers.sort
|
33
|
+
best_parameter_type_matcher = matching_parameter_type_matchers[0]
|
34
|
+
best_parameter_type_matchers = matching_parameter_type_matchers.select do |m|
|
35
|
+
(m <=> best_parameter_type_matcher).zero?
|
36
|
+
end
|
37
|
+
|
38
|
+
# Build a list of parameter types without duplicates. The reason there
|
39
|
+
# might be duplicates is that some parameter types have more than one regexp,
|
40
|
+
# which means multiple ParameterTypeMatcher objects will have a reference to the
|
41
|
+
# same ParameterType.
|
42
|
+
# We're sorting the list so prefer_for_regexp_match parameter types are listed first.
|
43
|
+
# Users are most likely to want these, so they should be listed at the top.
|
44
|
+
parameter_types = []
|
45
|
+
best_parameter_type_matchers.each do |parameter_type_matcher|
|
46
|
+
unless parameter_types.include?(parameter_type_matcher.parameter_type)
|
47
|
+
parameter_types.push(parameter_type_matcher.parameter_type)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
parameter_types.sort!
|
51
|
+
|
52
|
+
parameter_type_combinations.push(parameter_types)
|
53
|
+
|
54
|
+
expression_template += escape(text.slice(pos...best_parameter_type_matcher.start))
|
55
|
+
expression_template += "{%s}"
|
56
|
+
|
57
|
+
pos = best_parameter_type_matcher.start + best_parameter_type_matcher.group.length
|
58
|
+
else
|
59
|
+
break
|
60
|
+
end
|
61
|
+
|
62
|
+
if pos >= text.length
|
63
|
+
break
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
expression_template += escape(text.slice(pos..-1))
|
68
|
+
|
69
|
+
CombinatorialGeneratedExpressionFactory.new(
|
70
|
+
expression_template,
|
71
|
+
parameter_type_combinations
|
72
|
+
).generate_expressions
|
73
|
+
end
|
74
|
+
|
75
|
+
private
|
76
|
+
|
77
|
+
def create_parameter_type_matchers(text)
|
78
|
+
parameter_matchers = []
|
79
|
+
@parameter_type_registry.parameter_types.each do |parameter_type|
|
80
|
+
if parameter_type.use_for_snippets?
|
81
|
+
parameter_matchers += create_parameter_type_matchers2(parameter_type, text)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
parameter_matchers
|
85
|
+
end
|
86
|
+
|
87
|
+
def create_parameter_type_matchers2(parameter_type, text)
|
88
|
+
result = []
|
89
|
+
regexps = parameter_type.regexps
|
90
|
+
regexps.each do |regexp|
|
91
|
+
regexp = Regexp.new("(#{regexp})")
|
92
|
+
result.push(ParameterTypeMatcher.new(parameter_type, regexp, text, 0))
|
93
|
+
end
|
94
|
+
result
|
95
|
+
end
|
96
|
+
|
97
|
+
def escape(s)
|
98
|
+
s.gsub(/%/, '%%')
|
99
|
+
.gsub(/\(/, '\\(')
|
100
|
+
.gsub(/\{/, '\\{')
|
101
|
+
.gsub(/\//, '\\/')
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module Cucumber
|
2
|
+
module CucumberExpressions
|
3
|
+
class CucumberExpressionError < StandardError
|
4
|
+
end
|
5
|
+
|
6
|
+
class UndefinedParameterTypeError < CucumberExpressionError
|
7
|
+
def initialize(type_name)
|
8
|
+
super("Undefined parameter type {#{type_name}}")
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
class AmbiguousParameterTypeError < CucumberExpressionError
|
13
|
+
def initialize(parameter_type_regexp, expression_regexp, parameter_types, generated_expressions)
|
14
|
+
super(<<-EOM)
|
15
|
+
Your Regular Expression /#{expression_regexp.source}/
|
16
|
+
matches multiple parameter types with regexp /#{parameter_type_regexp}/:
|
17
|
+
#{parameter_type_names(parameter_types)}
|
18
|
+
|
19
|
+
I couldn't decide which one to use. You have two options:
|
20
|
+
|
21
|
+
1) Use a Cucumber Expression instead of a Regular Expression. Try one of these:
|
22
|
+
#{expressions(generated_expressions)}
|
23
|
+
|
24
|
+
2) Make one of the parameter types preferential and continue to use a Regular Expression.
|
25
|
+
|
26
|
+
EOM
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def parameter_type_names(parameter_types)
|
32
|
+
parameter_types.map{|p| "{#{p.name}}"}.join("\n ")
|
33
|
+
end
|
34
|
+
|
35
|
+
def expressions(generated_expressions)
|
36
|
+
generated_expressions.map{|ge| ge.source}.join("\n ")
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module Cucumber
|
2
|
+
module CucumberExpressions
|
3
|
+
class GeneratedExpression
|
4
|
+
attr_reader :parameter_types
|
5
|
+
|
6
|
+
def initialize(expression_template, parameters_types)
|
7
|
+
@expression_template, @parameter_types = expression_template, parameters_types
|
8
|
+
end
|
9
|
+
|
10
|
+
def source
|
11
|
+
sprintf(@expression_template, *@parameter_types.map(&:name))
|
12
|
+
end
|
13
|
+
|
14
|
+
def parameter_names
|
15
|
+
usage_by_type_name = Hash.new(0)
|
16
|
+
@parameter_types.map do |t|
|
17
|
+
get_parameter_name(t.name, usage_by_type_name)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def get_parameter_name(type_name, usage_by_type_name)
|
24
|
+
count = usage_by_type_name[type_name]
|
25
|
+
count += 1
|
26
|
+
usage_by_type_name[type_name] = count
|
27
|
+
count == 1 ? type_name : "#{type_name}#{count}"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Cucumber
|
2
|
+
module CucumberExpressions
|
3
|
+
class Group
|
4
|
+
attr_reader :value, :start, :end, :children
|
5
|
+
|
6
|
+
def initialize(value, start, _end, children)
|
7
|
+
@value = value
|
8
|
+
@start = start
|
9
|
+
@end = _end
|
10
|
+
@children = children
|
11
|
+
end
|
12
|
+
|
13
|
+
def values
|
14
|
+
(children.empty? ? [self] : children).map(&:value).compact
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
require 'cucumber/cucumber_expressions/group'
|
2
|
+
|
3
|
+
module Cucumber
|
4
|
+
module CucumberExpressions
|
5
|
+
class GroupBuilder
|
6
|
+
attr_accessor :source
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
@group_builders = []
|
10
|
+
@capturing = true
|
11
|
+
end
|
12
|
+
|
13
|
+
def add(group_builder)
|
14
|
+
@group_builders.push(group_builder)
|
15
|
+
end
|
16
|
+
|
17
|
+
def build(match, group_indices)
|
18
|
+
group_index = group_indices.next
|
19
|
+
children = @group_builders.map {|gb| gb.build(match, group_indices)}
|
20
|
+
Group.new(match[group_index], match.offset(group_index)[0], match.offset(group_index)[1], children)
|
21
|
+
end
|
22
|
+
|
23
|
+
def set_non_capturing!
|
24
|
+
@capturing = false
|
25
|
+
end
|
26
|
+
|
27
|
+
def capturing?
|
28
|
+
@capturing
|
29
|
+
end
|
30
|
+
|
31
|
+
def move_children_to(group_builder)
|
32
|
+
@group_builders.each do |child|
|
33
|
+
group_builder.add(child)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def children
|
38
|
+
@group_builders
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
require 'cucumber/cucumber_expressions/errors'
|
2
|
+
|
3
|
+
module Cucumber
|
4
|
+
module CucumberExpressions
|
5
|
+
class ParameterType
|
6
|
+
ILLEGAL_PARAMETER_NAME_PATTERN = /([\[\]()$.|?*+])/
|
7
|
+
UNESCAPE_PATTERN = /(\\([\[$.|?*+\]]))/
|
8
|
+
|
9
|
+
attr_reader :name, :type, :regexps
|
10
|
+
|
11
|
+
def self.check_parameter_type_name(type_name)
|
12
|
+
unescaped_type_name = type_name.gsub(UNESCAPE_PATTERN) do
|
13
|
+
$2
|
14
|
+
end
|
15
|
+
if ILLEGAL_PARAMETER_NAME_PATTERN =~ unescaped_type_name
|
16
|
+
raise CucumberExpressionError.new("Illegal character '#{$1}' in parameter name {#{unescaped_type_name}}")
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def prefer_for_regexp_match?
|
21
|
+
@prefer_for_regexp_match
|
22
|
+
end
|
23
|
+
|
24
|
+
def use_for_snippets?
|
25
|
+
@use_for_snippets
|
26
|
+
end
|
27
|
+
|
28
|
+
# Create a new Parameter
|
29
|
+
#
|
30
|
+
# @param name the name of the parameter type
|
31
|
+
# @param regexp [Array] list of regexps for capture groups. A single regexp can also be used
|
32
|
+
# @param type the return type of the transformed
|
33
|
+
# @param transformer lambda that transforms a String to (possibly) another type
|
34
|
+
# @param use_for_snippets true if this should be used for snippet generation
|
35
|
+
# @param prefer_for_regexp_match true if this should be preferred over similar types
|
36
|
+
#
|
37
|
+
def initialize(name, regexp, type, transformer, use_for_snippets, prefer_for_regexp_match)
|
38
|
+
raise "regexp can't be nil" if regexp.nil?
|
39
|
+
raise "type can't be nil" if type.nil?
|
40
|
+
raise "transformer can't be nil" if transformer.nil?
|
41
|
+
raise "use_for_snippets can't be nil" if use_for_snippets.nil?
|
42
|
+
raise "prefer_for_regexp_match can't be nil" if prefer_for_regexp_match.nil?
|
43
|
+
|
44
|
+
self.class.check_parameter_type_name(name) unless name.nil?
|
45
|
+
@name, @type, @transformer, @use_for_snippets, @prefer_for_regexp_match = name, type, transformer, use_for_snippets, prefer_for_regexp_match
|
46
|
+
@regexps = string_array(regexp)
|
47
|
+
end
|
48
|
+
|
49
|
+
def transform(self_obj, group_values)
|
50
|
+
self_obj.instance_exec(*group_values, &@transformer)
|
51
|
+
end
|
52
|
+
|
53
|
+
def <=>(other)
|
54
|
+
return -1 if prefer_for_regexp_match? && !other.prefer_for_regexp_match?
|
55
|
+
return 1 if other.prefer_for_regexp_match? && !prefer_for_regexp_match?
|
56
|
+
return name <=> other.name
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def string_array(regexps)
|
62
|
+
array = regexps.is_a?(Array) ? regexps : [regexps]
|
63
|
+
array.map {|regexp| regexp.is_a?(String) ? regexp : regexp_source(regexp)}
|
64
|
+
end
|
65
|
+
|
66
|
+
def regexp_source(regexp)
|
67
|
+
[
|
68
|
+
'EXTENDED',
|
69
|
+
'IGNORECASE',
|
70
|
+
'MULTILINE'
|
71
|
+
].each do |option_name|
|
72
|
+
option = Regexp.const_get(option_name)
|
73
|
+
if regexp.options & option != 0
|
74
|
+
raise CucumberExpressionError.new("ParameterType Regexps can't use option Regexp::#{option_name}")
|
75
|
+
end
|
76
|
+
end
|
77
|
+
regexp.source
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
module Cucumber
|
2
|
+
module CucumberExpressions
|
3
|
+
class ParameterTypeMatcher
|
4
|
+
attr_reader :parameter_type
|
5
|
+
|
6
|
+
def initialize(parameter_type, regexp, text, match_position=0)
|
7
|
+
@parameter_type, @regexp, @text = parameter_type, regexp, text
|
8
|
+
@match = @regexp.match(@text, match_position)
|
9
|
+
end
|
10
|
+
|
11
|
+
def advance_to(new_match_position)
|
12
|
+
(new_match_position...@text.length).each {|advancedPos|
|
13
|
+
matcher = self.class.new(parameter_type, @regexp, @text, advancedPos)
|
14
|
+
if matcher.find && matcher.full_word?
|
15
|
+
return matcher
|
16
|
+
end
|
17
|
+
}
|
18
|
+
|
19
|
+
self.class.new(parameter_type, @regexp, @text, @text.length)
|
20
|
+
end
|
21
|
+
|
22
|
+
def find
|
23
|
+
!@match.nil? && !group.empty?
|
24
|
+
end
|
25
|
+
|
26
|
+
def full_word?
|
27
|
+
space_before_match_or_sentence_start? && space_after_match_or_sentence_end?
|
28
|
+
end
|
29
|
+
|
30
|
+
def start
|
31
|
+
@match.begin(0)
|
32
|
+
end
|
33
|
+
|
34
|
+
def group
|
35
|
+
@match.captures[0]
|
36
|
+
end
|
37
|
+
|
38
|
+
def <=>(other)
|
39
|
+
pos_comparison = start <=> other.start
|
40
|
+
return pos_comparison if pos_comparison != 0
|
41
|
+
length_comparison = other.group.length <=> group.length
|
42
|
+
return length_comparison if length_comparison != 0
|
43
|
+
0
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def space_before_match_or_sentence_start?
|
49
|
+
match_begin = @match.begin(0)
|
50
|
+
match_begin == 0 || @text[match_begin - 1].match(/\s|\p{P}/)
|
51
|
+
end
|
52
|
+
|
53
|
+
def space_after_match_or_sentence_end?
|
54
|
+
match_end = @match.end(0)
|
55
|
+
match_end == @text.length || @text[match_end].match(/\s|\p{P}/)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
require 'cucumber/cucumber_expressions/parameter_type'
|
2
|
+
require 'cucumber/cucumber_expressions/errors'
|
3
|
+
require 'cucumber/cucumber_expressions/cucumber_expression_generator'
|
4
|
+
|
5
|
+
module Cucumber
|
6
|
+
module CucumberExpressions
|
7
|
+
class ParameterTypeRegistry
|
8
|
+
INTEGER_REGEXPS = [/-?\d+/, /\d+/]
|
9
|
+
FLOAT_REGEXP = /(?=.*\d.*)[-+]?\d*(?:\.(?=\d.*))?\d*(?:\d+[E][-+]?\d+)?/
|
10
|
+
WORD_REGEXP = /[^\s]+/
|
11
|
+
STRING_REGEXP = /"([^"\\]*(\\.[^"\\]*)*)"|'([^'\\]*(\\.[^'\\]*)*)'/
|
12
|
+
ANONYMOUS_REGEXP = /.*/
|
13
|
+
|
14
|
+
def initialize
|
15
|
+
@parameter_type_by_name = {}
|
16
|
+
@parameter_types_by_regexp = Hash.new {|hash, regexp| hash[regexp] = []}
|
17
|
+
|
18
|
+
define_parameter_type(ParameterType.new('int', INTEGER_REGEXPS, Integer, lambda {|s = nil| s && s.to_i}, true, true))
|
19
|
+
define_parameter_type(ParameterType.new('float', FLOAT_REGEXP, Float, lambda {|s = nil| s && s.to_f}, true, false))
|
20
|
+
define_parameter_type(ParameterType.new('word', WORD_REGEXP, String, lambda {|s = nil| s}, false, false))
|
21
|
+
define_parameter_type(ParameterType.new('string', STRING_REGEXP, String, lambda {|s = nil| s && s.gsub(/\\"/, '"').gsub(/\\'/, "'")}, true, false))
|
22
|
+
define_parameter_type(ParameterType.new('', ANONYMOUS_REGEXP, String, lambda {|s = nil| s}, false, true))
|
23
|
+
end
|
24
|
+
|
25
|
+
def lookup_by_type_name(name)
|
26
|
+
@parameter_type_by_name[name]
|
27
|
+
end
|
28
|
+
|
29
|
+
def lookup_by_regexp(parameter_type_regexp, expression_regexp, text)
|
30
|
+
parameter_types = @parameter_types_by_regexp[parameter_type_regexp]
|
31
|
+
return nil if parameter_types.nil?
|
32
|
+
if parameter_types.length > 1 && !parameter_types[0].prefer_for_regexp_match?
|
33
|
+
# We don't do this check on insertion because we only want to restrict
|
34
|
+
# ambiguity when we look up by Regexp. Users of CucumberExpression should
|
35
|
+
# not be restricted.
|
36
|
+
generated_expressions = CucumberExpressionGenerator.new(self).generate_expressions(text)
|
37
|
+
raise AmbiguousParameterTypeError.new(parameter_type_regexp, expression_regexp, parameter_types, generated_expressions)
|
38
|
+
end
|
39
|
+
parameter_types.first
|
40
|
+
end
|
41
|
+
|
42
|
+
def parameter_types
|
43
|
+
@parameter_type_by_name.values
|
44
|
+
end
|
45
|
+
|
46
|
+
def define_parameter_type(parameter_type)
|
47
|
+
if parameter_type.name != nil
|
48
|
+
if @parameter_type_by_name.has_key?(parameter_type.name)
|
49
|
+
if parameter_type.name.length == 0
|
50
|
+
raise CucumberExpressionError.new("The anonymous parameter type has already been defined")
|
51
|
+
else
|
52
|
+
raise CucumberExpressionError.new("There is already a parameter with name #{parameter_type.name}")
|
53
|
+
end
|
54
|
+
end
|
55
|
+
@parameter_type_by_name[parameter_type.name] = parameter_type
|
56
|
+
end
|
57
|
+
|
58
|
+
parameter_type.regexps.each do |parameter_type_regexp|
|
59
|
+
parameter_types = @parameter_types_by_regexp[parameter_type_regexp]
|
60
|
+
if parameter_types.any? && parameter_types[0].prefer_for_regexp_match? && parameter_type.prefer_for_regexp_match?
|
61
|
+
raise CucumberExpressionError.new("There can only be one preferential parameter type per regexp. The regexp /#{parameter_type_regexp}/ is used for two preferential parameter types, {#{parameter_types[0].name}} and {#{parameter_type.name}}")
|
62
|
+
end
|
63
|
+
parameter_types.push(parameter_type)
|
64
|
+
parameter_types.sort!
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|