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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +16 -1
- data/README.md +18 -62
- data/lib/csv_plus_plus/benchmarked_compiler.rb +62 -0
- data/lib/csv_plus_plus/can_define_references.rb +88 -0
- data/lib/csv_plus_plus/can_resolve_references.rb +8 -0
- data/lib/csv_plus_plus/cell.rb +3 -3
- data/lib/csv_plus_plus/cli.rb +24 -7
- data/lib/csv_plus_plus/color.rb +12 -6
- data/lib/csv_plus_plus/compiler.rb +156 -0
- data/lib/csv_plus_plus/data_validation.rb +138 -0
- data/lib/csv_plus_plus/{language → entities}/ast_builder.rb +5 -7
- data/lib/csv_plus_plus/entities/boolean.rb +31 -0
- data/lib/csv_plus_plus/{language → entities}/builtins.rb +2 -4
- data/lib/csv_plus_plus/entities/cell_reference.rb +60 -0
- data/lib/csv_plus_plus/entities/date.rb +30 -0
- data/lib/csv_plus_plus/entities/entity.rb +84 -0
- data/lib/csv_plus_plus/entities/function.rb +33 -0
- data/lib/csv_plus_plus/entities/function_call.rb +35 -0
- data/lib/csv_plus_plus/entities/number.rb +34 -0
- data/lib/csv_plus_plus/entities/runtime_value.rb +26 -0
- data/lib/csv_plus_plus/entities/string.rb +29 -0
- data/lib/csv_plus_plus/entities/variable.rb +25 -0
- data/lib/csv_plus_plus/entities.rb +33 -0
- data/lib/csv_plus_plus/error/error.rb +10 -0
- data/lib/csv_plus_plus/error/formula_syntax_error.rb +36 -0
- data/lib/csv_plus_plus/error/modifier_syntax_error.rb +27 -0
- data/lib/csv_plus_plus/error/modifier_validation_error.rb +49 -0
- data/lib/csv_plus_plus/{language → error}/syntax_error.rb +6 -14
- data/lib/csv_plus_plus/error/writer_error.rb +9 -0
- data/lib/csv_plus_plus/error.rb +9 -2
- data/lib/csv_plus_plus/expand.rb +3 -1
- data/lib/csv_plus_plus/google_api_client.rb +4 -0
- data/lib/csv_plus_plus/lexer/lexer.rb +19 -11
- data/lib/csv_plus_plus/modifier/conditional_formatting.rb +17 -0
- data/lib/csv_plus_plus/modifier.rb +73 -70
- data/lib/csv_plus_plus/options.rb +3 -0
- data/lib/csv_plus_plus/parser/cell_value.tab.rb +305 -0
- data/lib/csv_plus_plus/parser/code_section.tab.rb +410 -0
- data/lib/csv_plus_plus/parser/modifier.tab.rb +484 -0
- data/lib/csv_plus_plus/references.rb +68 -0
- data/lib/csv_plus_plus/row.rb +0 -3
- data/lib/csv_plus_plus/runtime.rb +199 -0
- data/lib/csv_plus_plus/scope.rb +196 -0
- data/lib/csv_plus_plus/template.rb +21 -5
- data/lib/csv_plus_plus/validated_modifier.rb +164 -0
- data/lib/csv_plus_plus/version.rb +1 -1
- data/lib/csv_plus_plus/writer/file_backer_upper.rb +6 -4
- data/lib/csv_plus_plus/writer/google_sheet_builder.rb +24 -29
- data/lib/csv_plus_plus/writer/google_sheet_modifier.rb +33 -12
- data/lib/csv_plus_plus/writer/rubyxl_builder.rb +3 -6
- data/lib/csv_plus_plus.rb +41 -16
- metadata +34 -24
- data/lib/csv_plus_plus/code_section.rb +0 -68
- data/lib/csv_plus_plus/language/benchmarked_compiler.rb +0 -65
- data/lib/csv_plus_plus/language/cell_value.tab.rb +0 -332
- data/lib/csv_plus_plus/language/code_section.tab.rb +0 -442
- data/lib/csv_plus_plus/language/compiler.rb +0 -157
- data/lib/csv_plus_plus/language/entities/boolean.rb +0 -33
- data/lib/csv_plus_plus/language/entities/cell_reference.rb +0 -33
- data/lib/csv_plus_plus/language/entities/entity.rb +0 -86
- data/lib/csv_plus_plus/language/entities/function.rb +0 -35
- data/lib/csv_plus_plus/language/entities/function_call.rb +0 -26
- data/lib/csv_plus_plus/language/entities/number.rb +0 -36
- data/lib/csv_plus_plus/language/entities/runtime_value.rb +0 -28
- data/lib/csv_plus_plus/language/entities/string.rb +0 -31
- data/lib/csv_plus_plus/language/entities/variable.rb +0 -25
- data/lib/csv_plus_plus/language/entities.rb +0 -28
- data/lib/csv_plus_plus/language/references.rb +0 -70
- data/lib/csv_plus_plus/language/runtime.rb +0 -205
- data/lib/csv_plus_plus/language/scope.rb +0 -188
- 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
|
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
|
-
|
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.
|
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,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(
|
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)
|