lkml 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE.md +10 -0
- data/README.md +11 -0
- data/lib/lkml/keys.rb +125 -0
- data/lib/lkml/lexer.rb +161 -0
- data/lib/lkml/parser.rb +338 -0
- data/lib/lkml/simple.rb +297 -0
- data/lib/lkml/tokens.rb +211 -0
- data/lib/lkml/tree.rb +319 -0
- data/lib/lkml/version.rb +5 -0
- data/lib/lkml/visitors.rb +91 -0
- data/lib/lkml.rb +26 -0
- metadata +54 -0
data/lib/lkml/simple.rb
ADDED
@@ -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
|
data/lib/lkml/tokens.rb
ADDED
@@ -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
|