ast-merge 1.0.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 +7 -0
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +46 -0
- data/CITATION.cff +20 -0
- data/CODE_OF_CONDUCT.md +134 -0
- data/CONTRIBUTING.md +227 -0
- data/FUNDING.md +74 -0
- data/LICENSE.txt +21 -0
- data/README.md +852 -0
- data/REEK +0 -0
- data/RUBOCOP.md +71 -0
- data/SECURITY.md +21 -0
- data/lib/ast/merge/ast_node.rb +87 -0
- data/lib/ast/merge/comment/block.rb +195 -0
- data/lib/ast/merge/comment/empty.rb +78 -0
- data/lib/ast/merge/comment/line.rb +138 -0
- data/lib/ast/merge/comment/parser.rb +278 -0
- data/lib/ast/merge/comment/style.rb +282 -0
- data/lib/ast/merge/comment.rb +36 -0
- data/lib/ast/merge/conflict_resolver_base.rb +399 -0
- data/lib/ast/merge/debug_logger.rb +271 -0
- data/lib/ast/merge/fenced_code_block_detector.rb +211 -0
- data/lib/ast/merge/file_analyzable.rb +307 -0
- data/lib/ast/merge/freezable.rb +82 -0
- data/lib/ast/merge/freeze_node_base.rb +434 -0
- data/lib/ast/merge/match_refiner_base.rb +312 -0
- data/lib/ast/merge/match_score_base.rb +135 -0
- data/lib/ast/merge/merge_result_base.rb +169 -0
- data/lib/ast/merge/merger_config.rb +258 -0
- data/lib/ast/merge/node_typing.rb +373 -0
- data/lib/ast/merge/region.rb +124 -0
- data/lib/ast/merge/region_detector_base.rb +114 -0
- data/lib/ast/merge/region_mergeable.rb +364 -0
- data/lib/ast/merge/rspec/shared_examples/conflict_resolver_base.rb +416 -0
- data/lib/ast/merge/rspec/shared_examples/debug_logger.rb +174 -0
- data/lib/ast/merge/rspec/shared_examples/file_analyzable.rb +193 -0
- data/lib/ast/merge/rspec/shared_examples/freeze_node_base.rb +219 -0
- data/lib/ast/merge/rspec/shared_examples/merge_result_base.rb +106 -0
- data/lib/ast/merge/rspec/shared_examples/merger_config.rb +202 -0
- data/lib/ast/merge/rspec/shared_examples/reproducible_merge.rb +115 -0
- data/lib/ast/merge/rspec/shared_examples.rb +26 -0
- data/lib/ast/merge/rspec.rb +4 -0
- data/lib/ast/merge/section_typing.rb +303 -0
- data/lib/ast/merge/smart_merger_base.rb +417 -0
- data/lib/ast/merge/text/conflict_resolver.rb +161 -0
- data/lib/ast/merge/text/file_analysis.rb +168 -0
- data/lib/ast/merge/text/line_node.rb +142 -0
- data/lib/ast/merge/text/merge_result.rb +42 -0
- data/lib/ast/merge/text/section.rb +93 -0
- data/lib/ast/merge/text/section_splitter.rb +397 -0
- data/lib/ast/merge/text/smart_merger.rb +141 -0
- data/lib/ast/merge/text/word_node.rb +86 -0
- data/lib/ast/merge/text.rb +35 -0
- data/lib/ast/merge/toml_frontmatter_detector.rb +88 -0
- data/lib/ast/merge/version.rb +12 -0
- data/lib/ast/merge/yaml_frontmatter_detector.rb +108 -0
- data/lib/ast/merge.rb +165 -0
- data/lib/ast-merge.rb +4 -0
- data/sig/ast/merge.rbs +195 -0
- data.tar.gz.sig +0 -0
- metadata +326 -0
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "line_node"
|
|
4
|
+
require_relative "word_node"
|
|
5
|
+
|
|
6
|
+
module Ast
|
|
7
|
+
module Merge
|
|
8
|
+
module Text
|
|
9
|
+
# Text file analysis class for the text-based AST.
|
|
10
|
+
#
|
|
11
|
+
# This class parses plain text files into a simple AST structure where:
|
|
12
|
+
# - Top-level nodes are LineNodes (one per line)
|
|
13
|
+
# - Nested nodes are WordNodes (words within each line, split on word boundaries)
|
|
14
|
+
#
|
|
15
|
+
# This provides a minimal AST implementation that can be used to test
|
|
16
|
+
# the merge infrastructure with any text-based content.
|
|
17
|
+
#
|
|
18
|
+
# @example Basic usage
|
|
19
|
+
# analysis = FileAnalysis.new("Hello world\nGoodbye world")
|
|
20
|
+
# analysis.statements.size # => 2
|
|
21
|
+
# analysis.statements[0].words.size # => 2
|
|
22
|
+
#
|
|
23
|
+
# @example With freeze blocks
|
|
24
|
+
# content = <<~TEXT
|
|
25
|
+
# Line one
|
|
26
|
+
# # text-merge:freeze
|
|
27
|
+
# Frozen content
|
|
28
|
+
# # text-merge:unfreeze
|
|
29
|
+
# Line four
|
|
30
|
+
# TEXT
|
|
31
|
+
# analysis = FileAnalysis.new(content, freeze_token: "text-merge")
|
|
32
|
+
# analysis.freeze_blocks.size # => 1
|
|
33
|
+
class FileAnalysis
|
|
34
|
+
include FileAnalyzable
|
|
35
|
+
|
|
36
|
+
# Default freeze token for text files
|
|
37
|
+
DEFAULT_FREEZE_TOKEN = "text-merge"
|
|
38
|
+
|
|
39
|
+
# Initialize a new FileAnalysis
|
|
40
|
+
#
|
|
41
|
+
# @param source [String] Source text content
|
|
42
|
+
# @param freeze_token [String] Token for freeze block markers
|
|
43
|
+
# @param signature_generator [Proc, nil] Custom signature generator
|
|
44
|
+
def initialize(source, freeze_token: DEFAULT_FREEZE_TOKEN, signature_generator: nil)
|
|
45
|
+
@source = source
|
|
46
|
+
# Split preserving empty lines, but remove trailing empty line from final newline
|
|
47
|
+
@lines = source.split("\n", -1)
|
|
48
|
+
@lines.pop if @lines.last&.empty? && source.end_with?("\n")
|
|
49
|
+
@freeze_token = freeze_token
|
|
50
|
+
@signature_generator = signature_generator
|
|
51
|
+
@statements = parse_statements
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Get all top-level statements (LineNodes and FreezeNodes)
|
|
55
|
+
#
|
|
56
|
+
# @return [Array<LineNode, FreezeNodeBase>] All top-level statements
|
|
57
|
+
attr_reader :statements
|
|
58
|
+
|
|
59
|
+
# Compute signature for a node
|
|
60
|
+
#
|
|
61
|
+
# @param node [Object] Node to compute signature for
|
|
62
|
+
# @return [Array, nil] Signature array or nil
|
|
63
|
+
def compute_node_signature(node)
|
|
64
|
+
case node
|
|
65
|
+
when LineNode
|
|
66
|
+
node.signature
|
|
67
|
+
when FreezeNodeBase
|
|
68
|
+
[:freeze_block, node.start_line, node.end_line]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Check if a value is a fallthrough node
|
|
73
|
+
#
|
|
74
|
+
# @param value [Object] Value to check
|
|
75
|
+
# @return [Boolean] True if fallthrough node
|
|
76
|
+
def fallthrough_node?(value)
|
|
77
|
+
value.is_a?(LineNode) || value.is_a?(FreezeNodeBase) || super
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# Parse source into statements (LineNodes and FreezeNodes)
|
|
83
|
+
#
|
|
84
|
+
# @return [Array] Parsed statements
|
|
85
|
+
def parse_statements
|
|
86
|
+
statements = []
|
|
87
|
+
freeze_start = nil
|
|
88
|
+
freeze_start_line = nil
|
|
89
|
+
|
|
90
|
+
@lines.each_with_index do |line, idx|
|
|
91
|
+
line_number = idx + 1
|
|
92
|
+
|
|
93
|
+
# Check for freeze markers
|
|
94
|
+
if freeze_marker?(line, :freeze)
|
|
95
|
+
freeze_start = line_number
|
|
96
|
+
freeze_start_line = line
|
|
97
|
+
next
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
if freeze_marker?(line, :unfreeze)
|
|
101
|
+
if freeze_start
|
|
102
|
+
# Create freeze block
|
|
103
|
+
freeze_content = @lines[(freeze_start - 1)..(line_number - 1)].join("\n")
|
|
104
|
+
statements << FreezeNodeBase.new(
|
|
105
|
+
start_line: freeze_start,
|
|
106
|
+
end_line: line_number,
|
|
107
|
+
content: freeze_content,
|
|
108
|
+
reason: extract_freeze_reason(freeze_start_line),
|
|
109
|
+
)
|
|
110
|
+
freeze_start = nil
|
|
111
|
+
freeze_start_line = nil
|
|
112
|
+
end
|
|
113
|
+
next
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Skip lines inside freeze blocks
|
|
117
|
+
next if freeze_start
|
|
118
|
+
|
|
119
|
+
# Regular line
|
|
120
|
+
statements << LineNode.new(line, line_number: line_number)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Handle unclosed freeze block
|
|
124
|
+
if freeze_start
|
|
125
|
+
raise FreezeNodeBase::InvalidStructureError.new(
|
|
126
|
+
"Unclosed freeze block starting at line #{freeze_start}",
|
|
127
|
+
start_line: freeze_start,
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
statements
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Check if a line is a freeze marker
|
|
135
|
+
#
|
|
136
|
+
# @param line [String] Line to check
|
|
137
|
+
# @param type [Symbol] :freeze or :unfreeze
|
|
138
|
+
# @return [Boolean] True if line is a marker
|
|
139
|
+
def freeze_marker?(line, type)
|
|
140
|
+
pattern = FreezeNodeBase.pattern_for(:hash_comment, @freeze_token)
|
|
141
|
+
match = line.match(pattern)
|
|
142
|
+
return false unless match
|
|
143
|
+
|
|
144
|
+
marker_type = match[1]&.downcase
|
|
145
|
+
case type
|
|
146
|
+
when :freeze
|
|
147
|
+
marker_type == "freeze"
|
|
148
|
+
when :unfreeze
|
|
149
|
+
marker_type == "unfreeze"
|
|
150
|
+
else
|
|
151
|
+
false
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Extract freeze reason from marker line
|
|
156
|
+
#
|
|
157
|
+
# @param line [String] Freeze marker line
|
|
158
|
+
# @return [String, nil] Reason or nil
|
|
159
|
+
def extract_freeze_reason(line)
|
|
160
|
+
pattern = FreezeNodeBase.pattern_for(:hash_comment, @freeze_token)
|
|
161
|
+
match = line.match(pattern)
|
|
162
|
+
reason = match[2]&.strip
|
|
163
|
+
(reason.nil? || reason.empty?) ? nil : reason
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ast
|
|
4
|
+
module Merge
|
|
5
|
+
module Text
|
|
6
|
+
# Represents a line of text in the text-based AST.
|
|
7
|
+
# Lines are top-level nodes, with words as nested children.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# line = LineNode.new("Hello world!", line_number: 1)
|
|
11
|
+
# line.content # => "Hello world!"
|
|
12
|
+
# line.words.size # => 2
|
|
13
|
+
# line.signature # => [:line, "Hello world!"]
|
|
14
|
+
class LineNode
|
|
15
|
+
# @return [String] The full line content (without trailing newline)
|
|
16
|
+
attr_reader :content
|
|
17
|
+
|
|
18
|
+
# @return [Integer] 1-based line number
|
|
19
|
+
attr_reader :line_number
|
|
20
|
+
|
|
21
|
+
# @return [Array<WordNode>] Words contained in this line
|
|
22
|
+
attr_reader :words
|
|
23
|
+
|
|
24
|
+
# Initialize a new LineNode
|
|
25
|
+
#
|
|
26
|
+
# @param content [String] The line content (without trailing newline)
|
|
27
|
+
# @param line_number [Integer] 1-based line number
|
|
28
|
+
def initialize(content, line_number:)
|
|
29
|
+
@content = content
|
|
30
|
+
@line_number = line_number
|
|
31
|
+
@words = parse_words
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Generate a signature for this line node.
|
|
35
|
+
# The signature is used for matching lines across template/destination.
|
|
36
|
+
#
|
|
37
|
+
# @return [Array] Signature array [:line, normalized_content]
|
|
38
|
+
def signature
|
|
39
|
+
[:line, normalized_content]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Get normalized content (trimmed whitespace for comparison)
|
|
43
|
+
#
|
|
44
|
+
# @return [String] Whitespace-trimmed content
|
|
45
|
+
def normalized_content
|
|
46
|
+
@content.strip
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Check if this line is blank (empty or whitespace only)
|
|
50
|
+
#
|
|
51
|
+
# @return [Boolean] True if line is blank
|
|
52
|
+
def blank?
|
|
53
|
+
@content.strip.empty?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Check if this line is a comment (starts with # after whitespace)
|
|
57
|
+
# This is a simple heuristic for text files.
|
|
58
|
+
#
|
|
59
|
+
# @return [Boolean] True if line appears to be a comment
|
|
60
|
+
def comment?
|
|
61
|
+
@content.strip.start_with?("#")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Get the starting line (for compatibility with AST node interface)
|
|
65
|
+
#
|
|
66
|
+
# @return [Integer] 1-based start line
|
|
67
|
+
def start_line
|
|
68
|
+
@line_number
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Get the ending line (for compatibility with AST node interface)
|
|
72
|
+
#
|
|
73
|
+
# @return [Integer] 1-based end line (same as start for single line)
|
|
74
|
+
def end_line
|
|
75
|
+
@line_number
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Check equality with another LineNode
|
|
79
|
+
#
|
|
80
|
+
# @param other [LineNode] Other node to compare
|
|
81
|
+
# @return [Boolean] True if content matches exactly
|
|
82
|
+
def ==(other)
|
|
83
|
+
other.is_a?(LineNode) && @content == other.content
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
alias_method :eql?, :==
|
|
87
|
+
|
|
88
|
+
# Hash code for use in Hash keys
|
|
89
|
+
#
|
|
90
|
+
# @return [Integer] Hash code
|
|
91
|
+
def hash
|
|
92
|
+
@content.hash
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# String representation for debugging
|
|
96
|
+
#
|
|
97
|
+
# @return [String] Debug representation
|
|
98
|
+
def inspect
|
|
99
|
+
"#<LineNode line=#{@line_number} #{@content.inspect} words=#{@words.size}>"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Convert to string (returns content)
|
|
103
|
+
#
|
|
104
|
+
# @return [String] Line content
|
|
105
|
+
def to_s
|
|
106
|
+
@content
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
# Parse words from the line content using word boundaries
|
|
112
|
+
#
|
|
113
|
+
# @return [Array<WordNode>] Parsed word nodes
|
|
114
|
+
def parse_words
|
|
115
|
+
words = []
|
|
116
|
+
word_index = 0
|
|
117
|
+
|
|
118
|
+
# Match words using word boundary regex
|
|
119
|
+
# This captures sequences of word characters (\w+)
|
|
120
|
+
@content.scan(/\b(\w+)\b/) do |match|
|
|
121
|
+
word = match[0]
|
|
122
|
+
# Get the match position
|
|
123
|
+
match_data = Regexp.last_match
|
|
124
|
+
start_col = match_data.begin(0)
|
|
125
|
+
end_col = match_data.end(0)
|
|
126
|
+
|
|
127
|
+
words << WordNode.new(
|
|
128
|
+
word,
|
|
129
|
+
line_number: @line_number,
|
|
130
|
+
word_index: word_index,
|
|
131
|
+
start_col: start_col,
|
|
132
|
+
end_col: end_col,
|
|
133
|
+
)
|
|
134
|
+
word_index += 1
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
words
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ast
|
|
4
|
+
module Merge
|
|
5
|
+
module Text
|
|
6
|
+
# Merge result for text-based AST merging.
|
|
7
|
+
# Tracks merged lines and decisions made during the merge process.
|
|
8
|
+
class MergeResult < MergeResultBase
|
|
9
|
+
# Add a line to the result
|
|
10
|
+
#
|
|
11
|
+
# @param line [String] Line content to add
|
|
12
|
+
# @return [void]
|
|
13
|
+
def add_line(line)
|
|
14
|
+
@lines << line
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Add multiple lines to the result
|
|
18
|
+
#
|
|
19
|
+
# @param lines [Array<String>] Lines to add
|
|
20
|
+
# @return [void]
|
|
21
|
+
def add_lines(lines)
|
|
22
|
+
@lines.concat(lines)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Record a merge decision
|
|
26
|
+
#
|
|
27
|
+
# @param decision [Symbol] Decision constant
|
|
28
|
+
# @param template_node [Object, nil] Template node involved
|
|
29
|
+
# @param dest_node [Object, nil] Destination node involved
|
|
30
|
+
# @return [void]
|
|
31
|
+
def record_decision(decision, template_node, dest_node)
|
|
32
|
+
@decisions << {
|
|
33
|
+
decision: decision,
|
|
34
|
+
template_node: template_node,
|
|
35
|
+
dest_node: dest_node,
|
|
36
|
+
line: @lines.length,
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ast
|
|
4
|
+
module Merge
|
|
5
|
+
module Text
|
|
6
|
+
# Represents a named section within text content.
|
|
7
|
+
#
|
|
8
|
+
# Sections are logical units of text that can be matched and merged
|
|
9
|
+
# independently. For example, in Markdown, sections might be delimited by
|
|
10
|
+
# headings; in plain text, sections might be delimited by comment markers.
|
|
11
|
+
#
|
|
12
|
+
# This is used for text-based splitting of leaf node content, NOT for
|
|
13
|
+
# AST-level node classification (see SectionTyping for that).
|
|
14
|
+
#
|
|
15
|
+
# @example A Markdown section
|
|
16
|
+
# Section.new(
|
|
17
|
+
# name: "Installation",
|
|
18
|
+
# header: "## Installation\n",
|
|
19
|
+
# body: "Install the gem...\n",
|
|
20
|
+
# start_line: 10,
|
|
21
|
+
# end_line: 25,
|
|
22
|
+
# metadata: { heading_level: 2 }
|
|
23
|
+
# )
|
|
24
|
+
#
|
|
25
|
+
# @api public
|
|
26
|
+
Section = Struct.new(
|
|
27
|
+
# @return [String, Symbol] Unique identifier for matching sections
|
|
28
|
+
# (e.g., heading text, comment marker, :preamble for content before first section)
|
|
29
|
+
:name,
|
|
30
|
+
|
|
31
|
+
# @return [String, nil] Header content (heading line, comment marker, etc.)
|
|
32
|
+
:header,
|
|
33
|
+
|
|
34
|
+
# @return [String] The section body content
|
|
35
|
+
:body,
|
|
36
|
+
|
|
37
|
+
# @return [Integer, nil] 1-indexed start line in the original content
|
|
38
|
+
:start_line,
|
|
39
|
+
|
|
40
|
+
# @return [Integer, nil] 1-indexed end line in the original content
|
|
41
|
+
:end_line,
|
|
42
|
+
|
|
43
|
+
# @return [Hash, nil] Optional metadata for splitter-specific information
|
|
44
|
+
# (e.g., { heading_level: 2 }, { marker_type: :comment })
|
|
45
|
+
:metadata,
|
|
46
|
+
keyword_init: true,
|
|
47
|
+
) do
|
|
48
|
+
# Returns the line range covered by this section.
|
|
49
|
+
#
|
|
50
|
+
# @return [Range, nil] The range from start_line to end_line (inclusive)
|
|
51
|
+
def line_range
|
|
52
|
+
return unless start_line && end_line
|
|
53
|
+
start_line..end_line
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Returns the number of lines this section spans.
|
|
57
|
+
#
|
|
58
|
+
# @return [Integer, nil] The number of lines
|
|
59
|
+
def line_count
|
|
60
|
+
return unless start_line && end_line
|
|
61
|
+
end_line - start_line + 1
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Reconstructs the full section text including header.
|
|
65
|
+
#
|
|
66
|
+
# @return [String] The complete section with header and body
|
|
67
|
+
def full_text
|
|
68
|
+
result = +""
|
|
69
|
+
result << header.to_s if header
|
|
70
|
+
result << body.to_s
|
|
71
|
+
result
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Check if this is the preamble section (content before first split point).
|
|
75
|
+
#
|
|
76
|
+
# @return [Boolean] true if this is the preamble
|
|
77
|
+
def preamble?
|
|
78
|
+
name == :preamble
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Normalize the section name for matching.
|
|
82
|
+
# Strips whitespace, downcases, and normalizes spaces.
|
|
83
|
+
#
|
|
84
|
+
# @return [String] Normalized name for matching
|
|
85
|
+
def normalized_name
|
|
86
|
+
return "" if name.nil?
|
|
87
|
+
return name.to_s if name.is_a?(Symbol)
|
|
88
|
+
name.to_s.strip.downcase.gsub(/\s+/, " ")
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|