csv_plus_plus 0.1.0 → 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
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)