csv_plus_plus 0.1.0 → 0.1.2

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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -1
  3. data/README.md +18 -62
  4. data/lib/csv_plus_plus/benchmarked_compiler.rb +62 -0
  5. data/lib/csv_plus_plus/can_define_references.rb +88 -0
  6. data/lib/csv_plus_plus/can_resolve_references.rb +8 -0
  7. data/lib/csv_plus_plus/cell.rb +3 -3
  8. data/lib/csv_plus_plus/cli.rb +24 -7
  9. data/lib/csv_plus_plus/color.rb +12 -6
  10. data/lib/csv_plus_plus/compiler.rb +156 -0
  11. data/lib/csv_plus_plus/data_validation.rb +138 -0
  12. data/lib/csv_plus_plus/{language → entities}/ast_builder.rb +5 -7
  13. data/lib/csv_plus_plus/entities/boolean.rb +31 -0
  14. data/lib/csv_plus_plus/{language → entities}/builtins.rb +2 -4
  15. data/lib/csv_plus_plus/entities/cell_reference.rb +60 -0
  16. data/lib/csv_plus_plus/entities/date.rb +30 -0
  17. data/lib/csv_plus_plus/entities/entity.rb +84 -0
  18. data/lib/csv_plus_plus/entities/function.rb +33 -0
  19. data/lib/csv_plus_plus/entities/function_call.rb +35 -0
  20. data/lib/csv_plus_plus/entities/number.rb +34 -0
  21. data/lib/csv_plus_plus/entities/runtime_value.rb +26 -0
  22. data/lib/csv_plus_plus/entities/string.rb +29 -0
  23. data/lib/csv_plus_plus/entities/variable.rb +25 -0
  24. data/lib/csv_plus_plus/entities.rb +33 -0
  25. data/lib/csv_plus_plus/error/error.rb +10 -0
  26. data/lib/csv_plus_plus/error/formula_syntax_error.rb +36 -0
  27. data/lib/csv_plus_plus/error/modifier_syntax_error.rb +27 -0
  28. data/lib/csv_plus_plus/error/modifier_validation_error.rb +49 -0
  29. data/lib/csv_plus_plus/{language → error}/syntax_error.rb +6 -14
  30. data/lib/csv_plus_plus/error/writer_error.rb +9 -0
  31. data/lib/csv_plus_plus/error.rb +9 -2
  32. data/lib/csv_plus_plus/expand.rb +3 -1
  33. data/lib/csv_plus_plus/google_api_client.rb +4 -0
  34. data/lib/csv_plus_plus/lexer/lexer.rb +19 -11
  35. data/lib/csv_plus_plus/modifier/conditional_formatting.rb +17 -0
  36. data/lib/csv_plus_plus/modifier.rb +73 -70
  37. data/lib/csv_plus_plus/options.rb +3 -0
  38. data/lib/csv_plus_plus/parser/cell_value.tab.rb +305 -0
  39. data/lib/csv_plus_plus/parser/code_section.tab.rb +410 -0
  40. data/lib/csv_plus_plus/parser/modifier.tab.rb +484 -0
  41. data/lib/csv_plus_plus/references.rb +68 -0
  42. data/lib/csv_plus_plus/row.rb +0 -3
  43. data/lib/csv_plus_plus/runtime.rb +199 -0
  44. data/lib/csv_plus_plus/scope.rb +196 -0
  45. data/lib/csv_plus_plus/template.rb +21 -5
  46. data/lib/csv_plus_plus/validated_modifier.rb +164 -0
  47. data/lib/csv_plus_plus/version.rb +1 -1
  48. data/lib/csv_plus_plus/writer/file_backer_upper.rb +6 -4
  49. data/lib/csv_plus_plus/writer/google_sheet_builder.rb +24 -29
  50. data/lib/csv_plus_plus/writer/google_sheet_modifier.rb +33 -12
  51. data/lib/csv_plus_plus/writer/rubyxl_builder.rb +3 -6
  52. data/lib/csv_plus_plus.rb +41 -16
  53. metadata +34 -24
  54. data/lib/csv_plus_plus/code_section.rb +0 -68
  55. data/lib/csv_plus_plus/language/benchmarked_compiler.rb +0 -65
  56. data/lib/csv_plus_plus/language/cell_value.tab.rb +0 -332
  57. data/lib/csv_plus_plus/language/code_section.tab.rb +0 -442
  58. data/lib/csv_plus_plus/language/compiler.rb +0 -157
  59. data/lib/csv_plus_plus/language/entities/boolean.rb +0 -33
  60. data/lib/csv_plus_plus/language/entities/cell_reference.rb +0 -33
  61. data/lib/csv_plus_plus/language/entities/entity.rb +0 -86
  62. data/lib/csv_plus_plus/language/entities/function.rb +0 -35
  63. data/lib/csv_plus_plus/language/entities/function_call.rb +0 -26
  64. data/lib/csv_plus_plus/language/entities/number.rb +0 -36
  65. data/lib/csv_plus_plus/language/entities/runtime_value.rb +0 -28
  66. data/lib/csv_plus_plus/language/entities/string.rb +0 -31
  67. data/lib/csv_plus_plus/language/entities/variable.rb +0 -25
  68. data/lib/csv_plus_plus/language/entities.rb +0 -28
  69. data/lib/csv_plus_plus/language/references.rb +0 -70
  70. data/lib/csv_plus_plus/language/runtime.rb +0 -205
  71. data/lib/csv_plus_plus/language/scope.rb +0 -188
  72. data/lib/csv_plus_plus/modifier.tab.rb +0 -907
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CSVPlusPlus
4
+ # The runtime state of the compiler (the current +line_number+/+row_index+, +cell+ being processed, etc) for parsing
5
+ # a given file. We take multiple runs through the input file for parsing so it's really convenient to have a
6
+ # central place for these things to be managed.
7
+ #
8
+ # @attr_reader filename [String, nil] The filename that the input came from (mostly used for debugging since
9
+ # +filename+ can be +nil+ if it's read from stdin.
10
+ # @attr_reader length_of_code_section [Integer] The length (count of lines) of the code section part of the original
11
+ # input.
12
+ # @attr_reader length_of_csv_section [Integer] The length (count of lines) of the CSV part of the original csvpp
13
+ # input.
14
+ # @attr_reader length_of_original_file [Integer] The length (count of lines) of the original csvpp input.
15
+ #
16
+ # @attr cell [Cell] The current cell being processed
17
+ # @attr cell_index [Integer] The index of the current cell being processed (starts at 0)
18
+ # @attr row_index [Integer] The index of the current row being processed (starts at 0)
19
+ # @attr line_number [Integer] The line number of the original csvpp template (starts at 1)
20
+ class Runtime
21
+ attr_reader :filename, :length_of_code_section, :length_of_csv_section, :length_of_original_file
22
+
23
+ attr_accessor :cell, :cell_index, :row_index, :line_number
24
+
25
+ # @param input [String] The input to be parsed
26
+ # @param filename [String, nil] The filename that the input came from (mostly used for debugging since +filename+
27
+ # can be +nil+ if it's read from stdin
28
+ def initialize(input:, filename:)
29
+ @filename = filename || 'stdin'
30
+
31
+ init_input!(input)
32
+ start!
33
+ end
34
+
35
+ # Map over an a csvpp file and keep track of line_number and row_index
36
+ #
37
+ # @param lines [Array]
38
+ #
39
+ # @return [Array]
40
+ def map_lines(lines, &block)
41
+ @line_number = 1
42
+ lines.map do |line|
43
+ block.call(line).tap { next_line! }
44
+ end
45
+ end
46
+
47
+ # Map over a single row and keep track of the cell and it's index
48
+ #
49
+ # @param row [Array<Cell>] The row to map each cell over
50
+ #
51
+ # @return [Array]
52
+ def map_row(row, &block)
53
+ @cell_index = 0
54
+ row.map.with_index do |cell, index|
55
+ set_cell!(cell, index)
56
+ block.call(cell, index)
57
+ end
58
+ end
59
+
60
+ # Map over all rows and keep track of row and line numbers
61
+ #
62
+ # @param rows [Array<Row>] The rows to map over (and keep track of indexes)
63
+ # @param cells_too [boolean] If the cells of each +row+ should be iterated over also.
64
+ #
65
+ # @return [Array]
66
+ def map_rows(rows, cells_too: false, &block)
67
+ @row_index = 0
68
+ map_lines(rows) do |row|
69
+ if cells_too
70
+ # it's either CSV or a Row object
71
+ map_row((row.is_a?(::CSVPlusPlus::Row) ? row.cells : row), &block)
72
+ else
73
+ block.call(row)
74
+ end
75
+ end
76
+ end
77
+
78
+ # Increment state to the next line
79
+ #
80
+ # @return [Integer]
81
+ def next_line!
82
+ @row_index += 1 unless @row_index.nil?
83
+ @line_number += 1
84
+ end
85
+
86
+ # Return the current spreadsheet row number. It parallels +@row_index+ but starts at 1.
87
+ #
88
+ # @return [Integer, nil]
89
+ def rownum
90
+ return if @row_index.nil?
91
+
92
+ @row_index + 1
93
+ end
94
+
95
+ # Set the current cell and index
96
+ #
97
+ # @param cell [Cell] The current cell
98
+ # @param cell_index [Integer] The index of the cell
99
+ def set_cell!(cell, cell_index)
100
+ @cell = cell
101
+ @cell_index = cell_index
102
+ end
103
+
104
+ # Each time we run a parse on the input, reset the runtime state starting at the beginning of the file
105
+ def start!
106
+ @row_index = @cell_index = nil
107
+ @line_number = 1
108
+ end
109
+
110
+ # Reset the runtime state starting at the CSV section
111
+ def start_at_csv!
112
+ # TODO: isn't the input re-written anyway without the code section? why do we need this?
113
+ start!
114
+ @line_number = @length_of_code_section || 1
115
+ end
116
+
117
+ # @return [String]
118
+ def to_s
119
+ "Runtime(cell: #{@cell}, row_index: #{@row_index}, cell_index: #{@cell_index})"
120
+ end
121
+
122
+ # Get the current (entity) value of a runtime value
123
+ #
124
+ # @param var_id [String, Symbol] The Variable#id of the variable being resolved.
125
+ #
126
+ # @return [Entity]
127
+ def runtime_value(var_id)
128
+ if runtime_variable?(var_id)
129
+ ::CSVPlusPlus::Entities::Builtins::VARIABLES[var_id.to_sym].resolve_fn.call(self)
130
+ else
131
+ raise_formula_syntax_error('Undefined variable', var_id)
132
+ end
133
+ end
134
+
135
+ # Is +var_id+ a runtime variable? (it's a static variable otherwise)
136
+ #
137
+ # @param var_id [String, Symbol] The Variable#id to check if it's a runtime variable
138
+ #
139
+ # @return [boolean]
140
+ def runtime_variable?(var_id)
141
+ ::CSVPlusPlus::Entities::Builtins::VARIABLES.key?(var_id.to_sym)
142
+ end
143
+
144
+ # Called when an error is encoutered during parsing. It will construct a useful
145
+ # error with the current +@row/@cell_index+, +@line_number+ and +@filename+
146
+ #
147
+ # @param message [String] A message relevant to why this error is being raised.
148
+ # @param bad_input [String] The offending input that caused this error to be thrown.
149
+ # @param wrapped_error [StandardError, nil] The underlying error that was raised (if it's not from our own logic)
150
+ def raise_formula_syntax_error(message, bad_input, wrapped_error: nil)
151
+ raise(::CSVPlusPlus::Error::FormulaSyntaxError.new(message, bad_input, self, wrapped_error:))
152
+ end
153
+
154
+ # The currently available input for parsing. The tmp state will be re-written
155
+ # between parsing the code section and the CSV section
156
+ #
157
+ # @return [String]
158
+ def input
159
+ @tmp
160
+ end
161
+
162
+ # We mutate the input over and over. It's ok because it's just a Tempfile
163
+ #
164
+ # @param data [String] The data to rewrite our input file to
165
+ def rewrite_input!(data)
166
+ @tmp.truncate(0)
167
+ @tmp.write(data)
168
+ @tmp.rewind
169
+ end
170
+
171
+ # Clean up the Tempfile we're using for parsing
172
+ def cleanup!
173
+ return unless @tmp
174
+
175
+ @tmp.close
176
+ @tmp.unlink
177
+ @tmp = nil
178
+ end
179
+
180
+ private
181
+
182
+ def count_code_section_lines(lines)
183
+ eoc = ::CSVPlusPlus::Lexer::END_OF_CODE_SECTION
184
+ lines.include?(eoc) ? (lines.take_while { |l| l != eoc }).length + 1 : 0
185
+ end
186
+
187
+ def init_input!(input)
188
+ lines = (input || '').split(/\s*\n\s*/)
189
+ @length_of_original_file = lines.length
190
+ @length_of_code_section = count_code_section_lines(lines)
191
+ @length_of_csv_section = @length_of_original_file - @length_of_code_section
192
+
193
+ # we're gonna take our input file, write it to a tmp file then each
194
+ # step is gonna mutate that tmp file
195
+ @tmp = ::Tempfile.new
196
+ rewrite_input!(input)
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'can_define_references'
4
+ require_relative 'entities'
5
+ require_relative 'graph'
6
+ require_relative 'references'
7
+
8
+ module CSVPlusPlus
9
+ # A class representing the scope of the current Template and responsible for resolving variables
10
+ #
11
+ # @attr_reader functions [Hash<Symbol, Function>] The currently functions defined
12
+ # @attr_reader runtime [Runtime] The compiler's current runtime
13
+ # @attr_reader variables [Hash<Symbol, Entity>] The currently defined variables
14
+ #
15
+ # rubocop:disable Metrics/ClassLength
16
+ class Scope
17
+ include ::CSVPlusPlus::CanDefineReferences
18
+ # TODO: split out a CanResolveReferences
19
+
20
+ attr_reader :functions, :runtime, :variables
21
+
22
+ # @param runtime [Runtime]
23
+ def initialize(runtime:, functions: {}, variables: {})
24
+ @runtime = runtime
25
+ @functions = functions
26
+ @variables = variables
27
+ end
28
+
29
+ # Resolve all values in the ast of the current cell being processed
30
+ #
31
+ # @return [Entity]
32
+ def resolve_cell_value
33
+ return unless (ast = @runtime.cell&.ast)
34
+
35
+ last_round = nil
36
+ loop do
37
+ refs = ::CSVPlusPlus::References.extract(ast, self)
38
+ return ast if refs.empty?
39
+
40
+ # TODO: throw an error here instead I think - basically we did a round and didn't make progress
41
+ return ast if last_round == refs
42
+
43
+ ast = resolve_functions(resolve_variables(ast, refs.variables), refs.functions)
44
+ end
45
+ end
46
+
47
+ # Bind +var_id+ to the current cell (based on where +@runtime+ is currently pointing).
48
+ #
49
+ # @param var_id [Symbol] The name of the variable to bind the cell reference to
50
+ #
51
+ # @return [CellReference]
52
+ def bind_variable_to_cell(var_id)
53
+ ::CSVPlusPlus::Entities::CellReference.from_index(
54
+ cell_index: runtime.cell_index,
55
+ row_index: runtime.row_index
56
+ ).tap do |cell_reference|
57
+ def_variable(var_id, cell_reference)
58
+ end
59
+ end
60
+
61
+ # @return [String]
62
+ def to_s
63
+ "Scope(functions: #{@functions}, runtime: #{@runtime}, variables: #{@variables})"
64
+ end
65
+
66
+ private
67
+
68
+ # Resolve all variable references defined statically in the code section
69
+ # TODO: experiment with getting rid of this - does it even play correctly with runtime vars?
70
+ def resolve_static_variables!
71
+ last_var_dependencies = {}
72
+ loop do
73
+ var_dependencies, resolution_order = variable_resolution_order(only_static_vars(variables))
74
+ return if var_dependencies == last_var_dependencies
75
+
76
+ # TODO: make the contract better here
77
+ @variables = resolve_dependencies(var_dependencies, resolution_order, variables)
78
+ last_var_dependencies = var_dependencies.clone
79
+ end
80
+ end
81
+
82
+ def only_static_vars(var_dependencies)
83
+ var_dependencies.reject { |k| @runtime.runtime_variable?(k) }
84
+ end
85
+
86
+ def resolve_functions(ast, refs)
87
+ refs.reduce(ast.dup) do |acc, elem|
88
+ function_replace(acc, elem.id, resolve_function(elem.id))
89
+ end
90
+ end
91
+
92
+ def resolve_variables(ast, refs)
93
+ refs.reduce(ast.dup) do |acc, elem|
94
+ variable_replace(acc, elem.id, resolve_variable(elem.id))
95
+ end
96
+ end
97
+
98
+ # Make a copy of the AST represented by +node+ and replace +fn_id+ with +replacement+ throughout
99
+ # rubocop:disable Metrics/MethodLength
100
+ def function_replace(node, fn_id, replacement)
101
+ if node.function_call? && node.id == fn_id
102
+ call_function_or_runtime_value(replacement, node)
103
+ elsif node.function_call?
104
+ # not our function, but continue our depth first search on it
105
+ ::CSVPlusPlus::Entities::FunctionCall.new(
106
+ node.id,
107
+ node.arguments.map { |n| function_replace(n, fn_id, replacement) },
108
+ infix: node.infix
109
+ )
110
+ else
111
+ node
112
+ end
113
+ end
114
+ # rubocop:enable Metrics/MethodLength
115
+
116
+ def resolve_function(fn_id)
117
+ id = fn_id.to_sym
118
+ return functions[id] if defined_function?(id)
119
+
120
+ ::CSVPlusPlus::Entities::Builtins::FUNCTIONS[id]
121
+ end
122
+
123
+ def call_function_or_runtime_value(function_or_runtime_value, function_call)
124
+ if function_or_runtime_value.function?
125
+ call_function(function_or_runtime_value, function_call)
126
+ else
127
+ function_or_runtime_value.resolve_fn.call(@runtime, function_call.arguments)
128
+ end
129
+ end
130
+
131
+ def call_function(function, function_call)
132
+ i = 0
133
+ function.arguments.reduce(function.body.dup) do |ast, argument|
134
+ variable_replace(ast, argument, function_call.arguments[i]).tap do
135
+ i += 1
136
+ end
137
+ end
138
+ end
139
+
140
+ # Make a copy of the AST represented by +node+ and replace +var_id+ with +replacement+ throughout
141
+ def variable_replace(node, var_id, replacement)
142
+ if node.function_call?
143
+ arguments = node.arguments.map { |n| variable_replace(n, var_id, replacement) }
144
+ # TODO: refactor these places where we copy functions... it's brittle with the kwargs
145
+ ::CSVPlusPlus::Entities::FunctionCall.new(node.id, arguments, infix: node.infix)
146
+ elsif node.variable? && node.id == var_id
147
+ replacement
148
+ else
149
+ node
150
+ end
151
+ end
152
+
153
+ def resolve_variable(var_id)
154
+ id = var_id.to_sym
155
+ return variables[id] if defined_variable?(id)
156
+
157
+ # this will throw a syntax error if it doesn't exist (which is what we want)
158
+ @runtime.runtime_value(id)
159
+ end
160
+
161
+ def check_unbound_vars(dependencies, variables)
162
+ unbound_vars = dependencies.values.flatten - variables.keys
163
+ return if unbound_vars.empty?
164
+
165
+ @runtime.raise_formula_syntax_error('Undefined variables', unbound_vars.map(&:to_s).join(', '))
166
+ end
167
+
168
+ def variable_resolution_order(variables)
169
+ # we have a hash of variables => ASTs but they might have references to each other, so
170
+ # we need to interpolate them first (before interpolating the cell values)
171
+ var_dependencies = ::CSVPlusPlus::Graph.dependency_graph(variables, @runtime)
172
+ # are there any references that we don't have variables for? (undefined variable)
173
+ check_unbound_vars(var_dependencies, variables)
174
+
175
+ # a topological sort will give us the order of dependencies
176
+ [var_dependencies, ::CSVPlusPlus::Graph.topological_sort(var_dependencies)]
177
+ # TODO: don't expose this exception directly to the caller
178
+ rescue ::TSort::Cyclic
179
+ @runtime.raise_formula_syntax_error('Cyclic variable dependency detected', var_refs.keys)
180
+ end
181
+
182
+ def resolve_dependencies(var_dependencies, resolution_order, variables)
183
+ {}.tap do |resolved_vars|
184
+ # for each var and each dependency it has, build up and mutate resolved_vars
185
+ resolution_order.each do |var|
186
+ resolved_vars[var] = variables[var].dup
187
+
188
+ var_dependencies[var].each do |dependency|
189
+ resolved_vars[var] = variable_replace(resolved_vars[var], dependency, variables[dependency])
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
195
+ # rubocop:enable Metrics/ClassLength
196
+ end
@@ -1,23 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CSVPlusPlus
4
- # Contains the flow and data from a code section and CSV section
4
+ # Contains the data from a parsed csvpp template.
5
5
  #
6
6
  # @attr_reader rows [Array<Row>] The +Row+s that comprise this +Template+
7
+ # @attr_reader scope [Scope] The +Scope+ containing all function and variable references
7
8
  class Template
8
- attr_reader :rows
9
+ attr_reader :rows, :scope
9
10
 
10
11
  # @param rows [Array<Row>] The +Row+s that comprise this +Template+
11
- def initialize(rows:)
12
+ # @param scope [Scope] The +Scope+ containing all function and variable references
13
+ def initialize(rows:, scope:)
14
+ @scope = scope
12
15
  @rows = rows
13
16
  end
14
17
 
15
18
  # @return [String]
16
19
  def to_s
17
- "Template(rows: #{@rows})"
20
+ "Template(rows: #{@rows}, scope: #{@scope})"
18
21
  end
19
22
 
20
23
  # Apply any expand= modifiers to the parsed template
24
+ #
21
25
  # @return [Array<Row>]
22
26
  def expand_rows!
23
27
  expanded_rows = []
@@ -40,12 +44,24 @@ module CSVPlusPlus
40
44
  infinite_expand_rows = @rows.filter { |r| r.modifier.expand&.infinite? }
41
45
  return unless infinite_expand_rows.length > 1
42
46
 
43
- runtime.raise_syntax_error(
47
+ runtime.raise_formula_syntax_error(
44
48
  'You can only have one infinite expand= (on all others you must specify an amount)',
45
49
  infinite_expand_rows[1]
46
50
  )
47
51
  end
48
52
 
53
+ # Provide a summary of the state of the template (and it's +@scope+)
54
+ #
55
+ # @return [String]
56
+ def verbose_summary
57
+ # TODO: we can probably include way more stats in here
58
+ <<~SUMMARY
59
+ #{@scope.verbose_summary}
60
+
61
+ > #{@rows.length} rows to be written
62
+ SUMMARY
63
+ end
64
+
49
65
  private
50
66
 
51
67
  def expand_rows(push_row_fn)
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'data_validation'
4
+ require_relative 'modifier'
5
+
6
+ module CSVPlusPlus
7
+ # Validates and coerces modifier values as they are parsed.
8
+ #
9
+ # Previously this logic was handled in the parser's grammar, but with the introduction of variable binding, the
10
+ # grammar is no longer context free so we need the parser to be a little looser on what it accepts and validate it
11
+ # here. Having this layer is also nice because we can provide better error messages to the user for what went
12
+ # wrong during the parse.
13
+ class ValidatedModifier < ::CSVPlusPlus::Modifier
14
+ # Validates that +border+ is 'all', 'top', 'bottom', 'left' or 'right'.
15
+ #
16
+ # @param value [String] The unvalidated user input
17
+ def border=(value)
18
+ super(one_of(:border, value, %i[all top bottom left right]))
19
+ end
20
+
21
+ # Validates that +bordercolor+ is a hex color.
22
+ #
23
+ # @param value [String] The unvalidated user input
24
+ def bordercolor=(value)
25
+ super(color_value(:bordercolor, value))
26
+ end
27
+
28
+ # Validates that +borderstyle+ is 'dashed', 'dotted', 'double', 'solid', 'solid_medium' or 'solid_thick'.
29
+ #
30
+ # @param value [String] The unvalidated user input
31
+ def borderstyle=(value)
32
+ super(one_of(:borderstyle, value, %i[dashed dotted double solid solid_medium solid_thick]))
33
+ end
34
+
35
+ # Validates that +color+ is a hex color.
36
+ #
37
+ # @param value [String] The unvalidated user input
38
+ def color=(value)
39
+ super(color_value(:color, value))
40
+ end
41
+
42
+ # Validates that +expand+ is a positive integer.
43
+ #
44
+ # @param value [String] The unvalidated user input
45
+ def expand=(value)
46
+ super(::CSVPlusPlus::Expand.new(positive_integer(:expand, value)))
47
+ end
48
+
49
+ # Validates that +fontcolor+ is a hex color.
50
+ #
51
+ # @param value [String] The unvalidated user input
52
+ def fontcolor=(value)
53
+ super(color_value(:fontcolor, value))
54
+ end
55
+
56
+ # Validates that +fontcolor+ is a hex color.
57
+ def fontfamily=(value)
58
+ super(matches_regexp(:fontfamily, unquote(value), /^[\w\s]+$/, 'It is not a valid font family.'))
59
+ end
60
+
61
+ # Validates that +fontsize+ is a positive integer
62
+ #
63
+ # @param value [String] The unvalidated user input
64
+ def fontsize=(value)
65
+ super(positive_integer(:fontsize, value))
66
+ end
67
+
68
+ # Validates that +format+ is 'bold', 'italic', 'strikethrough' or 'underline'.
69
+ #
70
+ # @param value [String] The unvalidated user input
71
+ def format=(value)
72
+ super(one_of(:format, value, %i[bold italic strikethrough underline]))
73
+ end
74
+
75
+ # Validates that +halign+ is 'left', 'center' or 'right'.
76
+ #
77
+ # @param value [String] The unvalidated user input
78
+ def halign=(value)
79
+ super(one_of(:halign, value, %i[left center right]))
80
+ end
81
+
82
+ # Validates that +note+ is a quoted string.
83
+ #
84
+ # @param value [String] The unvalidated user input
85
+
86
+ # Validates that +numberformat+ is 'currency', 'date', 'date_time', 'number', 'percent', 'text', 'time' or
87
+ # 'scientific'.
88
+ #
89
+ # @param value [String] The unvalidated user input
90
+ def numberformat=(value)
91
+ super(one_of(:nubmerformat, value, %i[currency date date_time number percent text time scientific]))
92
+ end
93
+
94
+ # Validates that +valign+ is 'top', 'center' or 'bottom'.
95
+ #
96
+ # @param value [String] The unvalidated user input
97
+ def valign=(value)
98
+ super(one_of(:valign, value, %i[top center bottom]))
99
+ end
100
+
101
+ # Validates that the conditional validating rules are well-formed.
102
+ #
103
+ # Pretty much based off of the Google Sheets API spec here:
104
+ #
105
+ # @param value [String] The unvalidated user input
106
+ def validation=(value)
107
+ super(a_data_validation(:validation, value))
108
+ end
109
+
110
+ # Validates +variable+ is a valid variable identifier.
111
+ #
112
+ # @param value [String] The unvalidated user input
113
+ def var=(value)
114
+ # TODO: I need a shared definition of what a variable can be (I guess the :ID token)
115
+ super(matches_regexp(:var, value, /^\w+$/, 'It must be a sequence of letters, numbers and _.').to_sym)
116
+ end
117
+
118
+ private
119
+
120
+ # XXX centralize this :(((
121
+ def unquote(str)
122
+ # TODO: I'm pretty sure this isn't sufficient and we need to deal with the backslashes
123
+ str.gsub(/^['\s]*|['\s]*$/, '')
124
+ end
125
+
126
+ def a_data_validation(modifier, value)
127
+ data_validation = ::CSVPlusPlus::DataValidation.new(value)
128
+ return data_validation unless data_validation.valid?
129
+
130
+ raise_error(modifier, value, message: data_validation.invalid_reason)
131
+ end
132
+
133
+ def color_value(modifier, value)
134
+ unless ::CSVPlusPlus::Color.valid_hex_string?(value)
135
+ raise_error(modifier, value, message: 'It must be a 3 or 6 digit hex code.')
136
+ end
137
+
138
+ ::CSVPlusPlus::Color.new(value)
139
+ end
140
+
141
+ def matches_regexp(modifier, value, regexp, message)
142
+ raise_error(modifier, value, message:) unless value =~ regexp
143
+ value
144
+ end
145
+
146
+ def one_of(modifier, value, choices)
147
+ value.downcase.to_sym.tap do |v|
148
+ raise_error(modifier, value, choices:) unless choices.include?(v)
149
+ end
150
+ end
151
+
152
+ def positive_integer(modifier, value)
153
+ Integer(value, 10).tap do |i|
154
+ raise_error(modifier, value, message: 'It must be positive and greater than 0.') unless i.positive?
155
+ end
156
+ rescue ::ArgumentError
157
+ raise_error(modifier, value, message: 'It must be a valid (whole) number.')
158
+ end
159
+
160
+ def raise_error(modifier, bad_input, choices: nil, message: nil)
161
+ raise(::CSVPlusPlus::Error::ModifierValidationError.new(modifier, bad_input, choices:, message:))
162
+ end
163
+ end
164
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CSVPlusPlus
4
- VERSION = '0.1.0'
4
+ VERSION = '0.1.2'
5
5
  public_constant :VERSION
6
6
  end
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'fileutils'
4
- require 'pathname'
5
-
6
3
  module CSVPlusPlus
7
4
  module Writer
8
5
  # A module that can be mixed into any Writer that needs to back up it's @output_filename (all of them except Google
@@ -26,6 +23,7 @@ module CSVPlusPlus
26
23
 
27
24
  private
28
25
 
26
+ # rubocop:disable Metrics/MethodLength
29
27
  def attempt_backups
30
28
  attempted =
31
29
  # rubocop:disable Lint/ConstantResolution
@@ -39,8 +37,12 @@ module CSVPlusPlus
39
37
  return backed_up_to
40
38
  end
41
39
 
42
- raise(::CSVPlusPlus::Error, "Unable to write backup file despite trying these: #{attempted.join(', ')}")
40
+ raise(
41
+ ::CSVPlusPlus::Error::WriterError,
42
+ "Unable to write backup file despite trying these: #{attempted.join(', ')}"
43
+ )
43
44
  end
45
+ # rubocop:enable Metrics/MethodLength
44
46
 
45
47
  def backup(filename)
46
48
  return if ::File.exist?(filename)