csv_plus_plus 0.1.2 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +9 -5
  3. data/{CHANGELOG.md → docs/CHANGELOG.md} +25 -0
  4. data/lib/csv_plus_plus/a1_reference.rb +202 -0
  5. data/lib/csv_plus_plus/benchmarked_compiler.rb +70 -20
  6. data/lib/csv_plus_plus/cell.rb +29 -41
  7. data/lib/csv_plus_plus/cli.rb +53 -80
  8. data/lib/csv_plus_plus/cli_flag.rb +71 -71
  9. data/lib/csv_plus_plus/color.rb +32 -7
  10. data/lib/csv_plus_plus/compiler.rb +98 -66
  11. data/lib/csv_plus_plus/entities/ast_builder.rb +30 -39
  12. data/lib/csv_plus_plus/entities/boolean.rb +26 -10
  13. data/lib/csv_plus_plus/entities/builtins.rb +66 -24
  14. data/lib/csv_plus_plus/entities/date.rb +42 -6
  15. data/lib/csv_plus_plus/entities/entity.rb +17 -69
  16. data/lib/csv_plus_plus/entities/entity_with_arguments.rb +44 -0
  17. data/lib/csv_plus_plus/entities/function.rb +34 -11
  18. data/lib/csv_plus_plus/entities/function_call.rb +49 -10
  19. data/lib/csv_plus_plus/entities/has_identifier.rb +19 -0
  20. data/lib/csv_plus_plus/entities/number.rb +30 -11
  21. data/lib/csv_plus_plus/entities/reference.rb +77 -0
  22. data/lib/csv_plus_plus/entities/runtime_value.rb +43 -13
  23. data/lib/csv_plus_plus/entities/string.rb +23 -7
  24. data/lib/csv_plus_plus/entities.rb +7 -16
  25. data/lib/csv_plus_plus/error/cli_error.rb +17 -0
  26. data/lib/csv_plus_plus/error/compiler_error.rb +17 -0
  27. data/lib/csv_plus_plus/error/error.rb +25 -2
  28. data/lib/csv_plus_plus/error/formula_syntax_error.rb +12 -12
  29. data/lib/csv_plus_plus/error/modifier_syntax_error.rb +34 -12
  30. data/lib/csv_plus_plus/error/modifier_validation_error.rb +21 -27
  31. data/lib/csv_plus_plus/error/positional_error.rb +15 -0
  32. data/lib/csv_plus_plus/error/writer_error.rb +8 -0
  33. data/lib/csv_plus_plus/error.rb +5 -1
  34. data/lib/csv_plus_plus/error_formatter.rb +111 -0
  35. data/lib/csv_plus_plus/google_api_client.rb +25 -10
  36. data/lib/csv_plus_plus/lexer/racc_lexer.rb +144 -0
  37. data/lib/csv_plus_plus/lexer/tokenizer.rb +58 -17
  38. data/lib/csv_plus_plus/lexer.rb +64 -1
  39. data/lib/csv_plus_plus/modifier/conditional_formatting.rb +1 -0
  40. data/lib/csv_plus_plus/modifier/data_validation.rb +138 -0
  41. data/lib/csv_plus_plus/modifier/expand.rb +78 -0
  42. data/lib/csv_plus_plus/modifier/google_sheet_modifier.rb +133 -0
  43. data/lib/csv_plus_plus/modifier/modifier.rb +222 -0
  44. data/lib/csv_plus_plus/modifier/modifier_validator.rb +243 -0
  45. data/lib/csv_plus_plus/modifier/rubyxl_modifier.rb +84 -0
  46. data/lib/csv_plus_plus/modifier.rb +89 -160
  47. data/lib/csv_plus_plus/options/file_options.rb +49 -0
  48. data/lib/csv_plus_plus/options/google_sheets_options.rb +42 -0
  49. data/lib/csv_plus_plus/options/options.rb +97 -0
  50. data/lib/csv_plus_plus/options.rb +34 -77
  51. data/lib/csv_plus_plus/parser/cell_value.tab.rb +66 -67
  52. data/lib/csv_plus_plus/parser/code_section.tab.rb +86 -83
  53. data/lib/csv_plus_plus/parser/modifier.tab.rb +57 -53
  54. data/lib/csv_plus_plus/reader/csv.rb +50 -0
  55. data/lib/csv_plus_plus/reader/google_sheets.rb +129 -0
  56. data/lib/csv_plus_plus/reader/reader.rb +27 -0
  57. data/lib/csv_plus_plus/reader/rubyxl.rb +37 -0
  58. data/lib/csv_plus_plus/reader.rb +14 -0
  59. data/lib/csv_plus_plus/row.rb +53 -12
  60. data/lib/csv_plus_plus/runtime/graph.rb +68 -0
  61. data/lib/csv_plus_plus/runtime/position.rb +242 -0
  62. data/lib/csv_plus_plus/runtime/references.rb +115 -0
  63. data/lib/csv_plus_plus/runtime/runtime.rb +132 -0
  64. data/lib/csv_plus_plus/runtime/scope.rb +280 -0
  65. data/lib/csv_plus_plus/runtime.rb +34 -191
  66. data/lib/csv_plus_plus/source_code.rb +71 -0
  67. data/lib/csv_plus_plus/template.rb +71 -39
  68. data/lib/csv_plus_plus/version.rb +2 -1
  69. data/lib/csv_plus_plus/writer/csv.rb +37 -8
  70. data/lib/csv_plus_plus/writer/excel.rb +25 -5
  71. data/lib/csv_plus_plus/writer/file_backer_upper.rb +27 -13
  72. data/lib/csv_plus_plus/writer/google_sheets.rb +29 -85
  73. data/lib/csv_plus_plus/writer/google_sheets_builder.rb +179 -0
  74. data/lib/csv_plus_plus/writer/merger.rb +31 -0
  75. data/lib/csv_plus_plus/writer/open_document.rb +21 -2
  76. data/lib/csv_plus_plus/writer/rubyxl_builder.rb +140 -42
  77. data/lib/csv_plus_plus/writer/writer.rb +42 -0
  78. data/lib/csv_plus_plus/writer.rb +79 -10
  79. data/lib/csv_plus_plus.rb +47 -18
  80. metadata +50 -21
  81. data/lib/csv_plus_plus/can_define_references.rb +0 -88
  82. data/lib/csv_plus_plus/can_resolve_references.rb +0 -8
  83. data/lib/csv_plus_plus/data_validation.rb +0 -138
  84. data/lib/csv_plus_plus/entities/cell_reference.rb +0 -60
  85. data/lib/csv_plus_plus/entities/variable.rb +0 -25
  86. data/lib/csv_plus_plus/error/syntax_error.rb +0 -58
  87. data/lib/csv_plus_plus/expand.rb +0 -20
  88. data/lib/csv_plus_plus/google_options.rb +0 -27
  89. data/lib/csv_plus_plus/graph.rb +0 -62
  90. data/lib/csv_plus_plus/lexer/lexer.rb +0 -85
  91. data/lib/csv_plus_plus/references.rb +0 -68
  92. data/lib/csv_plus_plus/scope.rb +0 -196
  93. data/lib/csv_plus_plus/validated_modifier.rb +0 -164
  94. data/lib/csv_plus_plus/writer/base_writer.rb +0 -20
  95. data/lib/csv_plus_plus/writer/google_sheet_builder.rb +0 -147
  96. data/lib/csv_plus_plus/writer/google_sheet_modifier.rb +0 -77
  97. data/lib/csv_plus_plus/writer/rubyxl_modifier.rb +0 -59
@@ -0,0 +1,144 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module CSVPlusPlus
5
+ module Lexer
6
+ # TODO: ugh clean this up
7
+ RaccToken =
8
+ ::T.type_alias do
9
+ ::T.any(
10
+ [::String, ::Symbol],
11
+ [::Symbol, ::String],
12
+ [::String, ::String],
13
+ [::Symbol, ::Symbol],
14
+ [::FalseClass, ::FalseClass]
15
+ )
16
+ end
17
+ public_constant :RaccToken
18
+
19
+ # Common methods to be mixed into the Racc parsers
20
+ #
21
+ # @attr_reader tokens [Array]
22
+ module RaccLexer
23
+ extend ::T::Sig
24
+ extend ::T::Helpers
25
+ extend ::T::Generic
26
+ include ::Kernel
27
+
28
+ abstract!
29
+
30
+ ReturnType = type_member
31
+ public_constant :ReturnType
32
+
33
+ sig { returns(::T::Array[::CSVPlusPlus::Lexer::RaccToken]) }
34
+ attr_reader :tokens
35
+
36
+ sig { params(tokens: ::T::Array[::CSVPlusPlus::Lexer::RaccToken]).void }
37
+ # Initialize a lexer instance with an empty +@tokens+
38
+ def initialize(tokens: [])
39
+ @tokens = ::T.let(tokens, ::T::Array[::CSVPlusPlus::Lexer::RaccToken])
40
+ end
41
+
42
+ sig { returns(::T.nilable(::CSVPlusPlus::Lexer::RaccToken)) }
43
+ # Used by racc to iterate each token
44
+ #
45
+ # @return [Array<(Regexp, Symbol) | (false, false)>]
46
+ def next_token
47
+ @tokens.shift
48
+ end
49
+
50
+ sig { params(input: ::String).returns(::CSVPlusPlus::Lexer::RaccLexer::ReturnType) }
51
+ # Orchestate the tokenizing, parsing and error handling of parsing input. Each instance will implement their own
52
+ # +#tokenizer+ method
53
+ #
54
+ # @return [RaccLexer#] Each instance will define it's own +return_value+ with the result of parsing
55
+ # rubocop:disable Metrics/MethodLength
56
+ def parse(input)
57
+ return return_value unless anything_to_parse?(input)
58
+
59
+ tokenize(input)
60
+ do_parse
61
+ return_value
62
+ rescue ::Racc::ParseError => e
63
+ raise(
64
+ ::CSVPlusPlus::Error::FormulaSyntaxError.new(
65
+ "Error parsing #{parse_subject}",
66
+ bad_input: e.message,
67
+ wrapped_error: e
68
+ )
69
+ )
70
+ end
71
+ # rubocop:enable Metrics/MethodLength
72
+
73
+ protected
74
+
75
+ sig { abstract.params(input: ::String).returns(::T::Boolean) }
76
+ # Is the input even worth parsing? for example we don't want to parse cells unless they're a formula (start
77
+ # with '=')
78
+ #
79
+ # @param input [String]
80
+ #
81
+ # @return [Boolean]
82
+ def anything_to_parse?(input); end
83
+
84
+ sig { abstract.returns(::String) }
85
+ # Used for error messages, what is the thing being parsed? ("cell value", "modifier", "code section")
86
+ def parse_subject; end
87
+
88
+ sig { abstract.returns(::CSVPlusPlus::Lexer::RaccLexer::ReturnType) }
89
+ # The output of the parser
90
+ def return_value; end
91
+
92
+ sig { abstract.returns(::CSVPlusPlus::Lexer::Tokenizer) }
93
+ # Returns a +Lexer::Tokenizer+ configured for the given
94
+ def tokenizer; end
95
+
96
+ private
97
+
98
+ sig { params(input: ::String).void }
99
+ def tokenize(input)
100
+ return if input.nil?
101
+
102
+ t = tokenizer.scan(input)
103
+
104
+ until t.scanner.empty?
105
+ next if t.matches_ignore?
106
+
107
+ return if t.stop?
108
+
109
+ t.scan_tokens!
110
+ consume_token(t)
111
+ end
112
+
113
+ @tokens << %i[EOL EOL]
114
+ end
115
+
116
+ sig { params(tokenizer: ::CSVPlusPlus::Lexer::Tokenizer).void }
117
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
118
+ def consume_token(tokenizer)
119
+ if tokenizer.last_token&.token && tokenizer.last_match
120
+ @tokens << [::T.must(tokenizer.last_token).token, ::T.must(tokenizer.last_match)]
121
+ elsif tokenizer.scan_catchall
122
+ @tokens << [::T.must(tokenizer.last_match), ::T.must(tokenizer.last_match)]
123
+ # TODO: checking the +parse_subject+ like this is a little hacky... but we need to know if we're parsing
124
+ # modifiers or code_section (or formulas in a cell)
125
+ elsif parse_subject == 'modifier'
126
+ raise(
127
+ ::CSVPlusPlus::Error::ModifierSyntaxError.new(
128
+ "Unable to parse #{parse_subject} starting at",
129
+ bad_input: tokenizer.peek
130
+ )
131
+ )
132
+ else
133
+ raise(
134
+ ::CSVPlusPlus::Error::FormulaSyntaxError.new(
135
+ "Unable to parse #{parse_subject} starting at",
136
+ bad_input: tokenizer.peek
137
+ )
138
+ )
139
+ end
140
+ end
141
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
142
+ end
143
+ end
144
+ end
@@ -1,19 +1,33 @@
1
+ # typed: strict
1
2
  # frozen_string_literal: true
2
3
 
3
- require 'strscan'
4
-
5
4
  module CSVPlusPlus
6
5
  module Lexer
7
6
  # A class that contains the use-case-specific regexes for parsing
8
7
  #
9
- # @attr_reader last_token [String] The last token that's been matched.
10
- # @attr_reader scanner [StringScanner] The StringScanner instance that's parsing the input.
8
+ # @attr_reader last_token [String, nil] The last token that's been matched.
11
9
  class Tokenizer
12
- attr_reader :last_token, :scanner
10
+ extend ::T::Sig
11
+
12
+ sig { returns(::T.nilable(::CSVPlusPlus::Lexer::Token)) }
13
+ attr_reader :last_token
13
14
 
14
- # @param input [String]
15
+ sig do
16
+ params(
17
+ tokens: ::T::Enumerable[::CSVPlusPlus::Lexer::Token],
18
+ catchall: ::T.nilable(::Regexp),
19
+ ignore: ::T.nilable(::Regexp),
20
+ alter_matches: ::T::Hash[::Symbol, ::T.proc.params(s: ::String).returns(::String)],
21
+ stop_fn: ::T.nilable(::T.proc.params(s: ::StringScanner).returns(::T::Boolean))
22
+ ).void
23
+ end
24
+ # @param tokens [Array<Regexp, String>] The list of tokens to scan
25
+ # @param catchall [Regexp] A final regexp to try if nothing else matches
26
+ # @param ignore [Regexp] Ignore anything matching this regexp
27
+ # @param alter_matches [Object] A map of matches to alter
28
+ # @param stop_fn [Proc] Stop parsing when this is true
15
29
  def initialize(tokens:, catchall: nil, ignore: nil, alter_matches: {}, stop_fn: nil)
16
- @last_token = nil
30
+ @last_token = ::T.let(nil, ::T.nilable(::CSVPlusPlus::Lexer::Token))
17
31
 
18
32
  @catchall = catchall
19
33
  @ignore = ignore
@@ -22,67 +36,94 @@ module CSVPlusPlus
22
36
  @alter_matches = alter_matches
23
37
  end
24
38
 
39
+ sig { params(input: ::String).returns(::T.self_type) }
25
40
  # Initializers a scanner for the given input to be parsed
26
41
  #
27
42
  # @param input The input to be tokenized
43
+ #
28
44
  # @return [Tokenizer]
29
45
  def scan(input)
30
- @scanner = ::StringScanner.new(input.strip)
46
+ @scanner = ::T.let(::StringScanner.new(input.strip), ::T.nilable(::StringScanner))
31
47
  self
32
48
  end
33
49
 
50
+ sig { returns(::StringScanner) }
51
+ # Returns the currently initialized +StringScanner+. You must call +#scan+ first or else this will throw an
52
+ # exception.
53
+ #
54
+ # @return [StringScanner]
55
+ def scanner
56
+ # The caller needs to initialize this class with a call to #scan before we can do anything. it sets up the
57
+ # +@scanner+ with it's necessary input.
58
+ unless @scanner
59
+ raise(::CSVPlusPlus::Error::CompilerError, 'Called Tokenizer#scanner without calling #scan first')
60
+ end
61
+
62
+ @scanner
63
+ end
64
+
65
+ sig { void }
34
66
  # Scan tokens and set +@last_token+ if any match
35
67
  #
36
68
  # @return [String, nil]
37
69
  def scan_tokens!
38
- m = @tokens.find { |t| @scanner.scan(t.first) }
39
- @last_token = m ? m[1] : nil
70
+ @last_token = @tokens.find { |t| scanner.scan(t.regexp) }
40
71
  end
41
72
 
73
+ sig { returns(::T.nilable(::String)) }
42
74
  # Scan input against the catchall pattern
43
75
  #
44
76
  # @return [String, nil]
45
77
  def scan_catchall
46
- @scanner.scan(@catchall) if @catchall
78
+ scanner.scan(@catchall) if @catchall
47
79
  end
48
80
 
81
+ sig { returns(::T.nilable(::String)) }
49
82
  # Scan input against the ignore pattern
50
83
  #
51
84
  # @return [boolean]
52
85
  def matches_ignore?
53
- @scanner.scan(@ignore) if @ignore
86
+ scanner.scan(@ignore) if @ignore
54
87
  end
55
88
 
89
+ sig { returns(::T.nilable(::String)) }
56
90
  # The value of the last token matched
57
91
  #
58
92
  # @return [String, nil]
59
93
  def last_match
60
- return @alter_matches[@last_token].call(@scanner.matched) if @alter_matches.key?(@last_token)
94
+ # rubocop:disable Style/MissingElse
95
+ if @last_token && @alter_matches.key?(@last_token.token.to_sym)
96
+ # rubocop:enable Style/MissingElse
97
+ return ::T.must(@alter_matches[@last_token.token.to_sym]).call(scanner.matched)
98
+ end
61
99
 
62
- @scanner.matched
100
+ scanner.matched
63
101
  end
64
102
 
103
+ sig { params(peek_characters: ::Integer).returns(::String) }
65
104
  # Read the input but don't consume it
66
105
  #
67
106
  # @param peek_characters [Integer]
68
107
  #
69
108
  # @return [String]
70
109
  def peek(peek_characters: 100)
71
- @scanner.peek(peek_characters)
110
+ scanner.peek(peek_characters)
72
111
  end
73
112
 
113
+ sig { returns(::T::Boolean) }
74
114
  # Scan for our stop token (if there is one - some parsers stop early and some don't)
75
115
  #
76
116
  # @return [boolean]
77
117
  def stop?
78
- @stop_fn ? @stop_fn.call(@scanner) : false
118
+ @stop_fn ? @stop_fn.call(scanner) : false
79
119
  end
80
120
 
121
+ sig { returns(::String) }
81
122
  # The rest of the un-parsed input. The tokenizer might not need to parse the entire input
82
123
  #
83
124
  # @return [String]
84
125
  def rest
85
- @scanner.rest
126
+ scanner.rest
86
127
  end
87
128
  end
88
129
  end
@@ -1,14 +1,77 @@
1
+ # typed: strict
1
2
  # frozen_string_literal: true
2
3
 
3
- require_relative './lexer/lexer'
4
+ require_relative './lexer/racc_lexer'
4
5
  require_relative './lexer/tokenizer'
5
6
 
6
7
  module CSVPlusPlus
8
+ # Code for tokenizing a csvpp file
7
9
  module Lexer
10
+ extend ::T::Sig
11
+
12
+ # A token that's matched by +regexp+ and presented with +token+
13
+ class Token < ::T::Struct
14
+ const :regexp, ::Regexp
15
+ const :token, ::T.any(::String, ::Symbol)
16
+ end
17
+
8
18
  END_OF_CODE_SECTION = '---'
9
19
  public_constant :END_OF_CODE_SECTION
10
20
 
11
21
  VARIABLE_REF = '$$'
12
22
  public_constant :VARIABLE_REF
23
+
24
+ # @see https://github.com/ruby/racc/blob/master/lib/racc/parser.rb#L121
25
+ TOKEN_LIBRARY = ::T.let(
26
+ {
27
+ # A1_NOTATION: ::CSVPlusPlus::Lexer::Token.new(
28
+ # regexp: ::CSVPlusPlus::A1Reference::A1_NOTATION_REGEXP, token: :A1_NOTATION
29
+ # ),
30
+ FALSE: ::CSVPlusPlus::Lexer::Token.new(regexp: /false/i, token: :FALSE),
31
+ HEX_COLOR: ::CSVPlusPlus::Lexer::Token.new(regexp: ::CSVPlusPlus::Color::HEX_STRING_REGEXP, token: :HEX_COLOR),
32
+ INFIX_OP: ::CSVPlusPlus::Lexer::Token.new(regexp: %r{\^|\+|-|\*|/|&|<|>|<=|>=|<>}, token: :INFIX_OP),
33
+ NUMBER: ::CSVPlusPlus::Lexer::Token.new(regexp: /-?[\d.]+/, token: :NUMBER),
34
+ REF: ::CSVPlusPlus::Lexer::Token.new(regexp: /[$!\w:]+/, token: :REF),
35
+ STRING: ::CSVPlusPlus::Lexer::Token.new(
36
+ regexp: %r{"(?:[^"\\]|\\(?:["\\/bfnrt]|u[0-9a-fA-F]{4}))*"},
37
+ token: :STRING
38
+ ),
39
+ TRUE: ::CSVPlusPlus::Lexer::Token.new(regexp: /true/i, token: :TRUE),
40
+ VAR_REF: ::CSVPlusPlus::Lexer::Token.new(regexp: /\$\$/, token: :VAR_REF)
41
+ }.freeze,
42
+ ::T::Hash[::Symbol, ::CSVPlusPlus::Lexer::Token]
43
+ )
44
+ public_constant :TOKEN_LIBRARY
45
+
46
+ sig { params(str: ::String).returns(::String) }
47
+ # Run any transformations to the input before going into the CSV parser
48
+ #
49
+ # The CSV parser in particular does not like if there is whitespace after a double quote and before the next comma
50
+ #
51
+ # @param str [String]
52
+ # @return [String]
53
+ def self.preprocess(str)
54
+ str.gsub(/"\s*,/, '",')
55
+ end
56
+
57
+ sig { params(str: ::String).returns(::String) }
58
+ # When parsing a modifier with a quoted string field, we need a way to unescape. Some examples of quoted and
59
+ # unquoted results:
60
+ #
61
+ # * "just a string" => "just a string"
62
+ # * "' this is a string'" => "this is a string"
63
+ # * "won\'t this work?" => "won't this work"
64
+ #
65
+ # @param str [::String]
66
+ #
67
+ # @return [::String]
68
+ def self.unquote(str)
69
+ # could probably do this with one regex but we do it in 3 steps:
70
+ #
71
+ # 1. remove leading and trailing spaces and '
72
+ # 2. remove any backslashes that are by themselves (none on either side)
73
+ # 3. turn double backslashes into singles
74
+ str.gsub(/^\s*'?|'?\s*$/, '').gsub(/([^\\]+)\\([^\\]+)/, '\1\2').gsub(/\\\\/, '\\')
75
+ end
13
76
  end
14
77
  end
@@ -1,3 +1,4 @@
1
+ # typed: true
1
2
  # frozen_string_literal: true
2
3
 
3
4
  module CSVPlusPlus
@@ -0,0 +1,138 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module CSVPlusPlus
5
+ module Modifier
6
+ # A validation on a cell value. Used to support the `validate=` modifier directive. This is mostly based on the
7
+ # Google Sheets API spec:
8
+ #
9
+ # @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#ConditionType
10
+ #
11
+ # @attr_reader arguments [Array<::String>] The parsed arguments as required by the condition.
12
+ # @attr_reader condition [Symbol] The condition (:blank, :text_eq, :date_before, etc.)
13
+ # @attr_reader invalid_reason [::String, nil] If set, the reason why this modifier is not valid.
14
+ class DataValidation
15
+ attr_reader :arguments, :condition, :invalid_reason
16
+
17
+ # @param value [::String] The value to parse as a data validation
18
+ def initialize(value)
19
+ condition, args = value.split(/\s*:\s*/)
20
+ @arguments = ::CSVPlusPlus::Lexer.unquote(args || '').split(/\s+/)
21
+ @condition = condition.to_sym
22
+
23
+ validate!
24
+ end
25
+
26
+ # Each data validation (represented by +@condition+) has their own requirements for which arguments are valid.
27
+ # If this object is invalid, you can see the reason in +@invalid_reason+.
28
+ #
29
+ # @return [boolean]
30
+ def valid?
31
+ @invalid_reason.nil?
32
+ end
33
+
34
+ protected
35
+
36
+ def invalid!(reason)
37
+ @invalid_reason = reason
38
+ end
39
+
40
+ def a_number(arg)
41
+ Float(arg)
42
+ rescue ::ArgumentError
43
+ invalid!("Requires a number but given: #{arg}")
44
+ end
45
+
46
+ def a1_notation(arg)
47
+ return arg if ::CSVPlusPlus::A1Reference.valid_cell_reference?(arg)
48
+ end
49
+
50
+ def a_date(arg, allow_relative_date: false)
51
+ return arg if ::CSVPlusPlus::Entities::Date.valid_date?(arg)
52
+
53
+ if allow_relative_date
54
+ a_relative_date(arg)
55
+ else
56
+ invalid!("Requires a date but given: #{arg}")
57
+ end
58
+ end
59
+
60
+ def a_relative_date(arg)
61
+ return arg if %w[past_month past_week past_year yesterday today tomorrow].include?(arg.downcase)
62
+
63
+ invalid!('Requires a relative date: past_month, past_week, past_year, yesterday, today or tomorrow')
64
+ end
65
+
66
+ def no_args
67
+ return if @arguments.empty?
68
+
69
+ invalid!("Requires no arguments but #{@arguments.length} given: #{@arguments}")
70
+ end
71
+
72
+ def one_arg
73
+ return @arguments[0] if @arguments.length == 1
74
+
75
+ invalid!("Requires only one argument but #{@arguments.length} given: #{@arguments}")
76
+ end
77
+
78
+ def one_arg_or_more
79
+ return @arguments if @arguments.length.positive?
80
+
81
+ invalid!("Requires at least one argument but #{@arguments.length} given: #{@arguments}")
82
+ end
83
+
84
+ def two_dates
85
+ return @arguments if @arguments.length == 2 && a_date(@arguments[0]) && a_date(@arguments[1])
86
+
87
+ invalid!("Requires exactly two dates but given: #{@arguments}")
88
+ end
89
+
90
+ def two_numbers
91
+ return @arguments if @arguments.length == 2 && a_number(@arguments[0]) && a_number(@arguments[1])
92
+
93
+ invalid!("Requires exactly two numbers but given: #{@arguments}")
94
+ end
95
+
96
+ # validate_boolean is a weird one because it can have 0, 1 or 2 @arguments - all of them must be (true | false)
97
+ def validate_boolean
98
+ return @arguments if @arguments.empty?
99
+
100
+ converted_args = @arguments.map(&:strip).map(&:downcase)
101
+ return @arguments if [1, 2].include?(@arguments.length) && converted_args.all? do |arg|
102
+ %w[true false].include?(arg)
103
+ end
104
+
105
+ invalid!("Requires 0, 1 or 2 arguments and they all must be either 'true' or 'false'. Received: #{arguments}")
106
+ end
107
+
108
+ # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/AbcSize
109
+ def validate!
110
+ case condition.to_sym
111
+ when :blank, :date_is_valid, :not_blank, :text_is_email, :text_is_url
112
+ no_args
113
+ when :text_contains, :text_ends_with, :text_eq, :text_not_contains, :text_starts_with
114
+ one_arg
115
+ when :date_after, :date_before, :date_on_or_after, :date_on_or_before
116
+ a_date(one_arg, allow_relative_date: true)
117
+ when :date_eq, :date_not_eq
118
+ a_date(one_arg)
119
+ when :date_between, :date_not_between
120
+ two_dates
121
+ when :one_of_range
122
+ a1_notation(one_arg)
123
+ when :custom_formula, :one_of_list, :text_not_eq
124
+ one_arg_or_more
125
+ when :number_eq, :number_greater, :number_greater_than_eq, :number_less, :number_less_than_eq, :number_not_eq
126
+ a_number(one_arg)
127
+ when :number_between, :number_not_between
128
+ two_numbers
129
+ when :boolean
130
+ validate_boolean
131
+ else
132
+ invalid!('Not a recognized data validation directive')
133
+ end
134
+ end
135
+ # rubocop:enable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/AbcSize
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,78 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module CSVPlusPlus
5
+ module Modifier
6
+ # The logic for how a row can expand
7
+ #
8
+ # @attr_reader ends_at [Integer, nil] Once the row has been expanded, where it ends at.
9
+ # @attr_reader repetitions [Integer, nil] How many times the row repeats/expands.
10
+ # @attr_reader starts_at [Integer, nil] Once the row has been expanded, where it starts at.
11
+ class Expand
12
+ extend ::T::Sig
13
+
14
+ sig { returns(::T.nilable(::Integer)) }
15
+ attr_reader :ends_at
16
+
17
+ sig { returns(::T.nilable(::Integer)) }
18
+ attr_reader :repetitions
19
+
20
+ sig { returns(::T.nilable(::Integer)) }
21
+ attr_reader :starts_at
22
+
23
+ sig { params(repetitions: ::T.nilable(::Integer), starts_at: ::T.nilable(::Integer)).void }
24
+ # @param repetitions [Integer, nil] How many times this expand repeats. If it's +nil+ it will expand infinitely
25
+ # (for the rest of the worksheet.)
26
+ # @param starts_at [Integer, nil] The final location where the +Expand+ will start. It's important to note that
27
+ # this can't be derived until all rows are expanded, because each expand modifier will push down the ones below
28
+ # it. So typically this param will not be passed in the initializer but instead set later.
29
+ def initialize(repetitions: nil, starts_at: nil)
30
+ @repetitions = ::T.let(repetitions, ::T.nilable(::Integer))
31
+ @starts_at = ::T.let(starts_at, ::T.nilable(::Integer)) unless starts_at.nil?
32
+ @ends_at = ::T.let(nil, ::T.nilable(::Integer))
33
+ end
34
+
35
+ sig { returns(::T::Boolean) }
36
+ # Has the row been expanded?
37
+ #
38
+ # @return [boolean]
39
+ def expanded?
40
+ !@starts_at.nil?
41
+ end
42
+
43
+ sig { returns(::T::Boolean) }
44
+ # Does this infinitely expand?
45
+ #
46
+ # @return [boolean]
47
+ def infinite?
48
+ repetitions.nil?
49
+ end
50
+
51
+ sig { params(row_index: ::Integer).void }
52
+ # Mark the start of the row once it's been expanded, as well as where it +ends_at+. When expanding rows each one
53
+ # adds rows to the worksheet and if there are multiple `expand` modifiers in the worksheet, we don't know the
54
+ # final +row_index+ until we're in the phase of expanding all the rows out.
55
+ def starts_at=(row_index)
56
+ @starts_at = row_index
57
+ @ends_at = row_index + @repetitions unless @repetitions.nil?
58
+ end
59
+
60
+ sig { params(position: ::CSVPlusPlus::Runtime::Position).returns(::T::Boolean) }
61
+ # Does the given +position+ fall within this expand?
62
+ #
63
+ # @param position [Runtime::Position]
64
+ #
65
+ # @return [boolean]
66
+ def position_within?(position)
67
+ unless starts_at
68
+ raise(
69
+ ::CSVPlusPlus::Error::CompilerError,
70
+ 'Must call Template.expand_rows! before checking the scope of expands.'
71
+ )
72
+ end
73
+
74
+ position.row_index >= ::T.must(starts_at) && (ends_at.nil? || position.row_index <= ::T.must(ends_at))
75
+ end
76
+ end
77
+ end
78
+ end