diff --git a/docs/modules/release-notes/pages/0.33.adoc b/docs/modules/release-notes/pages/0.33.adoc index c662a7a1b..8012e9022 100644 --- a/docs/modules/release-notes/pages/0.33.adoc +++ b/docs/modules/release-notes/pages/0.33.adoc @@ -23,6 +23,20 @@ Native `pkl` and `pkldoc` binaries for Intel Mac systems are no longer published To continue running new Pkl releases on these systems, use an appropriate Java runtime and the `jpkl` and `jpkldoc` Java executables. +=== JSON and YAML Parsers Reject the Object Key `default` + +`Dynamic` declares a hidden `default` property, so an object key named `default` could not be represented as a `Dynamic` property. +Previously, `json.Parser.parse()`, `yaml.Parser.parse()` and `yaml.Parser.parseAll()` silently produced an object that was missing the key, and reading `.default` on it failed with a confusing type error. + +These methods now throw, and the error message points at `useMapping = true`, which parses into a `Mapping` and preserves the key: + +[source,pkl] +---- +new json.Parser { useMapping = true }.parse(""" + {"default": "greeting"} + """) +---- + === XXX == Bug Fixes [small]#🐜# diff --git a/pkl-core/src/main/java/org/pkl/core/stdlib/json/ParserNodes.java b/pkl-core/src/main/java/org/pkl/core/stdlib/json/ParserNodes.java index d7fbfb6a0..f888c9c1d 100644 --- a/pkl-core/src/main/java/org/pkl/core/stdlib/json/ParserNodes.java +++ b/pkl-core/src/main/java/org/pkl/core/stdlib/json/ParserNodes.java @@ -28,6 +28,7 @@ import org.pkl.core.stdlib.ExternalMethod1Node; import org.pkl.core.stdlib.PklConverter; import org.pkl.core.util.EconomicMaps; +import org.pkl.core.util.ErrorMessages; import org.pkl.core.util.LateInit; import org.pkl.core.util.json.JsonHandler; import org.pkl.core.util.json.JsonParser; @@ -168,7 +169,13 @@ public void endObject(@Nullable EconomicMap members) { @Override public void startObjectValue(@Nullable EconomicMap members, String name) { - currPath.push(Identifier.get(name)); + var identifier = Identifier.get(name); + // https://github.com/apple/pkl/issues/561 + if (!useMapping && identifier == Identifier.DEFAULT) { + throw new ParseException( + ErrorMessages.create("jsonParseErrorDynamicPropertyDefault"), getLocation()); + } + currPath.push(identifier); } @Override diff --git a/pkl-core/src/main/java/org/pkl/core/stdlib/yaml/ParserNodes.java b/pkl-core/src/main/java/org/pkl/core/stdlib/yaml/ParserNodes.java index e6543a931..754337e19 100644 --- a/pkl-core/src/main/java/org/pkl/core/stdlib/yaml/ParserNodes.java +++ b/pkl-core/src/main/java/org/pkl/core/stdlib/yaml/ParserNodes.java @@ -31,6 +31,8 @@ import org.pkl.core.stdlib.ExternalMethod1Node; import org.pkl.core.stdlib.PklConverter; import org.pkl.core.util.EconomicMaps; +import org.pkl.core.util.ErrorMessages; +import org.pkl.core.util.yaml.ParseException; import org.pkl.core.util.yaml.snake.YamlUtils; import org.snakeyaml.engine.v2.api.ConstructNode; import org.snakeyaml.engine.v2.api.Load; @@ -72,6 +74,9 @@ private Object doParse(VmTyped self, String text, String uri) { var document = load.loadFromString(text); return converter.convert(document, List.of()); } catch (YamlEngineException e) { + if (e.getCause() instanceof ParseException cause) { + throw exceptionBuilder().evalError("yamlParseError").withHint(cause.getMessage()).build(); + } if (e.getMessage() .startsWith("Number of aliases for non-scalar nodes exceeds the specified")) { throw exceptionBuilder() @@ -110,6 +115,9 @@ private VmList doParseAll(VmTyped self, String text, String uri) { builder.add(converter.convert(document, List.of(VmValueConverter.TOP_LEVEL_VALUE))); } } catch (YamlEngineException e) { + if (e.getCause() instanceof ParseException cause) { + throw exceptionBuilder().evalError("yamlParseError").withHint(cause.getMessage()).build(); + } if (e.getMessage() .startsWith("Number of aliases for non-scalar nodes exceeds the specified")) { throw exceptionBuilder() @@ -480,6 +488,13 @@ private void addMembers(MappingNode node, VmObject object) { var memberName = convertedKey instanceof String string && !useMapping ? Identifier.get(string) : null; + // https://github.com/apple/pkl/issues/561 + if (memberName == Identifier.DEFAULT) { + throw new ParseException( + ErrorMessages.create("yamlParseErrorDynamicPropertyDefault"), + keyNode.getStartMark().orElse(null)); + } + var member = new ObjectMember( sourceSection, diff --git a/pkl-core/src/main/java/org/pkl/core/util/yaml/ParseException.java b/pkl-core/src/main/java/org/pkl/core/util/yaml/ParseException.java new file mode 100644 index 000000000..86190f314 --- /dev/null +++ b/pkl-core/src/main/java/org/pkl/core/util/yaml/ParseException.java @@ -0,0 +1,48 @@ +/* + * Copyright © 2026 Apple Inc. and the Pkl project authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pkl.core.util.yaml; + +import org.jspecify.annotations.Nullable; +import org.snakeyaml.engine.v2.exceptions.Mark; + +/** + * An unchecked exception to indicate that a YAML document cannot be represented as the requested + * Pkl value. + * + *

Unlike {@code YamlEngineException}, whose messages originate from SnakeYAML, messages carried + * by this exception are written by Pkl and are suitable for presenting to users as-is. + * + *

SnakeYAML wraps any exception other than {@code YamlEngineException} thrown during + * construction, so this exception is observed as the cause of a {@code YamlEngineException} rather + * than caught directly. + */ +public final class ParseException extends RuntimeException { + private final @Nullable Mark location; + + public ParseException(String message, @Nullable Mark location) { + super(location == null ? message : message + location); + this.location = location; + } + + /** + * Returns the location at which the error occurred. + * + * @return the error location + */ + public @Nullable Mark getLocation() { + return location; + } +} diff --git a/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties b/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties index b1e52a977..40ba61b9a 100644 --- a/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties +++ b/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties @@ -798,6 +798,11 @@ Converter path `{0}` has invalid syntax. jsonParseError=\ Error parsing JSON document. +jsonParseErrorDynamicPropertyDefault=\ +Cannot parse an object with key `"default"` into a `Dynamic`.\ +\n\ +Try parsing into a `Mapping` instead, by setting `useMapping = true` in the `pkl.json#Parser`. + yamlParseError=\ Error parsing YAML document. @@ -806,6 +811,11 @@ Error parsing YAML document: The number of aliases for collection nodes exceeds \n\ To increase the allowed maximum, set `YamlRenderer.maxCollectionAliases`. +yamlParseErrorDynamicPropertyDefault=\ +Cannot parse an object with key `default` into a `Dynamic`.\ +\n\ +Try parsing into a `Mapping` instead, by setting `useMapping = true` in the `pkl.yaml#Parser`. + evaluationTimedOut=\ Evaluation timed out after {0,number,#.##} second(s). diff --git a/pkl-core/src/test/files/LanguageSnippetTests/input/api/jsonParser1.pkl b/pkl-core/src/test/files/LanguageSnippetTests/input/api/jsonParser1.pkl index b4c7fabfd..02a7d11be 100644 --- a/pkl-core/src/test/files/LanguageSnippetTests/input/api/jsonParser1.pkl +++ b/pkl-core/src/test/files/LanguageSnippetTests/input/api/jsonParser1.pkl @@ -66,3 +66,19 @@ res16 = parser.parse(""" // invalid syntax res17 = test.catch(() -> parser.parse("!@#$%")) + +// object key `default` cannot be represented as a `Dynamic` property +// https://github.com/apple/pkl/issues/561 +res18 = test.catch(() -> parser.parse(""" + {"default": null} + """)) + +// nested objects are rejected too +res19 = test.catch(() -> parser.parse(""" + {"person": {"default": null}} + """)) + +// parsing into a `Mapping` keeps working +res20 = (parser) { useMapping = true }.parse(""" + {"default": null} + """) diff --git a/pkl-core/src/test/files/LanguageSnippetTests/input/api/yamlParser2.pkl b/pkl-core/src/test/files/LanguageSnippetTests/input/api/yamlParser2.pkl index 043784188..4c4895a33 100644 --- a/pkl-core/src/test/files/LanguageSnippetTests/input/api/yamlParser2.pkl +++ b/pkl-core/src/test/files/LanguageSnippetTests/input/api/yamlParser2.pkl @@ -91,3 +91,25 @@ res15 = parser.parseAll(""" hobby: surfing ... """) + +// object key `default` cannot be represented as a `Dynamic` property +// https://github.com/apple/pkl/issues/561 +res16 = test.catch(() -> parser.parse(""" + default: null + """)) + +// nested mappings are rejected too +res17 = test.catch(() -> parser.parse(""" + person: + default: null + """)) + +// `parseAll` rejects it as well +res18 = test.catch(() -> parser.parseAll(""" + default: null + """)) + +// parsing into a `Mapping` keeps working +res19 = (parser) { useMapping = true }.parse(""" + default: null + """) diff --git a/pkl-core/src/test/files/LanguageSnippetTests/input/errors/jsonParserDefaultProperty.pkl b/pkl-core/src/test/files/LanguageSnippetTests/input/errors/jsonParserDefaultProperty.pkl new file mode 100644 index 000000000..f78023cb9 --- /dev/null +++ b/pkl-core/src/test/files/LanguageSnippetTests/input/errors/jsonParserDefaultProperty.pkl @@ -0,0 +1,9 @@ +import "pkl:json" + +// https://github.com/apple/pkl/issues/561 +res = new json.Parser {}.parse(""" + { + "hello": "world", + "default": "greeting" + } + """) diff --git a/pkl-core/src/test/files/LanguageSnippetTests/input/errors/yamlParserDefaultProperty.pkl b/pkl-core/src/test/files/LanguageSnippetTests/input/errors/yamlParserDefaultProperty.pkl new file mode 100644 index 000000000..982fd21b1 --- /dev/null +++ b/pkl-core/src/test/files/LanguageSnippetTests/input/errors/yamlParserDefaultProperty.pkl @@ -0,0 +1,7 @@ +import "pkl:yaml" + +// https://github.com/apple/pkl/issues/561 +res = new yaml.Parser {}.parse(""" + hello: world + default: greeting + """) diff --git a/pkl-core/src/test/files/LanguageSnippetTests/output/api/jsonParser1.pcf b/pkl-core/src/test/files/LanguageSnippetTests/output/api/jsonParser1.pcf index b51ba3d1c..2aba6921d 100644 --- a/pkl-core/src/test/files/LanguageSnippetTests/output/api/jsonParser1.pcf +++ b/pkl-core/src/test/files/LanguageSnippetTests/output/api/jsonParser1.pcf @@ -48,3 +48,8 @@ res16 { } } res17 = "Error parsing JSON document." +res18 = "Error parsing JSON document." +res19 = "Error parsing JSON document." +res20 { + ["default"] = null +} diff --git a/pkl-core/src/test/files/LanguageSnippetTests/output/api/yamlParser2.pcf b/pkl-core/src/test/files/LanguageSnippetTests/output/api/yamlParser2.pcf index 66336fde7..30567de18 100644 --- a/pkl-core/src/test/files/LanguageSnippetTests/output/api/yamlParser2.pcf +++ b/pkl-core/src/test/files/LanguageSnippetTests/output/api/yamlParser2.pcf @@ -47,3 +47,9 @@ res15 = List(new { }, new { hobby = "surfing" }) +res16 = "Error parsing YAML document." +res17 = "Error parsing YAML document." +res18 = "Error parsing YAML document." +res19 { + ["default"] = null +} diff --git a/pkl-core/src/test/files/LanguageSnippetTests/output/errors/jsonParserDefaultProperty.err b/pkl-core/src/test/files/LanguageSnippetTests/output/errors/jsonParserDefaultProperty.err new file mode 100644 index 000000000..1243abbda --- /dev/null +++ b/pkl-core/src/test/files/LanguageSnippetTests/output/errors/jsonParserDefaultProperty.err @@ -0,0 +1,17 @@ +–– Pkl Error –– +Error parsing JSON document. + +x | res = new json.Parser {}.parse(""" + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +at jsonParserDefaultProperty#res (file:///$snippetsDir/input/errors/jsonParserDefaultProperty.pkl) + +Cannot parse an object with key `"default"` into a `Dynamic`. +Try parsing into a `Mapping` instead, by setting `useMapping = true` in the `pkl.json#Parser`. at 3:14 + +xxx | renderer.renderDocument(value) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +at pkl.base#Module.output.text (pkl:base) + +xxx | if (renderer is BytesRenderer) renderer.renderDocument(value) else text.encodeToBytes("UTF-8") + ^^^^ +at pkl.base#Module.output.bytes (pkl:base) diff --git a/pkl-core/src/test/files/LanguageSnippetTests/output/errors/yamlParserDefaultProperty.err b/pkl-core/src/test/files/LanguageSnippetTests/output/errors/yamlParserDefaultProperty.err new file mode 100644 index 000000000..8be6c4710 --- /dev/null +++ b/pkl-core/src/test/files/LanguageSnippetTests/output/errors/yamlParserDefaultProperty.err @@ -0,0 +1,19 @@ +–– Pkl Error –– +Error parsing YAML document. + +x | res = new yaml.Parser {}.parse(""" + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +at yamlParserDefaultProperty#res (file:///$snippetsDir/input/errors/yamlParserDefaultProperty.pkl) + +Cannot parse an object with key `default` into a `Dynamic`. +Try parsing into a `Mapping` instead, by setting `useMapping = true` in the `pkl.yaml#Parser`. in input_string, line 2, column 1: + default: greeting + ^ + +xxx | renderer.renderDocument(value) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +at pkl.base#Module.output.text (pkl:base) + +xxx | if (renderer is BytesRenderer) renderer.renderDocument(value) else text.encodeToBytes("UTF-8") + ^^^^ +at pkl.base#Module.output.bytes (pkl:base)