Repository: joshtemple/lkml

Issue:
Support parsing datagroup interval_trigger
Hey, thanks for this library!

I'm parsing Looker datagroups and it seems the [`interval_trigger` subparameter](https://cloud.google.com/looker/docs/reference/param-model-datagroup#interval_trigger) isn't supported. Here's an example:

```
>>> datagroup_dict = {"datagroups": [{
    'name': 'name', 
    'label': 'label', 
    'sql_trigger': 'SELECT 1', 
    'description': 'description', 
    'max_cache_age': '24 hours', 
    'interval_trigger': '6 hours'
}]}

>>> datagroup = lkml.dump(datagroup_dict)
>>> print(datagroup)
datagroup: name {
  label: "label"
  sql_trigger: SELECT 1 ;;
  description: "description"
  max_cache_age: "24 hours"
  interval_trigger: 6 hours
}
```

Note the interval_trigger is unquoted. I took a look at the source and I don't see it mentioned, for example in the [`QUOTED_LITERAL_KEYS`](https://github.com/joshtemple/lkml/blob/c7545dc0567589b2e11a4d759ec66fcdd5969c5a/lkml/keys.py#L76). We've solved this downstream with some regex replacement but just wanted to note it upstream here as well. I'm happy to take a closer look too.

Discussion:

Patch:
diff --git a/.readthedocs.yml b/.readthedocs.yml
index b8a9237..6806601 100644
--- a/.readthedocs.yml
+++ b/.readthedocs.yml
@@ -1,5 +1,10 @@
 version: 2
 
+build:
+  os: ubuntu-22.04
+  tools:
+    python: "3.9"
+
 sphinx:
   configuration: docs/source/conf.py
 
diff --git a/lkml/keys.py b/lkml/keys.py
index 1f86f91..b284577 100644
--- a/lkml/keys.py
+++ b/lkml/keys.py
@@ -107,6 +107,7 @@ QUOTED_LITERAL_KEYS: Tuple[str, ...] = (
     "property_key",
     "property_label_key",
     "else",
+    "interval_trigger",
 )
 
 # These are keys for fields in Looker that have a "name" attribute. Since lkml uses the
diff --git a/lkml/lexer.py b/lkml/lexer.py
index 10d9ac8..11ec9a5 100644
--- a/lkml/lexer.py
+++ b/lkml/lexer.py
@@ -103,20 +103,27 @@ class Lexer:
         return any(string.startswith(key + ":") for key in EXPR_BLOCK_KEYS)
 
     def scan_whitespace(self) -> tokens.WhitespaceToken:
-        r"""Returns a token from one or more whitespace characters.
+        """Returns a token from one or more whitespace characters.
 
         Example:
             >>> lexer = Lexer("\n\n\t Hello")
             >>> lexer.scan_whitespace()
-            WhitespaceToken('\n\n\t ')
+            LinebreakToken('\n\n', 1)
 
         """
         chars = ""
-        while self.peek() in "\n\t ":
-            if self.peek() == "\n":
-                self.line_number += 1
-            chars += self.consume()
-        return tokens.WhitespaceToken(chars, self.line_number)
+        next_char = self.peek()
+        while next_char in "\n\t ":
+            if next_char == "\n":
+                while next_char == "\n":
+                    chars += self.consume()
+                    self.line_number += 1
+                    next_char = self.peek()
+                return tokens.LinebreakToken(chars, self.line_number)
+            else:
+                chars += self.consume()
+                next_char = self.peek()
+        return tokens.InlineWhitespaceToken(chars, self.line_number)
 
     def scan_comment(self) -> tokens.CommentToken:
         r"""Returns a token from a comment.
diff --git a/lkml/parser.py b/lkml/parser.py
index 6a78911..c6cdc43 100644
--- a/lkml/parser.py
+++ b/lkml/parser.py
@@ -144,11 +144,17 @@ class Parser:
             raise ValueError("Token %s does not have a consumable value." % token)
         return token.value
 
-    def consume_trivia(self) -> str:
+    def consume_trivia(self, only_newlines: bool = False) -> str:
         """Returns all continuous trivia values."""
+        valid_tokens: Tuple[Type[tokens.TriviaToken], ...] = (tokens.CommentToken,)
+        if only_newlines:
+            valid_tokens += (tokens.LinebreakToken,)
+        else:
+            valid_tokens += (tokens.WhitespaceToken,)
+
         trivia = ""
         while True:
-            if self.check(tokens.CommentToken, tokens.WhitespaceToken):
+            if self.check(*valid_tokens):
                 trivia += self.consume_token_value()
             else:
                 break
@@ -192,7 +198,7 @@ class Parser:
             # Reached the end of the stream
             result = False
         else:
-            if type(token) in token_types:
+            if isinstance(token, token_types):
                 result = True
             else:
                 result = False
@@ -300,7 +306,7 @@ class Parser:
         prefix = self.consume_trivia()
         if self.check(tokens.BlockEndToken):
             self.advance()
-            suffix = self.consume_trivia()
+            suffix = self.consume_trivia(only_newlines=True)
             right_brace = tree.RightCurlyBrace(prefix=prefix, suffix=suffix)
 
             block = tree.BlockNode(
diff --git a/lkml/tokens.py b/lkml/tokens.py
index 2511951..f3cc4a2 100644
--- a/lkml/tokens.py
+++ b/lkml/tokens.py
@@ -130,6 +130,18 @@ class WhitespaceToken(TriviaToken):
         return f"{self.__class__.__name__}({repr(self.value)})"
 
 
+class LinebreakToken(WhitespaceToken):
+    """Represents a newline character."""
+
+    id = "<linebreak>"
+
+
+class InlineWhitespaceToken(WhitespaceToken):
+    """Represents one or more whitespace characters."""
+
+    id = "<inline whitespace>"
+
+
 class CommentToken(TriviaToken):
     """Represents a comment."""
 
diff --git a/setup.py b/setup.py
index 018177b..e910783 100644
--- a/setup.py
+++ b/setup.py
@@ -2,7 +2,7 @@ from pathlib import Path
 
 from setuptools import find_packages, setup
 
-__version__ = "1.3.1"
+__version__ = "1.3.2"
 
 here = Path(__file__).parent.resolve()
 


Test patch:
diff --git a/tests/test_lexer.py b/tests/test_lexer.py
index ad1f44b..e704bab 100644
--- a/tests/test_lexer.py
+++ b/tests/test_lexer.py
@@ -81,7 +81,9 @@ def test_scan_whitespace():
     text = "\n\t Hello World!"
     lexer = lkml.Lexer(text)
     token = lexer.scan_whitespace()
-    assert token == tokens.WhitespaceToken("\n\t ", 1)
+    assert token == tokens.LinebreakToken("\n", 1)
+    token = lexer.scan_whitespace()
+    assert token == tokens.InlineWhitespaceToken("\t ", 2)
 
 
 def test_scan_comment():
@@ -97,9 +99,10 @@ def test_scan_comment_with_surrounding_whitespace():
     output = lkml.Lexer(text).scan()
     assert output == (
         tokens.StreamStartToken(1),
-        tokens.WhitespaceToken("\n", 1),
+        tokens.LinebreakToken("\n", 1),
         tokens.CommentToken("# A comment", 2),
-        tokens.WhitespaceToken("\n ", 2),
+        tokens.LinebreakToken("\n", 2),
+        tokens.InlineWhitespaceToken(" ", 3),
         tokens.StreamEndToken(3),
     )
 
@@ -173,7 +176,7 @@ def test_scan_with_non_expression_block_starting_with_sql():
         tokens.StreamStartToken(1),
         tokens.LiteralToken("sql_not_reserved_field", 1),
         tokens.ValueToken(1),
-        tokens.WhitespaceToken(" ", 1),
+        tokens.InlineWhitespaceToken(" ", 1),
         tokens.LiteralToken("yes", 1),
         tokens.StreamEndToken(1),
     )
@@ -186,7 +189,7 @@ def test_scan_with_non_expression_block_starting_with_html():
         tokens.StreamStartToken(1),
         tokens.LiteralToken("html_not_reserved_field", 1),
         tokens.ValueToken(1),
-        tokens.WhitespaceToken(" ", 1),
+        tokens.InlineWhitespaceToken(" ", 1),
         tokens.LiteralToken("yes", 1),
         tokens.StreamEndToken(1),
     )
diff --git a/tests/test_parser.py b/tests/test_parser.py
index 644a7a5..3f0d3de 100644
--- a/tests/test_parser.py
+++ b/tests/test_parser.py
@@ -706,5 +706,5 @@ def test_parse_block_with_no_expression():
         colon=Colon(line_number=1, suffix=" "),
         left_brace=LeftCurlyBrace(prefix=" "),
         container=ContainerNode(items=tuple()),
-        right_brace=RightCurlyBrace(suffix=" "),
+        right_brace=RightCurlyBrace(),
     )