csv_plus_plus 0.1.2 → 0.2.0

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