lkml 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0d77c017023caf25f3f6acadeff2cdc098d1ddbb6c75b8ca9f0ac7968abcefef
4
+ data.tar.gz: 6613bd618a1793463394f7e4b0408eb30467fb435da58735d61b51fc00d1a22e
5
+ SHA512:
6
+ metadata.gz: 668606773d646b80de95eef27aa09d060018a6b5018c4c7d2982e8ad23bc13257437272af13b5284b55d72f2c6ef2f72ff99fdf56e610aad07b87087524ef037
7
+ data.tar.gz: dc231523581f3a6125b9a94af667adeb2c6c4211143f8c614655b2da42b3efff6e23cc2b18df05dfc8c9ceacb8768a4a66b1309e3181454c65a8e07f0df71747
data/LICENSE.md ADDED
@@ -0,0 +1,10 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2019 Josh Temple
4
+ Copyright (c) 2025 Sylvain Utard
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
7
+
8
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
9
+
10
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # Lkml
2
+
3
+ A LookML parser and serializer implemented in pure Ruby.
4
+
5
+ > This is a Ruby rewrite of the amazing [joshtemple/lkml](https://github.com/joshtemple/lkml) python library.
6
+
7
+ Why should you use `lkml`?
8
+
9
+ - Tested on **over 160K lines of LookML** from public repositories on GitHub
10
+ - Written in pure, modern Ruby with **no external dependencies**
11
+ - A **full unit test suite** with excellent coverage
data/lib/lkml/keys.rb ADDED
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Defines constant sequences of LookML keys and helper methods.
4
+
5
+ # These are repeatable keys in LookML that the parser should collapse into a single
6
+ # Ruby hash key. For example, LookML can have multiple dimensions, so the parser
7
+ # will combine those dimensions into an array of hashes with a top-level key,
8
+ # `dimensions`.
9
+
10
+ module Lkml
11
+ PLURAL_KEYS = %w[
12
+ access_filter
13
+ access_grant
14
+ action
15
+ aggregate_table
16
+ allowed_value
17
+ assert
18
+ bind_filters
19
+ column
20
+ constant
21
+ datagroup
22
+ remote_dependency
23
+ derived_column
24
+ dimension
25
+ dimension_group
26
+ explore
27
+ extends
28
+ filter
29
+ filters
30
+ form_param
31
+ include
32
+ join
33
+ link
34
+ map_layer
35
+ measure
36
+ named_value_format
37
+ option
38
+ override_constant
39
+ param
40
+ parameter
41
+ query
42
+ set
43
+ sql_step
44
+ test
45
+ user_attribute_param
46
+ view
47
+ when
48
+ ].freeze
49
+
50
+ # These are keys in LookML that should be recognized as expression blocks (end with ;;).
51
+
52
+ EXPR_BLOCK_KEYS = %w[
53
+ expression_custom_filter
54
+ expression
55
+ html
56
+ sql_trigger_value
57
+ sql_table_name
58
+ sql_distinct_key
59
+ sql_start
60
+ sql_always_having
61
+ sql_always_where
62
+ sql_trigger
63
+ sql_foreign_key
64
+ sql_where
65
+ sql_end
66
+ sql_create
67
+ sql_latitude
68
+ sql_longitude
69
+ sql_step
70
+ sql_on
71
+ sql
72
+ sql_preamble
73
+ ].freeze
74
+
75
+ # These are keys that the serializer should quote the value of (e.g. `label: "Label"`).
76
+ # An example of an unquoted literal would be `hidden: no`.
77
+
78
+ QUOTED_LITERAL_KEYS = %w[
79
+ label
80
+ view_label
81
+ group_label
82
+ group_item_label
83
+ suggest_persist_for
84
+ default_value
85
+ direction
86
+ value_format
87
+ name
88
+ url
89
+ icon_url
90
+ form_url
91
+ default
92
+ tags
93
+ value
94
+ description
95
+ sortkeys
96
+ indexes
97
+ partition_keys
98
+ connection
99
+ include
100
+ max_cache_age
101
+ allowed_values
102
+ timezone
103
+ persist_for
104
+ cluster_keys
105
+ distribution
106
+ extents_json_url
107
+ feature_key
108
+ file
109
+ property_key
110
+ property_label_key
111
+ else
112
+ interval_trigger
113
+ ].freeze
114
+
115
+ # These are keys for fields in Looker that have a "name" attribute. Since lkml uses the
116
+ # key `name` to represent the name of the field (e.g. for `dimension: dimension_name {`,
117
+ # the `name` key would hold the value `dimension_name`.)
118
+
119
+ KEYS_WITH_NAME_FIELDS = %w[
120
+ user_attribute_param
121
+ param
122
+ form_param
123
+ option
124
+ ].freeze
125
+ end
data/lib/lkml/lexer.rb ADDED
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'keys'
4
+ require_relative 'tokens'
5
+
6
+ # Splits a LookML string into a sequence of tokens.
7
+ module Lkml
8
+ class Lexer
9
+ attr_reader :text, :index, :tokens, :line_number
10
+
11
+ CHARACTER_TO_TOKEN = {
12
+ "\0" => Tokens::StreamEndToken,
13
+ '{' => Tokens::BlockStartToken,
14
+ '}' => Tokens::BlockEndToken,
15
+ '[' => Tokens::ListStartToken,
16
+ ']' => Tokens::ListEndToken,
17
+ ',' => Tokens::CommaToken,
18
+ ':' => Tokens::ValueToken,
19
+ ';' => Tokens::ExpressionBlockEndToken
20
+ }.freeze
21
+
22
+ def initialize(text)
23
+ # Initializes the Lexer with a LookML string and sets the index.
24
+ @text = "#{text}\u0000"
25
+ @index = 0
26
+ @tokens = []
27
+ @line_number = 1
28
+ end
29
+
30
+ def peek
31
+ # Returns the character at the current index of the text being lexed.
32
+ @text[@index]
33
+ end
34
+
35
+ def peek_multiple(length)
36
+ # Returns the next n characters from the current index in the text being lexed.
37
+ @text[@index, length]
38
+ end
39
+
40
+ def advance(length = 1)
41
+ # Moves the index forward by n characters.
42
+ @index += length
43
+ nil
44
+ end
45
+
46
+ def consume
47
+ # Returns the current index character and advances the index 1 character.
48
+ advance
49
+ @text[@index - 1]
50
+ end
51
+
52
+ def scan # rubocop:disable Metrics/CyclomaticComplexity
53
+ # Tokenizes LookML into a sequence of tokens.
54
+ @tokens << Tokens::StreamStartToken.new(@line_number)
55
+ loop do
56
+ ch = peek
57
+ case ch
58
+ when "\0"
59
+ @tokens << CHARACTER_TO_TOKEN[ch].new(@line_number)
60
+ break
61
+ when "\n", "\t", ' '
62
+ @tokens << scan_whitespace
63
+ when '#'
64
+ advance
65
+ @tokens << scan_comment
66
+ when ';'
67
+ if peek_multiple(2) == ';;'
68
+ advance(2)
69
+ @tokens << CHARACTER_TO_TOKEN[ch].new(@line_number)
70
+ end
71
+ when '"'
72
+ advance
73
+ @tokens << scan_quoted_literal
74
+ when *CHARACTER_TO_TOKEN.keys
75
+ advance
76
+ @tokens << CHARACTER_TO_TOKEN[ch].new(@line_number)
77
+ else
78
+ if self.class.check_for_expression_block(peek_multiple(25))
79
+ # TODO: Handle edges here with whitespace and comments
80
+ @tokens << scan_literal
81
+ advance
82
+ @tokens << Tokens::ValueToken.new(@line_number)
83
+ @tokens << scan_expression_block
84
+ else
85
+ # TODO: This should actually check for valid literals first
86
+ # and throw an error if it doesn't match
87
+ @tokens << scan_literal
88
+ end
89
+ end
90
+ end
91
+ @tokens
92
+ end
93
+
94
+ def self.check_for_expression_block(string)
95
+ # Returns true if the input string is an expression block.
96
+ EXPR_BLOCK_KEYS.any? { |key| string.start_with?("#{key}:") }
97
+ end
98
+
99
+ def scan_whitespace
100
+ # Returns a token from one or more whitespace characters.
101
+ chars = ''
102
+ next_char = peek
103
+ while ["\n", "\t", ' '].include?(next_char)
104
+ if next_char == "\n"
105
+ while next_char == "\n"
106
+ chars += consume
107
+ @line_number += 1
108
+ next_char = peek
109
+ end
110
+ return Tokens::LinebreakToken.new(chars, @line_number)
111
+ else
112
+ chars += consume
113
+ next_char = peek
114
+ end
115
+ end
116
+ Tokens::InlineWhitespaceToken.new(chars, @line_number)
117
+ end
118
+
119
+ def scan_comment
120
+ # Returns a token from a comment.
121
+ chars = '#'
122
+ chars += consume until ["\0", "\n"].include?(peek)
123
+ Tokens::CommentToken.new(chars, @line_number)
124
+ end
125
+
126
+ def scan_expression_block
127
+ # Returns a token from an expression block string.
128
+ chars = ''
129
+ while peek_multiple(2) != ';;'
130
+ @line_number += 1 if peek == "\n"
131
+ chars += consume
132
+ end
133
+ Tokens::ExpressionBlockToken.new(chars, @line_number)
134
+ end
135
+
136
+ def scan_literal
137
+ # Returns a token from a literal string.
138
+ chars = ''
139
+ chars += consume until ["\0", ' ', "\n", "\t", ':', '}', '{', ',', ']'].include?(peek)
140
+ Tokens::LiteralToken.new(chars, @line_number)
141
+ end
142
+
143
+ def scan_quoted_literal
144
+ # Returns a token from a quoted literal string.
145
+ chars = ''
146
+ loop do
147
+ ch = peek
148
+ break if ch == '"'
149
+
150
+ if ch == '\\'
151
+ chars += consume # Extra consume to skip the escaped character
152
+ elsif ch == "\n"
153
+ @line_number += 1
154
+ end
155
+ chars += consume
156
+ end
157
+ advance
158
+ Tokens::QuotedLiteralToken.new(chars, @line_number)
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,338 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Parses a sequence of tokenized LookML into a parse tree.
4
+
5
+ require_relative 'tokens'
6
+ require_relative 'tree'
7
+
8
+ module Lkml
9
+ class CommaSeparatedValues
10
+ attr_accessor :trailing_comma, :leading_comma
11
+
12
+ def initialize
13
+ @_values = []
14
+ @trailing_comma = nil
15
+ @leading_comma = nil
16
+ end
17
+
18
+ def append(value)
19
+ @_values << value
20
+ end
21
+
22
+ def values
23
+ @_values
24
+ end
25
+ end
26
+
27
+ class Parser
28
+ attr_accessor :tokens, :index, :progress, :depth, :log_debug
29
+
30
+ def initialize(stream)
31
+ stream.each { |token| raise TypeError, "Unsupported token: #{token}" unless token.is_a?(Tokens::Token) }
32
+ @tokens = stream
33
+ @index = 0
34
+ @progress = 0
35
+ @depth = -1
36
+ end
37
+
38
+ def jump_to_index(index)
39
+ @index = index
40
+ end
41
+
42
+ def peek
43
+ @tokens[@index]
44
+ end
45
+
46
+ def advance(length = 1)
47
+ @index += length
48
+ nil
49
+ end
50
+
51
+ def consume
52
+ advance
53
+ @tokens[@index - 1]
54
+ end
55
+
56
+ def consume_token_value
57
+ token = consume
58
+ raise "Token #{token} does not have a consumable value." if token.value.nil?
59
+
60
+ token.value
61
+ end
62
+
63
+ def consume_trivia(only_newlines: false)
64
+ valid_tokens = [Tokens::CommentToken]
65
+ valid_tokens += only_newlines ? [Tokens::LinebreakToken] : [Tokens::WhitespaceToken]
66
+
67
+ trivia = ''
68
+ loop do
69
+ break unless check(*valid_tokens)
70
+
71
+ trivia += consume_token_value
72
+ end
73
+ trivia
74
+ end
75
+
76
+ def check(*token_types, skip_trivia: false)
77
+ mark = @index
78
+ consume_trivia if skip_trivia
79
+
80
+ token = begin
81
+ peek
82
+ rescue StandardError
83
+ nil
84
+ end
85
+ result = token_types.any? { |type| token.is_a?(type) }
86
+
87
+ jump_to_index(mark) if skip_trivia
88
+ result
89
+ end
90
+
91
+ def parse
92
+ advance if check(Tokens::StreamStartToken)
93
+ prefix = consume_trivia
94
+ container = parse_container
95
+ suffix = consume_trivia
96
+ DocumentNode.new(container, prefix: prefix, suffix: suffix)
97
+ end
98
+
99
+ def parse_container
100
+ items = []
101
+ until check(Tokens::StreamEndToken, Tokens::BlockEndToken, skip_trivia: true)
102
+ block = parse_block
103
+ if block
104
+ items << block
105
+ next
106
+ end
107
+
108
+ pair = parse_pair
109
+ if pair
110
+ items << pair
111
+ next
112
+ end
113
+
114
+ list = parse_list
115
+ if list
116
+ items << list
117
+ next
118
+ end
119
+
120
+ token = @tokens[@progress]
121
+ raise SyntaxError, "Unable to find a matching expression for '#{token}' on line #{token.line_number}"
122
+ end
123
+
124
+ ContainerNode.new(items, top_level: @depth.zero?)
125
+ end
126
+
127
+ def parse_block
128
+ key = parse_key
129
+ return key if key.nil?
130
+
131
+ name = if check(Tokens::LiteralToken)
132
+ token = consume
133
+ SyntaxToken.new(token.value, token.line_number)
134
+ end
135
+
136
+ prefix = consume_trivia
137
+ return nil unless check(Tokens::BlockStartToken)
138
+
139
+ advance
140
+ suffix = consume_trivia
141
+ left_brace = LeftCurlyBrace.new(prefix: prefix, suffix: suffix)
142
+
143
+ container = parse_container
144
+
145
+ prefix = consume_trivia
146
+ return unless check(Tokens::BlockEndToken)
147
+
148
+ advance
149
+ suffix = consume_trivia(only_newlines: true)
150
+ right_brace = RightCurlyBrace.new(prefix: prefix, suffix: suffix)
151
+
152
+ BlockNode.new(
153
+ key[0],
154
+ colon: key[1],
155
+ name: name,
156
+ left_brace: left_brace,
157
+ container: container,
158
+ right_brace: right_brace
159
+ )
160
+ end
161
+
162
+ def parse_pair
163
+ key = parse_key
164
+ return nil if key.nil?
165
+
166
+ value = parse_value(parse_prefix: true, parse_suffix: true)
167
+ return nil if value.nil?
168
+
169
+ PairNode.new(key[0], value, colon: key[1])
170
+ end
171
+
172
+ def parse_key
173
+ prefix = consume_trivia
174
+ return nil unless check(Tokens::LiteralToken)
175
+
176
+ token = consume
177
+ key = SyntaxToken.new(token.value, token.line_number, prefix: prefix)
178
+
179
+ prefix = consume_trivia
180
+
181
+ colon = nil
182
+ while check(Tokens::ValueToken)
183
+ token = consume
184
+ suffix = consume_trivia
185
+ colon = Colon.new(token.line_number, prefix: prefix, suffix: suffix)
186
+ end
187
+ return nil unless colon
188
+
189
+ [key, colon]
190
+ end
191
+
192
+ def parse_value(parse_prefix: false, parse_suffix: false) # rubocop:disable Metrics/CyclomaticComplexity
193
+ prefix = parse_prefix ? consume_trivia : ''
194
+
195
+ if check(Tokens::LiteralToken)
196
+ token = consume
197
+ if token.value == '-' && consume_trivia
198
+ return nil unless check(Tokens::LiteralToken)
199
+
200
+ token = consume
201
+ token.value = "-#{token.value}"
202
+
203
+ end
204
+ suffix = parse_suffix ? consume_trivia : ''
205
+ SyntaxToken.new(token.value, token.line_number, prefix: prefix, suffix: suffix)
206
+ elsif check(Tokens::QuotedLiteralToken)
207
+ token = consume
208
+ suffix = parse_suffix ? consume_trivia : ''
209
+ QuotedSyntaxToken.new(token.value, token.line_number, prefix: prefix, suffix: suffix)
210
+ elsif check(Tokens::ExpressionBlockToken)
211
+ token = consume
212
+ match = token.value.match(/\A(\s*)(.*?)(\s*)\z/)
213
+ expr_prefix, value, expr_suffix = if match
214
+ [match[1], match[2], match[3]]
215
+ else
216
+ ['', token.value, '']
217
+ end
218
+ prefix += expr_prefix
219
+
220
+ return nil unless check(Tokens::ExpressionBlockEndToken)
221
+
222
+ advance
223
+
224
+ suffix = parse_suffix ? consume_trivia : ''
225
+ ExpressionSyntaxToken.new(value, token.line_number, prefix: prefix, suffix: suffix, expr_suffix: expr_suffix)
226
+ end
227
+ end
228
+
229
+ def parse_list
230
+ key = parse_key
231
+ return key if key.nil?
232
+
233
+ prefix = consume_trivia
234
+ return nil unless check(Tokens::ListStartToken)
235
+
236
+ advance
237
+ left_bracket = LeftBracket.new(prefix: prefix)
238
+
239
+ csv = parse_csv || CommaSeparatedValues.new
240
+
241
+ return unless check(Tokens::ListEndToken, skip_trivia: true)
242
+
243
+ prefix = consume_trivia
244
+ advance
245
+ suffix = consume_trivia
246
+ right_bracket = RightBracket.new(prefix: prefix, suffix: suffix)
247
+ ListNode.new(
248
+ key[0],
249
+ items: csv.values,
250
+ left_bracket: left_bracket,
251
+ right_bracket: right_bracket,
252
+ colon: key[1],
253
+ leading_comma: csv.leading_comma,
254
+ trailing_comma: csv.trailing_comma
255
+ )
256
+ end
257
+
258
+ def parse_csv # rubocop:disable Metrics/CyclomaticComplexity
259
+ pair_mode = false
260
+ csv = CommaSeparatedValues.new
261
+ csv.leading_comma = parse_comma
262
+
263
+ pair = parse_pair
264
+ if pair
265
+ csv.append(pair)
266
+ pair_mode = true
267
+ elsif check(Tokens::LiteralToken, Tokens::QuotedLiteralToken, skip_trivia: true)
268
+ value = parse_value(parse_prefix: true, parse_suffix: true)
269
+ csv.append(value)
270
+ else
271
+ return nil
272
+ end
273
+
274
+ until check(Tokens::ListEndToken, skip_trivia: true)
275
+ return nil unless check(Tokens::CommaToken)
276
+
277
+ index = @index
278
+ advance
279
+ if check(Tokens::ListEndToken, skip_trivia: true)
280
+ jump_to_index(index)
281
+ csv.trailing_comma = parse_comma
282
+ break
283
+ end
284
+
285
+ if pair_mode
286
+ pair = parse_pair
287
+ return nil if pair.nil?
288
+
289
+ csv.append(pair)
290
+ elsif check(Tokens::LiteralToken, Tokens::QuotedLiteralToken, skip_trivia: true)
291
+ value = parse_value(parse_prefix: true, parse_suffix: true)
292
+ csv.append(value)
293
+ elsif check(Tokens::ListEndToken, skip_trivia: true)
294
+ break
295
+ else
296
+ return nil
297
+ end
298
+ end
299
+
300
+ csv
301
+ end
302
+
303
+ def parse_comma
304
+ prefix = consume_trivia
305
+
306
+ return unless check(Tokens::CommaToken)
307
+
308
+ advance
309
+ suffix = check(Tokens::ListEndToken, skip_trivia: true) ? '' : consume_trivia
310
+ Comma.new(prefix: prefix, suffix: suffix)
311
+ end
312
+
313
+ def self.backtrack_if_none(method_name)
314
+ original_method = instance_method(method_name)
315
+
316
+ define_method(method_name) do |*args, **kwargs|
317
+ mark = @index
318
+ @depth += 1
319
+ result = original_method.bind(self).call(*args, **kwargs)
320
+ @depth -= 1
321
+ if result.nil?
322
+ @progress = [@index, @progress].max
323
+ jump_to_index(mark)
324
+ end
325
+ result
326
+ end
327
+ end
328
+
329
+ backtrack_if_none :parse_container
330
+ backtrack_if_none :parse_block
331
+ backtrack_if_none :parse_pair
332
+ backtrack_if_none :parse_key
333
+ backtrack_if_none :parse_value
334
+ backtrack_if_none :parse_list
335
+ backtrack_if_none :parse_csv
336
+ backtrack_if_none :parse_comma
337
+ end
338
+ end