diff --git a/pkl-core/src/main/java/org/pkl/core/OutputFormat.java b/pkl-core/src/main/java/org/pkl/core/OutputFormat.java index 1ecadf9f9..5249cbc2d 100644 --- a/pkl-core/src/main/java/org/pkl/core/OutputFormat.java +++ b/pkl-core/src/main/java/org/pkl/core/OutputFormat.java @@ -22,6 +22,7 @@ public enum OutputFormat { PCF("pcf"), PROPERTIES("properties"), PLIST("plist"), + STARLARK("starlark"), TEXTPROTO("textproto"), XML("xml"), YAML("yaml"); diff --git a/pkl-core/src/test/files/LanguageSnippetTests/input/api/starlarkRenderer1.pkl b/pkl-core/src/test/files/LanguageSnippetTests/input/api/starlarkRenderer1.pkl new file mode 100644 index 000000000..7cf69bfb0 --- /dev/null +++ b/pkl-core/src/test/files/LanguageSnippetTests/input/api/starlarkRenderer1.pkl @@ -0,0 +1,34 @@ +import "pkl:test" +import "pkl:starlark" + +class Person { + name: String + age: Int +} + +typealias Email = String + +local renderer = new starlark.Renderer {} + +res1 = renderer.renderValue(123) +res2 = renderer.renderValue(1.23) +res3 = renderer.renderValue(false) +res4 = renderer.renderValue("pigeon") +res6 = renderer.renderValue(List("pigeon", "parrot")) +res7 = renderer.renderValue(Set("pigeon", "parrot")) +res8 = renderer.renderValue(Map("name", "pigeon", "age", 42)) +res9 = renderer.renderValue(new Listing { "pigeon"; "parrot" }) +res10 = renderer.renderValue(new Mapping { ["name"] = "pigeon"; ["age"] = 42 }) +res11 = renderer.renderValue(new Dynamic { name = "pigeon"; age = 42 }) +res12 = renderer.renderValue(new Person { name = "pigeon"; age = 42 }) +res13 = renderer.renderValue(null) +res15 = renderer.renderValue(Pair(1, 2)) +res16 = renderer.renderValue(Pair("pigeon", List(1, 2, 3))) + +res14 = test.catch(() -> renderer.renderValue(1.min)) +res17 = test.catch(() -> renderer.renderValue(1.mb)) +res18 = test.catch(() -> renderer.renderValue(Person)) +res19 = test.catch(() -> renderer.renderValue(Email)) +res20 = test.catch(() -> renderer.renderValue((x) -> x)) +res21 = test.catch(() -> new starlark.Renderer { converters { [Int] = (_) -> throw("ouch") } }.renderValue(42)) +res22 = test.catch(() -> renderer.renderValue(IntSeq(1, 4))) diff --git a/pkl-core/src/test/files/LanguageSnippetTests/output/api/starlarkRenderer1.pcf b/pkl-core/src/test/files/LanguageSnippetTests/output/api/starlarkRenderer1.pcf new file mode 100644 index 000000000..a18951e33 --- /dev/null +++ b/pkl-core/src/test/files/LanguageSnippetTests/output/api/starlarkRenderer1.pcf @@ -0,0 +1,62 @@ +res1 = "123" +res2 = "1.23" +res3 = "False" +res4 = "\"pigeon\"" +res6 = """ + [ + "pigeon", + "parrot", + ] + """ +res7 = """ + set([ + "pigeon", + "parrot", + ]) + """ +res8 = """ + { + "name": "pigeon", + "age": 42, + } + """ +res9 = """ + [ + "pigeon", + "parrot", + ] + """ +res10 = """ + { + "name": "pigeon", + "age": 42, + } + """ +res11 = """ + struct( + age = 42, + name = "pigeon", + ) + """ +res12 = """ + Person( + name = "pigeon", + age = 42, + ) + """ +res13 = "None" +res15 = "(1, 2)" +res16 = """ + ("pigeon", [ + 1, + 2, + 3, + ]) + """ +res14 = "Cannot render value of type `Duration` as Starlark. Value: 1.min" +res17 = "Cannot render value of type `DataSize` as Starlark. Value: 1.mb" +res18 = "Cannot render value of type `Class` as Starlark. Value: starlarkRenderer1#Person" +res19 = "Cannot render value of type `TypeAlias` as Starlark. Value: starlarkRenderer1#Email" +res20 = "Cannot render value of type `Function1` as Starlark. Value: new Function1 {}" +res21 = "ouch" +res22 = "Cannot render value of type `IntSeq` as Starlark. Value: IntSeq(1, 4)" diff --git a/pkl-core/src/test/files/LanguageSnippetTests/output/errors/cannotFindStdLibModule.err b/pkl-core/src/test/files/LanguageSnippetTests/output/errors/cannotFindStdLibModule.err index f2bc6644d..ffd8c8847 100644 --- a/pkl-core/src/test/files/LanguageSnippetTests/output/errors/cannotFindStdLibModule.err +++ b/pkl-core/src/test/files/LanguageSnippetTests/output/errors/cannotFindStdLibModule.err @@ -25,6 +25,7 @@ pkl:release pkl:semver pkl:settings pkl:shell +pkl:starlark pkl:test pkl:xml pkl:yaml diff --git a/pkl-core/src/test/kotlin/org/pkl/core/EvaluateOutputTextTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/EvaluateOutputTextTest.kt index ffc2a53f3..b1470238e 100644 --- a/pkl-core/src/test/kotlin/org/pkl/core/EvaluateOutputTextTest.kt +++ b/pkl-core/src/test/kotlin/org/pkl/core/EvaluateOutputTextTest.kt @@ -42,6 +42,11 @@ class EvaluateOutputTextTest { checkRenderedOutput(OutputFormat.PLIST) } + @Test + fun `render Starlark`() { + checkRenderedOutput(OutputFormat.STARLARK) + } + private fun checkRenderedOutput(format: OutputFormat) { val evaluator = EvaluatorBuilder.preconfigured().setOutputFormat(format).build() diff --git a/pkl-core/src/test/resources/org/pkl/core/rendererTest.starlark b/pkl-core/src/test/resources/org/pkl/core/rendererTest.starlark new file mode 100644 index 000000000..fcd69ab76 --- /dev/null +++ b/pkl-core/src/test/resources/org/pkl/core/rendererTest.starlark @@ -0,0 +1,112 @@ +int = 123 + +float = 1.23 + +bool = True + +string = "Pigeon" + +unicodeString = "abc😀abc😎abc" + +multiLineString = "have a\ngreat\nday" + +list = [ + 123, + 1.23, + True, + "Pigeon", + "abc😀abc😎abc", + "have a\ngreat\nday", + [ + 1, + 2, + 3, + ], + set([ + 1, + 2, + 3, + ]), + { + "one": 1, + }, + struct( + name = "Pigeon", + ), +] + +set = set([ + 123, + 1.23, + True, + "Pigeon", + "abc😀abc😎abc", + "have a\ngreat\nday", + [ + 1, + 2, + 3, + ], + set([ + 1, + 2, + 3, + ]), + { + "one": 1, + }, + struct( + name = "Pigeon", + ), +]) + +map = { + "one": 123, + "two": 1.23, + "three": True, + "four": "Pigeon", + "five": "abc😀abc😎abc", + "six": "have a\ngreat\nday", + "seven": [ + 1, + 2, + 3, + ], + "eight": set([ + 1, + 2, + 3, + ]), + "nine": { + "one": 1, + }, + "ten": struct( + name = "Pigeon", + ), +} + +Person( + name = "typedObject", + address = Address( + street = "Folsom St.", + ), + age = 30, + hobbies = [ + "swimming", + "gardening", + "reading", + ], +) + +container = struct( + address = struct( + hobbies = [ + "swimming", + "gardening", + "reading", + ], + street = "Folsom St.", + ), + age = 30, + name = "Pigeon", +) diff --git a/stdlib/base.pkl b/stdlib/base.pkl index d1e407094..1c35dc6c3 100644 --- a/stdlib/base.pkl +++ b/stdlib/base.pkl @@ -26,6 +26,7 @@ import "pkl:jsonnet" import "pkl:math" import "pkl:pklbinary" import "pkl:protobuf" +import "pkl:starlark" import "pkl:xml" import "pkl:yaml" @@ -119,13 +120,15 @@ abstract external class Module { new protobuf.Renderer {} else if (format == "xml") new xml.Renderer {} + else if (format == "starlark") + new starlark.Renderer {} else if (format == "yaml") new YamlRenderer {} else if (format == "pkl-binary") new pklbinary.Renderer {} else throw( - "Unknown output format: `\(format)`. Supported formats are `json`, `jsonnet`, `pcf`, `plist`, `properties`, `textproto`, `xml`, `yaml`, `pkl-binary`." + "Unknown output format: `\(format)`. Supported formats are `json`, `jsonnet`, `pcf`, `plist`, `properties`, `starlark`, `textproto`, `xml`, `yaml`, `pkl-binary`." ) text = if (renderer is ValueRenderer) diff --git a/stdlib/starlark.pkl b/stdlib/starlark.pkl new file mode 100644 index 000000000..f4a4c7220 --- /dev/null +++ b/stdlib/starlark.pkl @@ -0,0 +1,303 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2024-2025 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. +//===----------------------------------------------------------------------===// + +/// A [Starlark](https://github.com/bazelbuild/starlark) renderer. +@ModuleInfo { minPklVersion = "0.31.0" } +module pkl.starlark + +/// Renders values as [Starlark](https://github.com/bazelbuild/starlark/blob/master/spec.md). +/// +/// Pkl values are mapped to Starlark values as follows: +/// +/// | Pkl type | Starlark type | +/// | -------------- | ---------------- | +/// | [Null] | None | +/// | [Boolean] | bool | +/// | [Int] | int | +/// | [Float] | float | +/// | [String] | string | +/// | [List] | list | +/// | [Set] | set | +/// | [Map] | dict | +/// | [Listing] | list | +/// | [Mapping] | dict | +/// | [Dynamic] | struct (if properties) or list (if elements) | +/// | [Typed] | function call | +/// | [Pair] | tuple | +/// +/// Some Pkl types, such as [Duration] and [DataSize], don't have a Starlark equivalent. +/// To render values of such types, define _output converters_ (see [Renderer.converters]). +/// +/// When rendering module output, [Typed] properties are rendered as rule instantiations +/// (function calls) with an injected `name` argument derived from the property name. +/// All other properties are rendered as variable assignments. +/// +/// Example: +/// ``` +/// import "pkl:starlark" +/// +/// class CcLibrary { +/// srcs: Listing +/// deps: Listing +/// } +/// +/// myLib = new CcLibrary { +/// srcs { "foo.cc" } +/// deps { ":bar" } +/// } +/// +/// output { +/// renderer = new starlark.Renderer {} +/// } +/// ``` +/// +/// The above renders as: +/// ``` +/// cc_library( +/// name = "myLib", +/// srcs = [ +/// "foo.cc", +/// ], +/// deps = [ +/// ":bar", +/// ], +/// ) +/// ``` +class Renderer extends ValueRenderer { + extension = "starlark" + + /// The characters to use for indenting output. + /// + /// If empty (`""`), renders everything on a single line. + indent: String = " " + + /// Whether to omit properties and map entries whose value is `null`. + omitNullProperties: Boolean = true + + /// Renders [value] as a Starlark document. + /// + /// If [value] is a [Typed] or [Dynamic] object, its properties are rendered + /// as top-level Starlark statements. Otherwise, the value is rendered directly. + function renderDocument(value: Any): String = + if (value is Typed | Dynamic) + renderModuleObject(value) + else + renderValue(value) + + function renderValue(value: Any): String = renderAny(value, 0) + + // --- Converter support --- + + local convertersMap: Map = converters.toMap() + + local function getConvertersForValue(value: Any): List = + new Listing { + when (convertersMap.containsKey(value.getClass())) { + convertersMap[value.getClass()] + } + when (convertersMap.containsKey("*")) { + convertersMap["*"] + } + }.toList() + + local function applyConverters(value: Any): Any = + if (value == null) + null + else + let (convs = getConvertersForValue(value)) + convs.fold(value, (acc, c) -> c.apply(acc)) + + // --- String rendering (leverage JsonRenderer for escaping) --- + + local jsonRenderer = new JsonRenderer {} + + local function renderString(s: String): String = jsonRenderer.renderValue(s) + + // --- Indentation --- + + local isInline: Boolean = indent.isEmpty + + local function indentStr(depth: Int): String = indent.repeat(depth) + + // --- Core renderer: apply converters, then dispatch --- + + local function renderAny(value: Any, depth: Int): String = + renderConverted(applyConverters(value), depth) + + local function renderConverted(value: Any, depth: Int): String = + if (value == null) + "None" + else if (value is RenderDirective) + value.text + else if (value is Boolean) + if (value) "True" else "False" + else if (value is Int | Float) + "\(value)" + else if (value is String) + renderString(value) + else if (value is Pair) + renderPair(value, depth) + else if (value is Set) + "set(\(renderList(value.toList(), depth)))" + else if (value is List | Listing) + renderList(toListValue(value), depth) + else if (value is Map | Mapping) + renderDict(toMapValue(value), depth) + else if (value is Dynamic) + renderDynamic(value, depth) + else if (value is Typed) + renderTypedFunctionCall(value.getClass().simpleName, value.toMap(), depth) + else + throw( + "Cannot render value of type `\(value.getClass().simpleName)` as Starlark. Value: \(value)" + ) + + // --- Type coercions --- + + local function toListValue(value: List | Listing): List = + if (value is List) value else value.toList() + + local function toMapValue(value: Map | Mapping): Map = if (value is Map) value else value.toMap() + + // --- List / Listing → [...] --- + + local function renderList(items: List, depth: Int): String = + if (items.isEmpty) + "[]" + else if (isInline) + "[\(items.map((it) -> renderAny(it, depth)).join(", "))]" + else + "[\n\(items.map((it) -> "\(indentStr(depth + 1))\(renderAny(it, depth + 1))").join(",\n")),\n\(indentStr(depth))]" + + // --- Map / Mapping → {...} --- + + local function renderDict(entries: Map, depth: Int): String = + let (filtered = if (omitNullProperties) entries.filter((_, v) -> v != null) else entries) + if (filtered.isEmpty) + "{}" + else if (isInline) + "{\(new Listing { for (k, v in filtered) { "\(renderDictKey(k)): \(renderAny(v, depth))" } }.join(", "))}" + else + let ( + lines = + new Listing { + for (k, v in filtered) { + "\(indentStr(depth + 1))\(renderDictKey(k)): \(renderAny(v, depth + 1))" + } + }.toList() + ) + "{\n\(lines.join(",\n")),\n\(indentStr(depth))}" + + local function renderDictKey(key: Any): String = + if (key is String) + renderString(key) + else if (key is RenderDirective) + key.text + else + throw("Cannot render non-string dict key: \(key)") + + // --- Pair → tuple --- + + local function renderPair(pair: Pair, depth: Int): String = + "(\(renderAny(pair.first, depth)), \(renderAny(pair.second, depth)))" + + // --- Dynamic → struct(...) or [...] --- + + local function renderDynamic(value: Dynamic, depth: Int): String = + if (value.toList().isEmpty) + renderFunctionCall("struct", value.toMap(), depth) + else + renderList(value.toList(), depth) + + // --- Function call: name(key = val, ...) --- + // + // Dynamic/struct: arguments sorted alphabetically by key. + // Typed: `name` argument always first, remaining arguments sorted alphabetically. + + local function renderFunctionCall(callName: String, props: Map, depth: Int): String = + let (filtered = if (omitNullProperties) props.filter((_, v) -> v != null) else props) + let (sorted = filtered.entries.sortBy((e) -> "\(e.first)")) + if (filtered.isEmpty) + "\(callName)()" + else if (isInline) + "\(callName)(\(new Listing { for (e in sorted) { "\(e.first) = \(renderAny(e.second, depth))" } }.join(", ")))" + else + let ( + lines = + new Listing { + for (e in sorted) { + "\(indentStr(depth + 1))\(e.first) = \(renderAny(e.second, depth + 1))" + } + }.toList() + ) + "\(callName)(\n\(lines.join(",\n")),\n\(indentStr(depth)))" + + local function renderTypedFunctionCall(callName: String, props: Map, depth: Int): String = + let (filtered = if (omitNullProperties) props.filter((_, v) -> v != null) else props) + let (sorted = filtered.entries.sortBy((e) -> if ("\(e.first)" == "name") "" else "\(e.first)")) + if (filtered.isEmpty) + "\(callName)()" + else if (isInline) + "\(callName)(\(new Listing { for (e in sorted) { "\(e.first) = \(renderAny(e.second, depth))" } }.join(", ")))" + else + let ( + lines = + new Listing { + for (e in sorted) { + "\(indentStr(depth + 1))\(e.first) = \(renderAny(e.second, depth + 1))" + } + }.toList() + ) + "\(callName)(\n\(lines.join(",\n")),\n\(indentStr(depth)))" + + // --- Module-level / document-level rendering --- + + local function renderModuleObject(value: Typed | Dynamic): String = + let (props = value.toMap()) + let (filtered = if (omitNullProperties) props.filter((_, v) -> v != null) else props) + filtered.entries + .map((e) -> renderTopLevelStatement(e.first.toString(), applyConverters(e.second))) + .join("\n") + + local function renderTopLevelStatement(name: String, value: Any): String = + if (value is Typed && !(value is RenderDirective)) + renderRuleCall(name, value) + else + "\(name) = \(renderConverted(value, 0))\n" + + local function renderRuleCall(name: String, value: Typed): String = + let (props = value.toMap()) + let (filtered = if (omitNullProperties) props.filter((_, v) -> v != null) else props) + let (withoutName = filtered.filter((k, _) -> k != "name")) + let (sortedRest = withoutName.entries.sortBy((e) -> "\(e.first)")) + if (isInline) + let ( + inlineArgs = + List("name = \"\(name)\"") + + new Listing { for (e in sortedRest) { "\(e.first) = \(renderAny(e.second, 0))" } } + .toList() + ) + "\(value.getClass().simpleName)(\(inlineArgs.join(", ")))\n" + else + let ( + lines = + List("\(indent)name = \"\(name)\"") + + new Listing { + for (e in sortedRest) { "\(indent)\(e.first) = \(renderAny(e.second, 1))" } + }.toList() + ) + "\(value.getClass().simpleName)(\n\(lines.join(",\n")),\n)\n" +}