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