calcsheet 0.1.0 → 0.2.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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -1
  3. data/README.md +124 -9
  4. data/lib/calcsheet/book.rb +25 -9
  5. data/lib/calcsheet/cell_range.rb +54 -0
  6. data/lib/calcsheet/configuration.rb +10 -0
  7. data/lib/calcsheet/display_format.rb +75 -0
  8. data/lib/calcsheet/errors.rb +5 -0
  9. data/lib/calcsheet/formula.rb +40 -0
  10. data/lib/calcsheet/limited_inflater.rb +16 -0
  11. data/lib/calcsheet/native_backends.rb +139 -0
  12. data/lib/calcsheet/option_validation.rb +4 -1
  13. data/lib/calcsheet/raw_row.rb +1 -1
  14. data/lib/calcsheet/readers/csv_reader.rb +43 -20
  15. data/lib/calcsheet/readers/csv_records.rb +193 -0
  16. data/lib/calcsheet/readers/ods/content_parser.rb +49 -69
  17. data/lib/calcsheet/readers/ods/scanner.rb +33 -6
  18. data/lib/calcsheet/readers/ods_reader.rb +16 -1
  19. data/lib/calcsheet/readers/sax_sheet.rb +6 -48
  20. data/lib/calcsheet/readers/xlsx/sheet_parser.rb +31 -64
  21. data/lib/calcsheet/reading.rb +29 -7
  22. data/lib/calcsheet/row.rb +2 -2
  23. data/lib/calcsheet/row_builder.rb +235 -0
  24. data/lib/calcsheet/sheet_list.rb +25 -0
  25. data/lib/calcsheet/sheet_selection.rb +3 -2
  26. data/lib/calcsheet/style.rb +114 -0
  27. data/lib/calcsheet/version.rb +1 -1
  28. data/lib/calcsheet/write_book.rb +49 -17
  29. data/lib/calcsheet/write_sheet.rb +220 -18
  30. data/lib/calcsheet/writers/csv_writer.rb +18 -7
  31. data/lib/calcsheet/writers/ods_writer.rb +249 -16
  32. data/lib/calcsheet/writers/open_formula.rb +67 -0
  33. data/lib/calcsheet/writers/xlsx_writer.rb +226 -37
  34. data/lib/calcsheet/writers/zip_buffered.rb +20 -1
  35. data/lib/calcsheet.rb +31 -8
  36. metadata +11 -2
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "version"
5
+
6
+ module Calcsheet
7
+ # The reader backends of the optional calcsheet-native gem, which Calcsheet.open uses in place of the Ruby ones
8
+ # under +backend: :auto+ (when calcsheet-native is installed and compatible) and +backend: :native+ (or raises).
9
+ #
10
+ # calcsheet-native is found once per process, at the first open that asks for it: Calcsheet::Native when it is
11
+ # already defined, else +require "calcsheet/native"+ (a LoadError for that library itself means not installed; one
12
+ # for a file it requires, such as its extension, means it is unusable). Its +Calcsheet::Native::PROTOCOL+ must be an Integer of PROTOCOLS and +Calcsheet::Native.backends+ a
13
+ # Hash mapping formats among FORMATS to classes implementing the backend contract of Reading; otherwise, or when its
14
+ # code raises, it is unusable: the first +:auto+ open warns once and the Ruby backends read. A native class replaces
15
+ # only calcsheet's own reader: when an application registered another one in Calcsheet.backends for that format,
16
+ # the application's one reads.
17
+ #
18
+ # calcsheet-native depends on this interface (the protocol, +Calcsheet::Native.backends+ and the backend contract of
19
+ # Reading): change it only in a minor release and together with the protocol version.
20
+ #
21
+ # @api private
22
+ module NativeBackends
23
+ # The protocols of calcsheet-native this calcsheet supports.
24
+ PROTOCOLS = [ 1 ].freeze
25
+ # The library of calcsheet-native.
26
+ LIBRARY = "calcsheet/native"
27
+ # The formats calcsheet-native may read.
28
+ FORMATS = %i[csv xlsx ods].freeze
29
+ # What an open from the code of calcsheet-native finds while that code is being required: nothing yet.
30
+ LOADING = [ {}.freeze, "calcsheet-native is being loaded (an open from its own code)", nil ].freeze
31
+
32
+ @mutex = Mutex.new
33
+ @library = LIBRARY
34
+ @loaded = nil
35
+ @warning = nil
36
+
37
+ class << self
38
+ # @param format [Symbol] a key of Calcsheet.backends
39
+ # @param mode [Symbol] +:auto+ or +:native+
40
+ # @return [Class, nil] the native backend of +format+; nil when none applies under +:auto+
41
+ # @raise [BackendUnavailableError] under +:native+ when none applies
42
+ def backend_for(format, mode)
43
+ backends, reason, cause = loaded
44
+ reason ||= refusal(backends, format)
45
+ return backends.fetch(format) if reason.nil?
46
+ raise BackendUnavailableError, "backend: :native: #{reason}", cause: cause if mode == :native
47
+
48
+ warn_once
49
+ nil
50
+ end
51
+
52
+ # For specs: forgets what was loaded, so that the next open looks for calcsheet-native again, requiring
53
+ # +library+ if Calcsheet::Native is not defined.
54
+ # @param library [String]
55
+ # @return [void]
56
+ def reset!(library: LIBRARY)
57
+ @mutex.synchronize do
58
+ @library = library
59
+ @loaded = nil
60
+ @warning = nil
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ # @return [Array(Hash{Symbol => Class}, String, Exception)] the native backends by format and, when
67
+ # calcsheet-native cannot be used, the reason (its backends are then empty) and the error that made it
68
+ # unusable, if any
69
+ def loaded
70
+ return @loaded if @loaded
71
+ # The thread that is requiring calcsheet-native opens a book from its code: the lock cannot be taken again,
72
+ # and nothing is known yet (nor memoized).
73
+ return LOADING if @mutex.owned?
74
+
75
+ @mutex.synchronize { @loaded ||= load_native }
76
+ end
77
+
78
+ # Any error of calcsheet-native's code (its extension's initialization, a NameError, a failing backends) makes
79
+ # it unusable, once: it is not required again at the next open.
80
+ def load_native
81
+ native = native_module
82
+ return [ {}.freeze, "calcsheet-native is not installed (add it to the Gemfile)", nil ].freeze if native.nil?
83
+
84
+ # A Calcsheet::Native without an Integer PROTOCOL, or without backends, is not one this calcsheet can use.
85
+ protocol = native.const_get(:PROTOCOL, false) if native.const_defined?(:PROTOCOL, false)
86
+ unless protocol.is_a?(Integer) && PROTOCOLS.include?(protocol)
87
+ return unusable("calcsheet-native protocol #{protocol.inspect} is not supported by calcsheet #{VERSION} " \
88
+ "(supported: #{PROTOCOLS.join(", ")})")
89
+ end
90
+
91
+ backends = native.backends if native.respond_to?(:backends)
92
+ unless backends.is_a?(Hash)
93
+ return unusable("calcsheet-native protocol #{protocol} does not answer Calcsheet::Native.backends with a Hash")
94
+ end
95
+
96
+ [ backends.slice(*FORMATS).freeze, nil, nil ].freeze
97
+ rescue StandardError, ScriptError => error # a LoadError for the library itself is not an error (#native_module)
98
+ unusable("calcsheet-native cannot be loaded (#{error.class}: #{error.message})", error)
99
+ end
100
+
101
+ # @return [Array(Hash, String, Exception)] no backends, +reason+ (which the first +:auto+ open warns of) and
102
+ # +cause+
103
+ def unusable(reason, cause = nil)
104
+ @warning = "calcsheet: #{reason}; using the Ruby backends"
105
+ [ {}.freeze, reason, cause ].freeze
106
+ end
107
+
108
+ # @return [Module, nil] Calcsheet::Native; nil when the library itself cannot be found (calcsheet-native is not
109
+ # installed) or defines no Calcsheet::Native
110
+ # @raise [LoadError] when the library is found but cannot be loaded (its extension or a dependency is missing)
111
+ def native_module
112
+ require @library unless Calcsheet.const_defined?(:Native, false)
113
+ Calcsheet::Native if Calcsheet.const_defined?(:Native, false)
114
+ rescue LoadError => error
115
+ raise unless error.path == @library # a file the library requires is missing: it is installed but unusable
116
+
117
+ nil
118
+ end
119
+
120
+ # @return [String, nil] why the loaded calcsheet-native does not read +format+; nil when it does
121
+ def refusal(backends, format)
122
+ return "calcsheet-native does not read #{format}" unless backends.key?(format)
123
+ return if Calcsheet.backends[format].equal?(RUBY_BACKENDS[format])
124
+
125
+ "Calcsheet.backends[#{format.inspect}] is not the reader calcsheet-native replaces"
126
+ end
127
+
128
+ # Warns of an unusable calcsheet-native, at the first open that falls back to the Ruby backends.
129
+ def warn_once
130
+ return if @warning.nil?
131
+
132
+ @mutex.synchronize do
133
+ warn(@warning) if @warning # another thread may have warned meanwhile
134
+ @warning = nil
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -17,7 +17,9 @@ module Calcsheet
17
17
  # The zip-bomb limits: always on, so that a blank configuration value cannot switch them off.
18
18
  ZIP_LIMITS = %i[max_uncompressed_size max_zip_entries].freeze
19
19
  # The options that are true or false.
20
- BOOLEANS = %i[strip_formula_prefix excel_it sanitize_sheet_names].freeze
20
+ BOOLEANS = %i[strip_formula_prefix excel_it sanitize_sheet_names precheck_sheets].freeze
21
+ # The values of +backend:+.
22
+ BACKENDS = %i[auto ruby native].freeze
21
23
  # The decimal separators of text read as numbers and of CSV output.
22
24
  DECIMAL_SEPARATORS = [ ".", "," ].freeze
23
25
  # The thousands separators: none, ".", ",", a space, an apostrophe, the no-break space U+00A0 and the narrow
@@ -44,6 +46,7 @@ module Calcsheet
44
46
  positive(:batch_size, options[:batch_size])
45
47
  non_negative(:sample_rows, options[:sample_rows], nil_allowed: false)
46
48
  one_of(:decimals, options[:decimals], %i[bigdecimal float])
49
+ one_of(:backend, options[:backend], BACKENDS)
47
50
  TimeZone.wrap(options[:time_zone])
48
51
  separators(options[:decimal_separator], options[:thousands_separator])
49
52
  %i[date_formats datetime_formats].each { |key| formats(key, options[key]) }
@@ -5,7 +5,7 @@ module Calcsheet
5
5
  #
6
6
  # @attr_reader number [Integer] the row number the user sees
7
7
  # @attr_reader line [Integer, nil] the physical line where it starts (CSV); nil otherwise
8
- # @attr_reader values [Array] in file order, already cast, with trailing blank cells trimmed
8
+ # @attr_reader values [Array] in file order, already cast, trimmed at the end (see Reading)
9
9
  # @attr_reader errors [Array<Array(Integer, String, Symbol)>, nil] nil, or +[column_index, raw_value, reason]+ for
10
10
  # each cell whose cast failed (its value is nil), in column order
11
11
  RawRow = Data.define(:number, :line, :values, :errors) do
@@ -2,14 +2,14 @@
2
2
 
3
3
  require "csv"
4
4
  require "stringio"
5
- require_relative "../cast"
6
5
  require_relative "../errors"
7
6
  require_relative "../formula_protection"
8
- require_relative "../raw_row"
9
7
  require_relative "../reading"
8
+ require_relative "../row_builder"
10
9
  require_relative "../sheet_info"
11
10
  require_relative "csv_dialect"
12
11
  require_relative "csv_input"
12
+ require_relative "csv_records"
13
13
  require_relative "row_batches"
14
14
 
15
15
  module Calcsheet
@@ -20,7 +20,8 @@ module Calcsheet
20
20
  # @api private
21
21
  module Readers
22
22
  # The CSV reader backend (see Reading for the contract): one sheet, named after the file without its extension
23
- # ("csv" for an IO without a path), parsed by the stdlib CSV in the detected dialect.
23
+ # ("csv" for an IO without a path), parsed in the detected dialect by CsvRecords, or by the stdlib CSV when the
24
+ # sample showed no line ending.
24
25
  #
25
26
  # Rows are numbered by CSV record, counting the header and blank lines, and +line+ is the physical line where
26
27
  # the record starts. A seekable source is read again from its start whenever a request needs a record
@@ -78,6 +79,13 @@ module Calcsheet
78
79
  end
79
80
 
80
81
  # Drops the parser and the sample; the book closes the source.
82
+ # The row that would follow the last record, once a read reached the end of the file: the blank records at the
83
+ # end are rows of the file (see Reading).
84
+ # @return [Array(Integer, Integer)] its number and line
85
+ def end_row(_sheet_index)
86
+ [ @records + 1, @line ]
87
+ end
88
+
81
89
  def close
82
90
  @csv = nil
83
91
  @rows = nil
@@ -95,6 +103,7 @@ module Calcsheet
95
103
  # forced dialect or a parser that failed starts again from the first record.
96
104
  restart(forced) if @csv.nil? || start <= @records || forced != @forced
97
105
  strip = cast.options.strip_formula_prefix
106
+ builder = RowBuilder.new(cast, types)
98
107
  while (fields = shift)
99
108
  number = @records += 1
100
109
  line = @line
@@ -108,8 +117,7 @@ module Calcsheet
108
117
  last -= 1 while last >= 0 && blank_field?(fields[last], number, last)
109
118
  next if last.negative?
110
119
 
111
- fields = fields.first(last + 1) if last + 1 < fields.size
112
- yield raw_row(number, line, fields, cast, types, strip)
120
+ yield raw_row(builder, number, line, fields, last, strip)
113
121
  end
114
122
  end
115
123
 
@@ -125,13 +133,24 @@ module Calcsheet
125
133
  @forced = forced
126
134
  end
127
135
  @started = true
128
- @csv = CSV.new(input, col_sep: @dialect.col_sep, quote_char: @dialect.quote_char, row_sep: @dialect.row_sep)
136
+ @csv = records(input)
129
137
  @stray = nil
130
138
  @rows = nil
131
139
  @records = 0
132
140
  @line = 1
133
141
  end
134
142
 
143
+ # The parser of the records: CsvRecords for a line ending the sample showed, the stdlib parser, which finds the
144
+ # line ending itself, otherwise.
145
+ def records(input)
146
+ dialect = @dialect
147
+ if dialect.row_sep.is_a?(String)
148
+ CsvRecords.new(input, col_sep: dialect.col_sep, quote_char: dialect.quote_char, row_sep: dialect.row_sep)
149
+ else
150
+ CSV.new(input, col_sep: dialect.col_sep, quote_char: dialect.quote_char, row_sep: dialect.row_sep)
151
+ end
152
+ end
153
+
135
154
  # The text the parser reads: the source after the byte order mark, or the sample (consumed here) and the rest of
136
155
  # the stream.
137
156
  def input
@@ -204,25 +223,29 @@ module Calcsheet
204
223
  raise ArgumentError, "encoding: cannot read #{value.inspect} (#{error.message})"
205
224
  end
206
225
 
207
- def raw_row(number, line, fields, cast, types, strip)
208
- errors = nil
209
- # Array.new with a block rather than each_with_index.map, which allocates an Enumerator per row.
210
- values = Array.new(fields.size) do |index|
226
+ # The row of a record whose last non-blank field is at +last+: the builder casts the fields, so that the row
227
+ # equals those of the other readers. A field is checked and unprotected first, when a limit or
228
+ # +strip_formula_prefix+ asks for it.
229
+ def raw_row(builder, number, line, fields, last, strip)
230
+ prepare(fields, last, number, strip) if strip || @max_cell_bytes
231
+ builder.text_row(fields, last, number, line: line)
232
+ end
233
+
234
+ # Checks the size of the fields up to +last+ and removes the formula protection from them, in place.
235
+ def prepare(fields, last, number, strip)
236
+ index = 0
237
+ while index <= last
211
238
  field = fields[index]
212
- next if field.nil? || field.empty?
213
-
214
- check_size(field, number, index) if @max_cell_bytes && field.bytesize > @max_cell_bytes
215
- field = FormulaProtection.unprotect(field) if strip
216
- cast.text(field, types&.[](index))
217
- rescue Cast::Failure => failure
218
- (errors ||= []) << [ index, field, failure.reason ]
219
- nil
239
+ unless field.nil?
240
+ check_size(field, number, index) if @max_cell_bytes && field.bytesize > @max_cell_bytes
241
+ fields[index] = FormulaProtection.unprotect(field) if strip
242
+ end
243
+ index += 1
220
244
  end
221
- RawRow.new(number: number, line: line, values: values, errors: errors)
222
245
  end
223
246
 
224
247
  # Every field is measured before a regexp sees it: here those from the end to the last non-blank one, the
225
- # others in raw_row.
248
+ # others in #prepare.
226
249
  def blank_field?(field, number, index)
227
250
  return true if field.nil?
228
251
 
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module Calcsheet
6
+ module Readers
7
+ # The records of a CSV text, as the stdlib CSV parser gives them with +liberal_parsing: false+, for a known line
8
+ # ending: the same fields (nil for an empty unquoted one, "" for an empty quoted one), the same records ([] for an
9
+ # empty line) and a CSV::MalformedCSVError with the same message where it refuses a record. The stdlib parser is
10
+ # general (converters, headers, skipped lines...) and costs about half of a CSV read; a record without the quote
11
+ # character is split in one call here, and one with it is scanned field by field.
12
+ #
13
+ # It stands in for the CSV object of CsvReader: #each gives an object whose #next is the next record, #line the
14
+ # raw text of the record #next returned, #row_sep the line ending.
15
+ #
16
+ # @api private
17
+ class CsvRecords
18
+ # The text read from the input at a time, so that a long record is gathered in linear time.
19
+ CHUNK_BYTES = 64 * 1024
20
+
21
+ # @return [String] the line ending
22
+ attr_reader :row_sep
23
+ # @return [String, nil] the raw text of the last record, line ending included
24
+ attr_reader :line
25
+
26
+ # @param input [#gets] UTF-8 text (CsvInput)
27
+ # @param col_sep [String] the separator
28
+ # @param quote_char [String] the quote character
29
+ # @param row_sep [String] "\n", "\r\n" or "\r"
30
+ def initialize(input, col_sep:, quote_char:, row_sep:)
31
+ @input = input
32
+ @col_sep = col_sep
33
+ @quote = quote_char
34
+ @row_sep = row_sep
35
+ # The line break that is not the line ending, which an unquoted field keeps (none in a CRLF text).
36
+ @other_break = { "\n" => "\r", "\r" => "\n" }[row_sep]
37
+ @records = 0
38
+ @line = nil
39
+ @eof = false
40
+ end
41
+
42
+ # @return [CsvRecords] self: #next gives the records
43
+ def each
44
+ self
45
+ end
46
+
47
+ # @return [Array<String, nil>] the fields of the next record
48
+ # @raise [StopIteration] at the end
49
+ # @raise [CSV::MalformedCSVError] for a record the stdlib parser refuses
50
+ # @raise [EncodingError] from the input
51
+ def next
52
+ text = read_line or raise StopIteration
53
+ fields = plain(text) || quoted(text)
54
+ @records += 1
55
+ fields
56
+ end
57
+
58
+ private
59
+
60
+ # The next line, ending with the line ending unless the text ends first; nil at the end.
61
+ def read_line
62
+ return if @eof
63
+
64
+ line = @input.gets(@row_sep, CHUNK_BYTES)
65
+ return @eof = nil if line.nil?
66
+
67
+ until line.end_with?(@row_sep)
68
+ more = @input.gets(@row_sep, CHUNK_BYTES)
69
+ if more.nil?
70
+ @eof = true
71
+ break
72
+ end
73
+ line << more
74
+ end
75
+ line
76
+ end
77
+
78
+ # The fields of a line without the quote character or a line break inside it (but the other line break, which
79
+ # an unquoted field keeps): a split. nil for another line.
80
+ def plain(text)
81
+ return if text.include?(@quote)
82
+
83
+ body = text.end_with?(@row_sep) ? text.byteslice(0, text.bytesize - @row_sep.bytesize) : text
84
+ return if @other_break.nil? ? body.include?("\r") || body.include?("\n") : body.include?(@row_sep)
85
+
86
+ @line = text
87
+ fields = body.split(@col_sep, -1)
88
+ fields.map! { _1.empty? ? nil : _1 }
89
+ end
90
+
91
+ # The fields of a record that holds the quote character or a stray line break, scanned field by field, reading
92
+ # further lines while a quoted field is open.
93
+ def quoted(text)
94
+ loop do
95
+ result = parse(text)
96
+ case result
97
+ when Array
98
+ @line = text
99
+ return result
100
+ when String then raise CSV::MalformedCSVError.new(result, @records + 1)
101
+ end
102
+
103
+ more = read_line
104
+ return finish_unclosed(text) if more.nil?
105
+
106
+ text << more
107
+ end
108
+ end
109
+
110
+ # A quoted field still open at the end of the text.
111
+ def finish_unclosed(text)
112
+ @eof = true
113
+ result = parse(text, at_end: true)
114
+ raise CSV::MalformedCSVError.new(result, @records + 1) if result.is_a?(String)
115
+
116
+ @line = text
117
+ result
118
+ end
119
+
120
+ # The record that +text+ holds: its fields, the stdlib's message for a malformed one, or :more when a quoted
121
+ # field is still open and the text may go on. The text ends with the line ending, or is the end of the input.
122
+ def parse(text, at_end: @eof)
123
+ size = text.bytesize
124
+ ending = text.end_with?(@row_sep) ? size - @row_sep.bytesize : size
125
+ fields = []
126
+ position = 0
127
+ return fields if ending.zero? && size.positive?
128
+
129
+ loop do
130
+ if at?(text, position, @quote)
131
+ close = position + @quote.bytesize
132
+ value = nil
133
+ loop do
134
+ found = text.byteindex(@quote, close)
135
+ return at_end ? "Unclosed quoted field" : :more if found.nil?
136
+
137
+ piece = text.byteslice(close, found - close)
138
+ after = found + @quote.bytesize
139
+ if at?(text, after, @quote)
140
+ # A doubled quote: the field is joined from its pieces.
141
+ (value ||= +"") << piece << @quote
142
+ close = after + @quote.bytesize
143
+ next
144
+ end
145
+ # The closing quote: what follows it must end the field or the record.
146
+ fields << (value ? value << piece : piece)
147
+ return fields if after == size || after == ending
148
+ return "Any value after quoted field isn't allowed" unless at?(text, after, @col_sep)
149
+
150
+ position = after + @col_sep.bytesize
151
+ break
152
+ end
153
+ next
154
+ end
155
+
156
+ stop = text.byteindex(@col_sep, position)
157
+ stop = ending if stop.nil? || stop > ending
158
+ field = text.byteslice(position, stop - position)
159
+ if (message = refusal(field))
160
+ return message
161
+ end
162
+
163
+ fields << (field.empty? ? nil : field)
164
+ return fields if stop == ending
165
+
166
+ position = stop + @col_sep.bytesize
167
+ end
168
+ end
169
+
170
+ # Whether +token+ is at +position+ of +text+; a one-byte token (the usual separator and quote) is compared as a
171
+ # byte, without a slice.
172
+ def at?(text, position, token)
173
+ return text.getbyte(position) == token.getbyte(0) if token.bytesize == 1
174
+
175
+ text.byteslice(position, token.bytesize) == token
176
+ end
177
+
178
+ # The stdlib's message for an unquoted field it refuses, by the first character it stops at: a quote, or in a
179
+ # CRLF text a line break of another ending (named a wrong line ending when it starts the field).
180
+ def refusal(field)
181
+ quote = field.index(@quote)
182
+ line_break = field.index(/[\r\n]/) if @other_break.nil?
183
+ return if quote.nil? && line_break.nil?
184
+ return "Illegal quoting" if line_break.nil? || (quote && quote < line_break)
185
+
186
+ shown = field[line_break] == "\n" ? "\\n" : "\\r"
187
+ return %(New line must be <"\\r\\n"> not <"#{shown}">) if line_break.zero?
188
+
189
+ %(Unquoted fields do not allow new line <"#{shown}">)
190
+ end
191
+ end
192
+ end
193
+ end