|
| 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() |
0 commit comments