lkml 0.1.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.
@@ -0,0 +1,297 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Interface classes between the parse tree and a data structure of primitives.
4
+ #
5
+ # These classes facilitate parsing and generation to and from simple data structures like
6
+ # lists and dictionaries, and allow users to parse and generate LookML without needing
7
+ # to interact with the parse tree.
8
+
9
+ require_relative 'keys'
10
+ require_relative 'tree'
11
+
12
+ module Lkml
13
+ def self.pluralize(key)
14
+ # Converts a singular key like "explore" to a plural key, e.g. 'explores'.
15
+ case key
16
+ when 'filters', 'bind_filters', 'extends'
17
+ "#{key}__all"
18
+ when 'query'
19
+ 'queries'
20
+ when 'remote_dependency'
21
+ 'remote_dependencies'
22
+ else
23
+ "#{key}s"
24
+ end
25
+ end
26
+
27
+ def self.singularize(key)
28
+ # Converts a plural key like "explores" to a singular key, e.g. 'explore'.
29
+ if key == 'queries'
30
+ 'query'
31
+ elsif key == 'remote_dependencies'
32
+ 'remote_dependency'
33
+ elsif key.end_with?('__all')
34
+ key[0...-5] # Strip off __all
35
+ elsif key.end_with?('s')
36
+ key.chomp('s')
37
+ else
38
+ key
39
+ end
40
+ end
41
+
42
+ class DictVisitor
43
+ attr_accessor :depth
44
+
45
+ def initialize
46
+ @depth = -1
47
+ end
48
+
49
+ def update_tree(target, update)
50
+ keys = update.keys
51
+ raise KeyError, 'Dictionary to update with cannot have multiple keys.' if keys.size > 1
52
+
53
+ key = keys.first
54
+
55
+ if PLURAL_KEYS.include?(key)
56
+ plural_key = ::Lkml.pluralize(key)
57
+ if target.key?(plural_key)
58
+ target[plural_key] << update[key]
59
+ else
60
+ target[plural_key] = [update[key]]
61
+ end
62
+ elsif target.key?(key)
63
+ unless @depth.zero?
64
+ raise KeyError, "Key \"#{key}\" already exists in tree and would overwrite the existing value."
65
+ end
66
+
67
+ $stderr << "Multiple declarations of top-level key \"#{key}\" found. Using the last-declared value.\n"
68
+ target[key] = update[key]
69
+
70
+ else
71
+ target[key] = update[key]
72
+ end
73
+ end
74
+
75
+ def visit(document)
76
+ visit_container(document.container)
77
+ end
78
+
79
+ def visit_container(node)
80
+ container = {}
81
+ if node.items.any?
82
+ @depth += 1
83
+ node.items.each do |item|
84
+ update_tree(container, item.accept(self))
85
+ end
86
+ @depth -= 1
87
+ end
88
+ container
89
+ end
90
+
91
+ def visit_block(node)
92
+ container_dict = node.container ? node.container.accept(self) : {}
93
+ container_dict['name'] = node.name.accept(self) if node.name
94
+ { node.type.accept(self) => container_dict }
95
+ end
96
+
97
+ def visit_list(node)
98
+ { node.type.accept(self) => node.items.map { |item| item.accept(self) } }
99
+ end
100
+
101
+ def visit_pair(node)
102
+ { node.type.accept(self) => node.value.accept(self) }
103
+ end
104
+
105
+ def visit_token(token)
106
+ token.value.to_s
107
+ end
108
+ end
109
+
110
+ class DictParser
111
+ attr_accessor :parent_key, :level, :base_indent, :latest_node
112
+
113
+ def initialize
114
+ @parent_key = nil
115
+ @level = 0
116
+ @base_indent = ' ' * 2
117
+ @latest_node = DocumentNode
118
+ end
119
+
120
+ def increase_level
121
+ @latest_node = nil
122
+ @level += 1
123
+ end
124
+
125
+ def decrease_level
126
+ @level -= 1
127
+ end
128
+
129
+ def indent
130
+ @level.positive? ? @base_indent * @level : ''
131
+ end
132
+
133
+ def newline_indent
134
+ "\n#{indent}"
135
+ end
136
+
137
+ def prefix
138
+ return '' if @latest_node == DocumentNode
139
+ return newline_indent if @latest_node.nil?
140
+ return "\n#{newline_indent}" if @latest_node == BlockNode
141
+
142
+ newline_indent
143
+ end
144
+
145
+ def plural_key?(key)
146
+ singular_key = ::Lkml.singularize(key)
147
+ PLURAL_KEYS.include?(singular_key) &&
148
+ !(singular_key == 'allowed_value' && @parent_key.rstrip == 'access_grant') &&
149
+ !(@parent_key == 'query' && singular_key != 'filters')
150
+ end
151
+
152
+ def resolve_filters(values)
153
+ if values.first.key?('name')
154
+ values.map do |value|
155
+ name = value.delete('name')
156
+ parse_block('filter', value, name:)
157
+ end
158
+ elsif values.first.key?('field') && values.first.key?('value')
159
+ values.map { |value| parse_block('filters', value) }
160
+ else
161
+ parse_list('filters', values)
162
+ end
163
+ end
164
+
165
+ def parse(obj)
166
+ nodes = obj.map { |key, value| parse_any(key, value) }
167
+ container = ContainerNode.new(nodes.flatten)
168
+ DocumentNode.new(container)
169
+ end
170
+
171
+ def expand_list(key, values)
172
+ if key == 'filters'
173
+ resolve_filters(values)
174
+ else
175
+ singular_key = ::Lkml.singularize(key)
176
+ values.map { |value| parse_any(singular_key, value) }.flatten
177
+ end
178
+ end
179
+
180
+ def parse_any(key, value)
181
+ case value
182
+ when String
183
+ parse_pair(key, value)
184
+ when Array
185
+ if plural_key?(key)
186
+ expand_list(key, value)
187
+ else
188
+ parse_list(key, value)
189
+ end
190
+ when Hash
191
+ to_parse = value.dup
192
+ name = if KEYS_WITH_NAME_FIELDS.include?(key) || !value.key?('name')
193
+ nil
194
+ else
195
+ to_parse.delete('name')
196
+ end
197
+ parse_block(key, to_parse, name:)
198
+ else
199
+ raise TypeError, 'Value must be a string, list, tuple, or dict.'
200
+ end
201
+ end
202
+
203
+ def parse_block(key, items, name: nil)
204
+ prev_parent_key = @parent_key
205
+ @parent_key = key
206
+ latest_node_at_this_level = @latest_node
207
+ increase_level
208
+ nodes = items.map { |k, v| parse_any(k, v) }
209
+ decrease_level
210
+ @latest_node = latest_node_at_this_level
211
+ @parent_key = prev_parent_key
212
+
213
+ container = ContainerNode.new(nodes.flatten)
214
+
215
+ prefix = if @latest_node && @latest_node != DocumentNode
216
+ "\n#{newline_indent}"
217
+ else
218
+ self.prefix
219
+ end
220
+
221
+ BlockNode.new(
222
+ SyntaxToken.new(key, prefix: prefix),
223
+ left_brace: LeftCurlyBrace.new(prefix: name ? ' ' : ''),
224
+ right_brace: RightCurlyBrace.new(prefix: container.items.any? ? newline_indent : ''),
225
+ name: name ? SyntaxToken.new(name) : nil,
226
+ container: container
227
+ ).tap { @latest_node = BlockNode }
228
+ end
229
+
230
+ def parse_list(key, values) # rubocop:disable Metrics/CyclomaticComplexity
231
+ force_quote = key == 'suggestions'
232
+ prev_parent_key = @parent_key
233
+ @parent_key = key
234
+
235
+ type_token = SyntaxToken.new(key, prefix: prefix)
236
+ right_bracket = RightBracket.new
237
+ items = []
238
+ pair_mode = false
239
+
240
+ pair_mode = true if values.any? && !values.first.is_a?(String)
241
+
242
+ if values.size >= 5 || pair_mode
243
+ Comma.new
244
+ increase_level
245
+ values.each do |value|
246
+ if pair_mode
247
+ # Extract key and value from dictionary with only one key
248
+ key, val = value.to_a.first
249
+ items << parse_pair(key, val)
250
+ else
251
+ items << parse_token(key, value, force_quote:, prefix: newline_indent)
252
+ end
253
+ end
254
+ decrease_level
255
+ right_bracket = RightBracket.new(prefix: newline_indent)
256
+ else
257
+ values.each_with_index do |value, i|
258
+ token = if i.zero?
259
+ parse_token(key, value, force_quote:)
260
+ else
261
+ parse_token(key, value, force_quote:, prefix: ' ')
262
+ end
263
+ items << token
264
+ end
265
+ end
266
+
267
+ @parent_key = prev_parent_key
268
+
269
+ ListNode.new(
270
+ type_token,
271
+ left_bracket: LeftBracket.new,
272
+ items: items,
273
+ right_bracket: right_bracket,
274
+ trailing_comma: pair_mode || values.size >= 5 ? Comma.new : nil
275
+ ).tap { @latest_node = ListNode }
276
+ end
277
+
278
+ def parse_pair(key, value)
279
+ force_quote = @parent_key == 'filters' && key != 'field'
280
+ value_syntax_token = parse_token(key, value, force_quote:)
281
+ PairNode.new(
282
+ SyntaxToken.new(key, prefix: prefix),
283
+ value_syntax_token
284
+ ).tap { @latest_node = PairNode }
285
+ end
286
+
287
+ def parse_token(key, value, force_quote: false, prefix: '', suffix: '')
288
+ if force_quote || QUOTED_LITERAL_KEYS.include?(key)
289
+ QuotedSyntaxToken.new(value, prefix: prefix, suffix: suffix)
290
+ elsif EXPR_BLOCK_KEYS.include?(key)
291
+ ExpressionSyntaxToken.new(value.strip, prefix: prefix, suffix: suffix)
292
+ else
293
+ SyntaxToken.new(value, prefix: prefix, suffix: suffix)
294
+ end
295
+ end
296
+ end
297
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Tokens used by the lexer to tokenize LookML.
4
+
5
+ module Lkml
6
+ module Tokens
7
+ class Token
8
+ # Base class for LookML tokens, lexed from LookML strings.
9
+
10
+ attr_reader :line_number
11
+
12
+ def initialize(line_number)
13
+ # Initializes a Token.
14
+ #
15
+ # Args:
16
+ # line_number: The corresponding line in the text where this token begins
17
+
18
+ @line_number = line_number
19
+ end
20
+
21
+ def ==(other)
22
+ # Compare one Token to another by their type.
23
+ self.class == other.class
24
+ end
25
+
26
+ def to_s
27
+ # Returns the token's string representation, truncated to 25 characters.
28
+ #
29
+ # If the token has a `value` attribute, include that in the output.
30
+
31
+ value = defined?(@value) ? @value.strip : ''
32
+ value = "#{value[0, 25].rstrip} ... " if value.length > 25
33
+ "#{self.class.name}(#{value})"
34
+ end
35
+ end
36
+
37
+ class ContentToken < Token
38
+ # Base class for LookML tokens that contain a string of content.
39
+
40
+ attr_accessor :value
41
+
42
+ def initialize(value, line_number)
43
+ # Initializes a ContentToken with string content.
44
+ #
45
+ # Args:
46
+ # value: A string value for the token's content
47
+ # line_number: The corresponding line in the text where this token begins
48
+
49
+ @value = value
50
+ super(line_number)
51
+ end
52
+
53
+ def ==(other)
54
+ # Compare one ContentToken to another by their values.
55
+ self.class == other.class && @value == other.value
56
+ end
57
+ end
58
+
59
+ class StreamStartToken < Token
60
+ # Represents the start of a stream of characters.
61
+
62
+ def initialize(line_number)
63
+ super
64
+ @id = '<stream start>'
65
+ end
66
+ end
67
+
68
+ class StreamEndToken < Token
69
+ # Represents the end of a stream of characters.
70
+
71
+ def initialize(line_number)
72
+ super
73
+ @id = '<stream end>'
74
+ end
75
+ end
76
+
77
+ class BlockStartToken < Token
78
+ # Represents the start of a block.
79
+
80
+ def initialize(line_number)
81
+ super
82
+ @id = '{'
83
+ end
84
+ end
85
+
86
+ class BlockEndToken < Token
87
+ # Represents the end of a block.
88
+
89
+ def initialize(line_number)
90
+ super
91
+ @id = '}'
92
+ end
93
+ end
94
+
95
+ class ValueToken < Token
96
+ # Separates a key from a value.
97
+
98
+ def initialize(line_number)
99
+ super
100
+ @id = ':'
101
+ end
102
+ end
103
+
104
+ class ExpressionBlockEndToken < Token
105
+ # Represents the end of an expression block.
106
+
107
+ def initialize(line_number)
108
+ super
109
+ @id = ';;'
110
+ end
111
+ end
112
+
113
+ class CommaToken < Token
114
+ # Separates elements in a list.
115
+
116
+ def initialize(line_number)
117
+ super
118
+ @id = ','
119
+ end
120
+ end
121
+
122
+ class ListStartToken < Token
123
+ # Represents the start of a list.
124
+
125
+ def initialize(line_number)
126
+ super
127
+ @id = '['
128
+ end
129
+ end
130
+
131
+ class ListEndToken < Token
132
+ # Represents the end of a list.
133
+
134
+ def initialize(line_number)
135
+ super
136
+ @id = ']'
137
+ end
138
+ end
139
+
140
+ class TriviaToken < ContentToken
141
+ # Represents a comment or whitespace.
142
+ end
143
+
144
+ class WhitespaceToken < TriviaToken
145
+ # Represents one or more whitespace characters.
146
+
147
+ def initialize(value, line_number)
148
+ super
149
+ @id = '<whitespace>'
150
+ end
151
+
152
+ def to_s
153
+ "#{self.class.name}(#{@value.inspect})"
154
+ end
155
+ end
156
+
157
+ class LinebreakToken < WhitespaceToken
158
+ # Represents a newline character.
159
+
160
+ def initialize(value, line_number)
161
+ super
162
+ @id = '<linebreak>'
163
+ end
164
+ end
165
+
166
+ class InlineWhitespaceToken < WhitespaceToken
167
+ # Represents one or more whitespace characters.
168
+
169
+ def initialize(value, line_number)
170
+ super
171
+ @id = '<inline whitespace>'
172
+ end
173
+ end
174
+
175
+ class CommentToken < TriviaToken
176
+ # Represents a comment.
177
+
178
+ def initialize(value, line_number)
179
+ super
180
+ @id = '<comment>'
181
+ end
182
+ end
183
+
184
+ class ExpressionBlockToken < ContentToken
185
+ # Contains the value of an expression block.
186
+
187
+ def initialize(value, line_number)
188
+ super
189
+ @id = '<expression block>'
190
+ end
191
+ end
192
+
193
+ class LiteralToken < ContentToken
194
+ # Contains the value of an unquoted literal.
195
+
196
+ def initialize(value, line_number)
197
+ super
198
+ @id = '<literal>'
199
+ end
200
+ end
201
+
202
+ class QuotedLiteralToken < ContentToken
203
+ # Contains the value of a quoted literal.
204
+
205
+ def initialize(value, line_number)
206
+ super
207
+ @id = '<quoted literal>'
208
+ end
209
+ end
210
+ end
211
+ end