Skip to content

Commit d9571cd

Browse files
authored
[pkl.lua] Add class Parser and enhance Renderer (#60)
* Add class `Parser` that can parse a static subset of Lua * Update `Renderer` to applying converters to mapping entry keys * Rename LuaIdentifier, LuaKeyword, and LuaRenderDirective to drop the leading "Lua", and deprecate renamed typealiases
1 parent 4373590 commit d9571cd

File tree

8 files changed

+1082
-59
lines changed

8 files changed

+1082
-59
lines changed

packages/pkl.lua/PklProject

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
// See the License for the specific language governing permissions and
1414
// limitations under the License.
1515
//===----------------------------------------------------------------------===//
16-
/// A [Lua](https://www.lua.org) renderer.
16+
/// A parser and renderer for a subset of [Lua](https://www.lua.org).
1717
amends "../basePklProject.pkl"
1818

1919
package {
20-
version = "1.0.1"
20+
version = "1.1.0"
2121
}

packages/pkl.lua/lua.pkl

Lines changed: 642 additions & 47 deletions
Large diffs are not rendered by default.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
-- This is a copy of the sample file from the Parser doc comment
2+
--[[
3+
This file has a header comment.
4+
]]
5+
foo="bar"
6+
count=2
7+
-- line comment here
8+
enable=true
9+
frob=nil
10+
ports={80, 443}
11+
ips={
12+
localhost = "127.0.0.1";
13+
["example.com"] = "93.184.215.14";
14+
}
15+
_ENV[" "]="space"
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
--[[
2+
This is an example config file written in Lua.
3+
It consists of key/value pairs and only literals, no expressions.
4+
]]
5+
greeting="Hello, world!"
6+
snippet=[=[
7+
Long Lua strings can be written inside brackets like this: [[
8+
This is a multiline string.
9+
It ends at the close double-bracket.]]
10+
]=]
11+
hex_floats={
12+
0x10.0,
13+
0x0.fffff,
14+
-0x32p3,
15+
}
16+
tableKeys={
17+
identifier = true;
18+
["string"] = "yes";
19+
[42] = "the meaning of life";
20+
[3.14159] = "pi";
21+
[true] = "very true";
22+
[{1, 2, 3}] = "even tables can be keys";
23+
-- but nil and NaN cannot
24+
}

packages/pkl.lua/tests/parser.pkl

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
module pkl.lua.tests.parser
17+
18+
amends "pkl:test"
19+
20+
import "../lua.pkl"
21+
import "pkl:math"
22+
23+
local parser: lua.Parser = new {}
24+
25+
local function parseValue(s: String): lua.Value =
26+
let (map = (parser) { useDynamic = false }.parse("value=\(s)"))
27+
if (map.length == 1) map["value"]
28+
else throw("source parsed as more than one value: \(s)")
29+
30+
local function parseDynamic(s: String): lua.Value =
31+
let (map = parser.parse("value=\(s)").toMap())
32+
if (map.length == 1) map["value"]
33+
else throw("source parsed as more than one value: \(s)")
34+
35+
facts {
36+
["hex floats"] {
37+
// with the way we parse hex floats, check for precision issues
38+
parseValue("0xaBcD") is Int(this == 0xabcd)
39+
parseValue("0X80.0") is Float(this == 0x80)
40+
parseValue("0x80.8") is Float(this == 128.5)
41+
parseValue("0x7f.f0") is Float(this == 127.9375)
42+
parseValue("0x7f.84") is Float(this == 127.515625)
43+
parseValue("0x7f.abc") is Float(this == 127.6708984375)
44+
parseValue("-0x7f.abc") is Float(this == -127.6708984375)
45+
parseValue("0x1p1") is Float(this == 2.0)
46+
parseValue("0x1p+1") is Float(this == 2.0)
47+
parseValue("0x1p2") is Float(this == 4.0)
48+
parseValue("0x1p-1") is Float(this == 0.5)
49+
parseValue("0x80.8p1") is Float(this == 257.0)
50+
parseValue("0x7fffffffffffffff") is Int(this == math.maxInt)
51+
parseValue("-0x8000000000000000") is Int(this == math.minInt)
52+
parseValue("0x1.fffffffffffffp+1023") is Float(this == math.maxFiniteFloat)
53+
parseValue("-0x1.fffffffffffffp+1023") is Float(this == math.minFiniteFloat)
54+
}
55+
["negative numbers"] {
56+
// negative numbers are a unary negation on a positive number
57+
parseValue("-1") == -1
58+
// we support double-negation because the alternative is giving the user an error like
59+
// error: expected number, found token '-'
60+
// and that's just confusing.
61+
parseValue("- -1") == 1
62+
parseValue("- - - 1") == -1
63+
parseValue("-\n1") == -1
64+
parseValue("\(math.maxUInt)") == math.maxUInt
65+
parseValue("\(math.maxInt)") == math.maxInt
66+
parseValue("\(math.minInt)") == math.minInt
67+
parseValue("-0.1") == -0.1
68+
parseValue("-0xABC") == -0xABC
69+
parseValue("-2e1") == -2e1
70+
}
71+
["tables with useDynamic=true"] {
72+
// note: new Dynamic { 1; 2 } == new Dynamic { [0] = 1; [1] = 2 }, so we have to take care with comparisons
73+
parseDynamic("{}") == new Dynamic {}
74+
parseDynamic("{1,2}") is Dynamic(this.toList() == List(1,2) && this.toMap().isEmpty)
75+
parseDynamic("{a=1;b=2}") == new Dynamic { a = 1; b = 2 }
76+
parseDynamic(#"{["a"]=1;["b"]=2}"#) == new Dynamic { a = 1; b = 2 }
77+
parseDynamic("{[1]=1;[2]=2}") is Dynamic(this.toList() == List(1,2) && this.toMap().isEmpty)
78+
parseDynamic("{[2]=2;[1]=1}") is Dynamic(this.toList() == List(1) && this.toMap() == Map(2,2))
79+
parseDynamic("{[0]=1;[2]=2}") is Dynamic(this.toList().isEmpty && this.toMap() == Map(0,1,2,2))
80+
parseDynamic("{[{}]=1}") == new Dynamic { [new Dynamic {}] = 1 }
81+
parseDynamic("{[{1,2}]=1}") == new Dynamic { [new Dynamic { 1; 2 }] = 1 }
82+
parseDynamic("{[{a=1}]=1}") == new Dynamic { [new Dynamic { a = 1 }] = 1 }
83+
}
84+
["tables with useDynamic=false"] {
85+
parseValue("{}") == new Listing {}
86+
parseValue("{1,2}") == new Listing { 1; 2 }
87+
parseValue("{a=1;b=2}") == new Mapping { ["a"] = 1; ["b"] = 2 }
88+
parseValue(#"{["a"]=1;["b"]=2}"#) == new Mapping { ["a"] = 1; ["b"] = 2 }
89+
parseValue("{[1]=1;[2]=2}") == new Listing { 1; 2 }
90+
module.catch(() -> parseValue("{[2]=2;[1]=1}")).startsWith("Table has both list elements and map entries")
91+
parseValue("{[2]=1;[3]=2}") == new Mapping { [2] = 1; [3] = 2 }
92+
parseValue("{[0]=1;[2]=2}") == new Mapping { [0] = 1; [2] = 2 }
93+
parseValue("{[{}]=1}") == new Mapping { [new Listing {}] = 1 }
94+
parseValue("{[{1,2}]=1}") == new Mapping { [new Listing { 1; 2 }] = 1 }
95+
parseValue("{[{a=1}]=1}") == new Mapping { [new Mapping { ["a"] = 1 }] = 1 }
96+
}
97+
["_ENV[key] with useDynamic=true"] {
98+
// note: new Dynamic { 1; 2 } == new Dynamic { [0] = 1; [1] = 2 }, so we have to take care with comparisons
99+
parser.parse("_ENV[true]=1") == new Dynamic { [true] = 1 }
100+
parser.parse(#"_ENV["foo"]=1"#) == new Dynamic { foo = 1 }
101+
parser.parse("_ENV[1]=2") is Dynamic(this.toList() == List(2) && this.toMap().isEmpty)
102+
parser.parse("_ENV[0]=2") is Dynamic(this.toList().isEmpty && this.toMap() == Map(0, 2))
103+
parser.parse("_ENV[-1]=2") is Dynamic(this.toList().isEmpty && this.toMap() == Map(-1, 2))
104+
parser.parse("_ENV[2]=2;_ENV[1]=1") is Dynamic(this.toList() == List(1) && this.toMap() == Map(2,2))
105+
parser.parse("_ENV[{a=1}]=2") == new Dynamic { [new Dynamic { a = 1 }] = 2 }
106+
}
107+
["_ENV[key] with useDynamic=false"] {
108+
local mapParser = (parser) { useDynamic = false }
109+
mapParser.parse("_ENV[true]=1") == new Mapping { [true] = 1 }
110+
mapParser.parse(#"_ENV["foo"]=1"#) == new Mapping { ["foo"] = 1 }
111+
mapParser.parse("_ENV[1]=2") == new Mapping { [1] = 2 }
112+
mapParser.parse("_ENV[0]=2") == new Mapping { [0] = 2 }
113+
mapParser.parse("_ENV[-1]=2") == new Mapping { [-1] = 2 }
114+
mapParser.parse("_ENV[2]=2;_ENV[1]=1") == new Mapping { [2] = 2; [1] = 1 }
115+
mapParser.parse("_ENV[{a=1}]=2") == new Mapping { [new Mapping { ["a"] = 1 }] = 2 }
116+
}
117+
["errors"] {
118+
// these are facts instead of examples so we can preserve formatting in the error strings, since module.catch
119+
// replaces newlines with spaces.
120+
module.catch(() -> parser.parse("foo=")) == """
121+
Expected value or {, found EOF
122+
123+
1 | foo=
124+
| ^
125+
at <input>:1:5
126+
""".replaceAll("\n", " ")
127+
module.catch(() -> parser.parse("foo=3\nbar=")) == """
128+
Expected value or {, found EOF
129+
130+
2 | bar=
131+
| ^
132+
at <input>:2:5
133+
""".replaceAll("\n", " ")
134+
module.catch(() -> parser.parse(new Resource { text = "foo=1.2.3"; uri = "uri:path/to/input.lua" })) == """
135+
Invalid numeric literal: 1.2.3
136+
137+
1 | foo=1.2.3
138+
| ^^^^^
139+
at uri:path/to/input.lua:1:5
140+
""".replaceAll("\n", " ")
141+
module.catch(() -> (parser) { useDynamic = false }.parse("foo={1, [true]=2}")) == """
142+
Table has both list elements and map entries
143+
144+
1 | foo={1, [true]=2}
145+
| ^ first list entry
146+
| ^^^^ first map entry
147+
at <input>:1:6
148+
""".replaceAll("\n", " ")
149+
module.catch(() -> (parser) { useDynamic = false }.parse("foo={[true]=1, 2}")) == """
150+
Table has both list elements and map entries
151+
152+
1 | foo={[true]=1, 2}
153+
| ^^^^ first map entry
154+
| ^ first list entry
155+
at <input>:1:7
156+
""".replaceAll("\n", " ")
157+
module.catch(() -> (parser) { useDynamic = false }.parse("foo={\n [true]=1;\n 2\n}")) == """
158+
Table has both list elements and map entries
159+
160+
2 | [true]=1;
161+
| ^^^^ first map entry
162+
3 | 2
163+
| ^ first list entry
164+
at <input>:2:4
165+
""".replaceAll("\n", " ")
166+
module.catch(() -> (parser) { useDynamic = false }.parse("foo={\n 1;\n [true]=2\n}")) == """
167+
Table has both list elements and map entries
168+
169+
2 | 1;
170+
| ^ first list entry
171+
3 | [true]=2
172+
| ^^^^ first map entry
173+
at <input>:2:3
174+
""".replaceAll("\n", " ")
175+
module.catch(() -> parser.parse("[1]=1")) == """
176+
Expected identifier or ;, found token `[`
177+
178+
1 | [1]=1
179+
| ^
180+
at <input>:1:1
181+
""".replaceAll("\n", " ")
182+
module.catch(() -> parser.parse("_ENV=1")) == """
183+
_ENV cannot be assigned to directly
184+
185+
1 | _ENV=1
186+
| ^
187+
at <input>:1:5
188+
""".replaceAll("\n", " ")
189+
}
190+
}
191+
192+
examples {
193+
["empty"] {
194+
parser.parse("")
195+
}
196+
["null"] {
197+
parser.parse("foo=nil")
198+
}
199+
["boolean"] {
200+
parser.parse("foo=true\nbar=false")
201+
}
202+
["number"] {
203+
parser.parse("""
204+
zero=0
205+
one=1
206+
negative=-1
207+
maxInt32=2147483647
208+
minInt32=-2147483648
209+
zerof=0.0
210+
float=5.32
211+
negfloat=-10.26
212+
hex=0xaBcD
213+
hexf=0X80.0
214+
hexf2=0x80.8 -- 128.5
215+
hexf3=0x7f.f0 -- 127.9375
216+
hexf4=0x7f.84 -- 127.515625
217+
hexf5=0x7f.abc -- 127.6708984375
218+
hexp=0x1p1 -- 2.0
219+
hexp=0x1p+1 -- 2.0
220+
hexp2=0x1p2 -- 4.0
221+
hexp3=0x1p-1 -- 0.5
222+
hexp4=0x80.8p1 -- 257.0
223+
""")
224+
}
225+
["string"] {
226+
parser.parse(#"""
227+
s="hello world"
228+
single = 'one\'two'
229+
-- line comment
230+
double = "one\"two"
231+
--[[
232+
long comment
233+
]]
234+
escapes = "\a\b\f\n\r\t\v"
235+
hex = --[=[ comment]] ]=] "\x00\x3a\x3A\x7f"
236+
dec = "\0\58\058\0580\127"
237+
u = "\u{0}\u{300a}\u{300B}\u{10FFFF}"
238+
newline = "foo\
239+
bar"
240+
z = "foo\z
241+
bar"
242+
long=[[foo]]
243+
long2=[[
244+
foo]]
245+
long3=[[
246+
foo]]
247+
long4=[=[]]]=]
248+
long5=[===[]==]]====]]===]
249+
"""#)
250+
}
251+
["class converters"] {
252+
(parser) { converters { [Int] = (it) -> it + 1 } }.parse("foo=1;bar=2;baz=1.0")
253+
(parser) { converters { [Int] = (it) -> it + 1 } }.parse("foo={1, 2}")
254+
(parser) { converters { [Int] = (it) -> it + 1 } }.parse("foo={[1]=1;[2]=2}")
255+
(parser) { converters { [Int] = (it) -> it + 1 } }.parse("foo={[2]=2;[3]=3}") // not listing elements!
256+
(parser) { converters { [Int] = (it) -> it + 1 } }.parse("_ENV[1]=1;_ENV[2]=2")
257+
(parser) { converters { [Int] = (it) -> it + 1 } }.parse("_ENV[2]=2;_ENV[3]=3") // not listing elements!
258+
(parser) { converters { [String] = (it) -> "\(it)!" } }.parse(#"foo="bar""#)
259+
(parser) { converters { [String] = (it) -> "\(it)!" } }.parse(#"_ENV["foo"]="bar""#)
260+
fixupTableKeys((parser) { converters { [String] = (it) -> "\(it)!" } }.parse(#"table={["foo"]="bar"}"#))
261+
fixupTableKeys((parser) { converters { [Int] = (it) -> it + 1 } }.parse("_ENV[{a=1}]=true"))
262+
(parser) { converters { [Dynamic] = (it) -> (it) { done = true } } }.parse("foo=1")
263+
}
264+
["path converters"] {
265+
(parser) { converters { ["^"] = (it) -> (it) { done = true } } }.parse("foo=1")
266+
(parser) { converters { [""] = (it) -> (it) { done = true } } }.parse("foo=1")
267+
(parser) { converters { ["foo"] = (it) -> it + 1 } }.parse("foo=1")
268+
(parser) { converters { ["^foo"] = (it) -> it + 1 } }.parse("foo=1")
269+
(parser) { converters { ["a.foo"] = (it) -> it + 1 } }.parse("foo=1")
270+
(parser) { converters { ["foo.a"] = (it) -> it + 1 } }.parse("foo={a=1;b=2}")
271+
(parser) { converters { ["^a"] = (it) -> it + 1 } }.parse("a=1;foo={a=1}")
272+
(parser) { converters { ["*"] = (it) -> it + 1 } }.parse("a=1;_ENV[true]=1")
273+
(parser) { converters { ["[*]"] = (it) -> it + 1 } }.parse(#"a=1;_ENV["b"]=1;_ENV[true]=1"#)
274+
(parser) { useDynamic = false; converters { ["[*]"] = (it) -> it + 1 } }.parse(#"foo={a=1;["b"]=1;[true]=1}"#)
275+
}
276+
["path converters apply after converting keys"] {
277+
// first validate that paths "x[42]" and "x.42" won't match an integral key
278+
(parser) { converters { ["x[42]"] = (it) -> "matched: \(it)"; ["x.42"] = (it) -> "matched: \(it)" } }.parse("x={[42]=true}")
279+
// and validate that the path "x.42" will match a string key
280+
(parser) { converters { ["x.42"] = (it) -> "matched: \(it)" } }.parse(#"x={["42"]=true}"#)
281+
// now if we convert the integral key to a string, it should match "x.42"
282+
(parser) { converters { [Int] = (it) -> it.toString(); ["x.42"] = (it) -> "matched: \(it)" } }.parse("x={[42]=true}")
283+
}
284+
["path converters in tables as table keys"] {
285+
fixupTableKeys((parser) { converters { ["^"] = (it) -> (it) { done = true } } }.parse("_ENV[{a=1}]=1")) // ^ doesn't match the key table
286+
fixupTableKeys((parser) { converters { ["a"] = (it) -> it + 1 } }.parse("_ENV[{a=1;b=1}]=1;a=1"))
287+
fixupTableKeys((parser) { converters { ["^a"] = (it) -> it + 1 } }.parse("_ENV[{a=1;b=1}]=1;a=1")) // ^ doesn't match the key table
288+
fixupTableKeys((parser) { converters { ["a.b"] = (it) -> it + 1 } }.parse("foo={[{a={b=1};b=1}]=1}"))
289+
}
290+
["converters can return non-Lua types"] {
291+
(parser) { converters { ["^"] = (it) -> Pair("foo", it.foo) } }.parse("foo=1")
292+
(parser) { converters { [Int] = (it) -> Pair("int", it) } }.parse("foo=1")
293+
(parser) { converters { [Int] = (it) -> Pair("int", it) } }.parse("_ENV[5]=true")
294+
(parser) { converters { [Int] = (it) -> Pair("int", it) } }.parse("foo={[5]=true}")
295+
(parser) { converters { [Int] = (it) -> Pair("int", it) }; useDynamic = false }.parse("_ENV[5]=true")
296+
(parser) { converters { [Int] = (it) -> Pair("int", it) }; useDynamic = false }.parse("foo={[5]=true}")
297+
}
298+
["fixtures"] {
299+
fixupTableKeys(parser.parse(read("fixtures/sample.lua")))
300+
parser.parse(read("fixtures/comment.lua"))
301+
}
302+
}
303+
304+
// When rendering parser.pkl-actual.pcf, any table keys that are objects just render as `new { … }`, and this produces
305+
// an error "Cannot tell which parent to amend". This function replaces any such keys with a rendered string. This does
306+
// mean that parser.pkl-expected.pcf needs to use rendered strings here instead.
307+
local function fixupTableKeys(value: Dynamic|Mapping|Listing): Dynamic|Mapping|Listing =
308+
if (value is Listing)
309+
value.toList().map((v) ->
310+
if (v is Dynamic|Mapping|Listing) fixupTableKeys(v)
311+
else v
312+
).toListing()
313+
else
314+
let (mapf = (k, v) -> Pair(
315+
if (k is Object) "new \(k.getClass().simpleName) \(new PcfRenderer { indent = "" }.renderValue(k).replaceAll("\n", " "))" else k,
316+
if (v is Dynamic|Mapping|Listing) fixupTableKeys(v) else v
317+
))
318+
let (valueMap = value.toMap())
319+
if (value is Dynamic)
320+
let (map1 = valueMap.filter((k,_) -> !(k is Object)).map(mapf))
321+
let (map2 = valueMap.filter((k,_) -> k is Object).map(mapf))
322+
(map1.toDynamic()) {
323+
...map2
324+
...value.toList()
325+
}
326+
else valueMap.map(mapf).toMapping()
3.76 KB
Binary file not shown.

0 commit comments

Comments
 (0)