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.
- checksums.yaml +4 -4
- data/README.md +9 -5
- data/{CHANGELOG.md → docs/CHANGELOG.md} +25 -0
- data/lib/csv_plus_plus/a1_reference.rb +202 -0
- data/lib/csv_plus_plus/benchmarked_compiler.rb +70 -20
- data/lib/csv_plus_plus/cell.rb +29 -41
- data/lib/csv_plus_plus/cli.rb +53 -80
- data/lib/csv_plus_plus/cli_flag.rb +71 -71
- data/lib/csv_plus_plus/color.rb +32 -7
- data/lib/csv_plus_plus/compiler.rb +98 -66
- data/lib/csv_plus_plus/entities/ast_builder.rb +30 -39
- data/lib/csv_plus_plus/entities/boolean.rb +26 -10
- data/lib/csv_plus_plus/entities/builtins.rb +66 -24
- data/lib/csv_plus_plus/entities/date.rb +42 -6
- data/lib/csv_plus_plus/entities/entity.rb +17 -69
- data/lib/csv_plus_plus/entities/entity_with_arguments.rb +44 -0
- data/lib/csv_plus_plus/entities/function.rb +34 -11
- data/lib/csv_plus_plus/entities/function_call.rb +49 -10
- data/lib/csv_plus_plus/entities/has_identifier.rb +19 -0
- data/lib/csv_plus_plus/entities/number.rb +30 -11
- data/lib/csv_plus_plus/entities/reference.rb +77 -0
- data/lib/csv_plus_plus/entities/runtime_value.rb +43 -13
- data/lib/csv_plus_plus/entities/string.rb +23 -7
- data/lib/csv_plus_plus/entities.rb +7 -16
- data/lib/csv_plus_plus/error/cli_error.rb +17 -0
- data/lib/csv_plus_plus/error/compiler_error.rb +17 -0
- data/lib/csv_plus_plus/error/error.rb +25 -2
- data/lib/csv_plus_plus/error/formula_syntax_error.rb +12 -12
- data/lib/csv_plus_plus/error/modifier_syntax_error.rb +34 -12
- data/lib/csv_plus_plus/error/modifier_validation_error.rb +21 -27
- data/lib/csv_plus_plus/error/positional_error.rb +15 -0
- data/lib/csv_plus_plus/error/writer_error.rb +8 -0
- data/lib/csv_plus_plus/error.rb +5 -1
- data/lib/csv_plus_plus/error_formatter.rb +111 -0
- data/lib/csv_plus_plus/google_api_client.rb +25 -10
- data/lib/csv_plus_plus/lexer/racc_lexer.rb +144 -0
- data/lib/csv_plus_plus/lexer/tokenizer.rb +58 -17
- data/lib/csv_plus_plus/lexer.rb +64 -1
- data/lib/csv_plus_plus/modifier/conditional_formatting.rb +1 -0
- data/lib/csv_plus_plus/modifier/data_validation.rb +138 -0
- data/lib/csv_plus_plus/modifier/expand.rb +78 -0
- data/lib/csv_plus_plus/modifier/google_sheet_modifier.rb +133 -0
- data/lib/csv_plus_plus/modifier/modifier.rb +222 -0
- data/lib/csv_plus_plus/modifier/modifier_validator.rb +243 -0
- data/lib/csv_plus_plus/modifier/rubyxl_modifier.rb +84 -0
- data/lib/csv_plus_plus/modifier.rb +89 -160
- data/lib/csv_plus_plus/options/file_options.rb +49 -0
- data/lib/csv_plus_plus/options/google_sheets_options.rb +42 -0
- data/lib/csv_plus_plus/options/options.rb +97 -0
- data/lib/csv_plus_plus/options.rb +34 -77
- data/lib/csv_plus_plus/parser/cell_value.tab.rb +66 -67
- data/lib/csv_plus_plus/parser/code_section.tab.rb +86 -83
- data/lib/csv_plus_plus/parser/modifier.tab.rb +57 -53
- data/lib/csv_plus_plus/reader/csv.rb +50 -0
- data/lib/csv_plus_plus/reader/google_sheets.rb +129 -0
- data/lib/csv_plus_plus/reader/reader.rb +27 -0
- data/lib/csv_plus_plus/reader/rubyxl.rb +37 -0
- data/lib/csv_plus_plus/reader.rb +14 -0
- data/lib/csv_plus_plus/row.rb +53 -12
- data/lib/csv_plus_plus/runtime/graph.rb +68 -0
- data/lib/csv_plus_plus/runtime/position.rb +242 -0
- data/lib/csv_plus_plus/runtime/references.rb +115 -0
- data/lib/csv_plus_plus/runtime/runtime.rb +132 -0
- data/lib/csv_plus_plus/runtime/scope.rb +280 -0
- data/lib/csv_plus_plus/runtime.rb +34 -191
- data/lib/csv_plus_plus/source_code.rb +71 -0
- data/lib/csv_plus_plus/template.rb +71 -39
- data/lib/csv_plus_plus/version.rb +2 -1
- data/lib/csv_plus_plus/writer/csv.rb +37 -8
- data/lib/csv_plus_plus/writer/excel.rb +25 -5
- data/lib/csv_plus_plus/writer/file_backer_upper.rb +27 -13
- data/lib/csv_plus_plus/writer/google_sheets.rb +29 -85
- data/lib/csv_plus_plus/writer/google_sheets_builder.rb +179 -0
- data/lib/csv_plus_plus/writer/merger.rb +31 -0
- data/lib/csv_plus_plus/writer/open_document.rb +21 -2
- data/lib/csv_plus_plus/writer/rubyxl_builder.rb +140 -42
- data/lib/csv_plus_plus/writer/writer.rb +42 -0
- data/lib/csv_plus_plus/writer.rb +79 -10
- data/lib/csv_plus_plus.rb +47 -18
- metadata +50 -21
- data/lib/csv_plus_plus/can_define_references.rb +0 -88
- data/lib/csv_plus_plus/can_resolve_references.rb +0 -8
- data/lib/csv_plus_plus/data_validation.rb +0 -138
- data/lib/csv_plus_plus/entities/cell_reference.rb +0 -60
- data/lib/csv_plus_plus/entities/variable.rb +0 -25
- data/lib/csv_plus_plus/error/syntax_error.rb +0 -58
- data/lib/csv_plus_plus/expand.rb +0 -20
- data/lib/csv_plus_plus/google_options.rb +0 -27
- data/lib/csv_plus_plus/graph.rb +0 -62
- data/lib/csv_plus_plus/lexer/lexer.rb +0 -85
- data/lib/csv_plus_plus/references.rb +0 -68
- data/lib/csv_plus_plus/scope.rb +0 -196
- data/lib/csv_plus_plus/validated_modifier.rb +0 -164
- data/lib/csv_plus_plus/writer/base_writer.rb +0 -20
- data/lib/csv_plus_plus/writer/google_sheet_builder.rb +0 -147
- data/lib/csv_plus_plus/writer/google_sheet_modifier.rb +0 -77
- 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
|
-
|
|
10
|
+
extend ::T::Sig
|
|
11
|
+
|
|
12
|
+
sig { returns(::T.nilable(::CSVPlusPlus::Lexer::Token)) }
|
|
13
|
+
attr_reader :last_token
|
|
13
14
|
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
126
|
+
scanner.rest
|
|
86
127
|
end
|
|
87
128
|
end
|
|
88
129
|
end
|
data/lib/csv_plus_plus/lexer.rb
CHANGED
|
@@ -1,14 +1,77 @@
|
|
|
1
|
+
# typed: strict
|
|
1
2
|
# frozen_string_literal: true
|
|
2
3
|
|
|
3
|
-
require_relative './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
|
|
@@ -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
|