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.
- 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)
|