csv_plus_plus 0.1.2 → 0.1.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -2
  3. data/{CHANGELOG.md → docs/CHANGELOG.md} +9 -0
  4. data/lib/csv_plus_plus/benchmarked_compiler.rb +70 -20
  5. data/lib/csv_plus_plus/cell.rb +46 -24
  6. data/lib/csv_plus_plus/cli.rb +23 -13
  7. data/lib/csv_plus_plus/cli_flag.rb +1 -2
  8. data/lib/csv_plus_plus/color.rb +32 -7
  9. data/lib/csv_plus_plus/compiler.rb +82 -60
  10. data/lib/csv_plus_plus/entities/ast_builder.rb +27 -43
  11. data/lib/csv_plus_plus/entities/boolean.rb +18 -9
  12. data/lib/csv_plus_plus/entities/builtins.rb +23 -9
  13. data/lib/csv_plus_plus/entities/cell_reference.rb +200 -29
  14. data/lib/csv_plus_plus/entities/date.rb +38 -5
  15. data/lib/csv_plus_plus/entities/entity.rb +27 -61
  16. data/lib/csv_plus_plus/entities/entity_with_arguments.rb +57 -0
  17. data/lib/csv_plus_plus/entities/function.rb +23 -11
  18. data/lib/csv_plus_plus/entities/function_call.rb +24 -9
  19. data/lib/csv_plus_plus/entities/number.rb +24 -10
  20. data/lib/csv_plus_plus/entities/runtime_value.rb +22 -5
  21. data/lib/csv_plus_plus/entities/string.rb +19 -6
  22. data/lib/csv_plus_plus/entities/variable.rb +16 -4
  23. data/lib/csv_plus_plus/entities.rb +20 -13
  24. data/lib/csv_plus_plus/error/error.rb +11 -1
  25. data/lib/csv_plus_plus/error/formula_syntax_error.rb +1 -0
  26. data/lib/csv_plus_plus/error/modifier_syntax_error.rb +53 -5
  27. data/lib/csv_plus_plus/error/modifier_validation_error.rb +34 -14
  28. data/lib/csv_plus_plus/error/syntax_error.rb +22 -9
  29. data/lib/csv_plus_plus/error/writer_error.rb +8 -0
  30. data/lib/csv_plus_plus/error.rb +1 -0
  31. data/lib/csv_plus_plus/google_api_client.rb +7 -2
  32. data/lib/csv_plus_plus/google_options.rb +23 -18
  33. data/lib/csv_plus_plus/lexer/lexer.rb +8 -4
  34. data/lib/csv_plus_plus/lexer/tokenizer.rb +6 -1
  35. data/lib/csv_plus_plus/lexer.rb +24 -0
  36. data/lib/csv_plus_plus/modifier/conditional_formatting.rb +1 -0
  37. data/lib/csv_plus_plus/modifier/data_validation.rb +138 -0
  38. data/lib/csv_plus_plus/modifier/expand.rb +61 -0
  39. data/lib/csv_plus_plus/modifier/google_sheet_modifier.rb +133 -0
  40. data/lib/csv_plus_plus/modifier/modifier.rb +222 -0
  41. data/lib/csv_plus_plus/modifier/modifier_validator.rb +243 -0
  42. data/lib/csv_plus_plus/modifier/rubyxl_modifier.rb +84 -0
  43. data/lib/csv_plus_plus/modifier.rb +82 -158
  44. data/lib/csv_plus_plus/options.rb +64 -19
  45. data/lib/csv_plus_plus/parser/cell_value.tab.rb +5 -5
  46. data/lib/csv_plus_plus/parser/code_section.tab.rb +8 -13
  47. data/lib/csv_plus_plus/parser/modifier.tab.rb +17 -23
  48. data/lib/csv_plus_plus/row.rb +53 -12
  49. data/lib/csv_plus_plus/runtime/can_define_references.rb +87 -0
  50. data/lib/csv_plus_plus/runtime/can_resolve_references.rb +209 -0
  51. data/lib/csv_plus_plus/runtime/graph.rb +68 -0
  52. data/lib/csv_plus_plus/runtime/position_tracker.rb +231 -0
  53. data/lib/csv_plus_plus/runtime/references.rb +110 -0
  54. data/lib/csv_plus_plus/runtime/runtime.rb +126 -0
  55. data/lib/csv_plus_plus/runtime.rb +34 -191
  56. data/lib/csv_plus_plus/source_code.rb +66 -0
  57. data/lib/csv_plus_plus/template.rb +62 -35
  58. data/lib/csv_plus_plus/version.rb +2 -1
  59. data/lib/csv_plus_plus/writer/base_writer.rb +30 -5
  60. data/lib/csv_plus_plus/writer/csv.rb +11 -9
  61. data/lib/csv_plus_plus/writer/excel.rb +9 -2
  62. data/lib/csv_plus_plus/writer/file_backer_upper.rb +1 -0
  63. data/lib/csv_plus_plus/writer/google_sheet_builder.rb +71 -23
  64. data/lib/csv_plus_plus/writer/google_sheets.rb +79 -29
  65. data/lib/csv_plus_plus/writer/open_document.rb +6 -1
  66. data/lib/csv_plus_plus/writer/rubyxl_builder.rb +103 -30
  67. data/lib/csv_plus_plus/writer.rb +39 -9
  68. data/lib/csv_plus_plus.rb +29 -12
  69. metadata +18 -14
  70. data/lib/csv_plus_plus/can_define_references.rb +0 -88
  71. data/lib/csv_plus_plus/can_resolve_references.rb +0 -8
  72. data/lib/csv_plus_plus/data_validation.rb +0 -138
  73. data/lib/csv_plus_plus/expand.rb +0 -20
  74. data/lib/csv_plus_plus/graph.rb +0 -62
  75. data/lib/csv_plus_plus/references.rb +0 -68
  76. data/lib/csv_plus_plus/scope.rb +0 -196
  77. data/lib/csv_plus_plus/validated_modifier.rb +0 -164
  78. data/lib/csv_plus_plus/writer/google_sheet_modifier.rb +0 -77
  79. data/lib/csv_plus_plus/writer/rubyxl_modifier.rb +0 -59
@@ -1,196 +0,0 @@
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,164 +0,0 @@
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,77 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module CSVPlusPlus
4
- module Writer
5
- # Decorate a Modifier so it can be written to the Google Sheets API
6
- class GoogleSheetModifier < ::SimpleDelegator
7
- # Format the border for Google Sheets
8
- #
9
- # @return [Google::Apis::SheetsV4::Border]
10
- def border
11
- # TODO: allow different border styles per side
12
- ::Google::Apis::SheetsV4::Border.new(
13
- color: bordercolor&.to_s || '#000000',
14
- style: borderstyle&.to_s || 'solid'
15
- )
16
- end
17
-
18
- # Format the color for Google Sheets
19
- #
20
- # @return [Google::Apis::SheetsV4::Color]
21
- def color
22
- google_sheets_color(super) if super
23
- end
24
-
25
- # Format the fontcolor for Google Sheets
26
- #
27
- # @return [Google::Apis::SheetsV4::Color]
28
- def fontcolor
29
- google_sheets_color(super) if super
30
- end
31
-
32
- # Format the halign for Google Sheets
33
- #
34
- # @return [String]
35
- def halign
36
- super&.to_s&.upcase
37
- end
38
-
39
- # Format the numberformat for Google Sheets
40
- #
41
- # @return [::Google::Apis::SheetsV4::NumberFormat]
42
- def numberformat
43
- ::Google::Apis::SheetsV4::NumberFormat.new(type: super) if super
44
- end
45
-
46
- # Builds a SheetsV4::TextFormat with the underlying Modifier
47
- #
48
- # @return [::Google::Apis::SheetsV4::TextFormat]
49
- def text_format
50
- ::Google::Apis::SheetsV4::TextFormat.new(
51
- bold: formatted?(:bold) || nil,
52
- italic: formatted?(:italic) || nil,
53
- strikethrough: formatted?(:strikethrough) || nil,
54
- underline: formatted?(:underline) || nil,
55
- font_family: fontfamily,
56
- font_size: fontsize,
57
- foreground_color: fontcolor
58
- )
59
- end
60
-
61
- # Format the valign for Google Sheets
62
- def valign
63
- super&.to_s&.upcase
64
- end
65
-
66
- private
67
-
68
- def google_sheets_color(color)
69
- ::Google::Apis::SheetsV4::Color.new(
70
- red: color.red_percent,
71
- green: color.green_percent,
72
- blue: color.blue_percent
73
- )
74
- end
75
- end
76
- end
77
- end
@@ -1,59 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module CSVPlusPlus
4
- module Writer
5
- # Build a RubyXL-decorated Modifier class adds some support for Excel
6
- class RubyXLModifier < ::SimpleDelegator
7
- # https://www.rubydoc.info/gems/rubyXL/RubyXL/NumberFormats
8
- # https://support.microsoft.com/en-us/office/number-format-codes-5026bbd6-04bc-48cd-bf33-80f18b4eae68
9
- NUM_FMT_IDS = {
10
- currency: 5,
11
- date: 14,
12
- date_time: 22,
13
- number: 1,
14
- percent: 9,
15
- text: 49,
16
- time: 21,
17
- scientific: 48
18
- }.freeze
19
- private_constant :NUM_FMT_IDS
20
-
21
- # https://www.rubydoc.info/gems/rubyXL/2.3.0/RubyXL
22
- # ST_BorderStyle = %w{ none thin medium dashed dotted thick double hair mediumDashed dashDot mediumDashDot
23
- # dashDotDot slantDashDot }
24
- BORDER_STYLES = {
25
- dashed: 'dashed',
26
- dotted: 'dotted',
27
- double: 'double',
28
- solid: 'thin',
29
- solid_medium: 'medium',
30
- solid_thick: 'thick'
31
- }.freeze
32
- private_constant :BORDER_STYLES
33
-
34
- # The excel-specific border weight
35
- #
36
- # @return [Integer]
37
- def border_weight
38
- return unless borderstyle
39
-
40
- # rubocop:disable Lint/ConstantResolution
41
- BORDER_STYLES[borderstyle.to_sym]
42
- # rubocop:enable Lint/ConstantResolution
43
- end
44
-
45
- # The excel-specific number format code
46
- #
47
- # @return [String]
48
- def number_format_code
49
- return unless numberformat
50
-
51
- ::RubyXL::NumberFormats::DEFAULT_NUMBER_FORMATS.find_by_format_id(
52
- # rubocop:disable Lint/ConstantResolution
53
- NUM_FMT_IDS[numberformat.to_sym]
54
- # rubocop:enable Lint/ConstantResolution
55
- ).format_code
56
- end
57
- end
58
- end
59
- end