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