From d742ce354222f89d8567efcd1c8de57d7da9cff7 Mon Sep 17 00:00:00 2001 From: Fabian Meumertzheim Date: Thu, 26 Feb 2026 10:29:20 +0100 Subject: [PATCH 1/4] Add `starlark` output format --- .../main/java/org/pkl/core/OutputFormat.java | 1 + .../input/api/starlarkRenderer1.pkl | 34 ++ .../output/api/starlarkRenderer1.pcf | 62 ++++ .../output/errors/cannotFindStdLibModule.err | 1 + .../org/pkl/core/EvaluateOutputTextTest.kt | 5 + .../org/pkl/core/rendererTest.starlark | 112 +++++++ stdlib/base.pkl | 5 +- stdlib/starlark.pkl | 303 ++++++++++++++++++ 8 files changed, 522 insertions(+), 1 deletion(-) create mode 100644 pkl-core/src/test/files/LanguageSnippetTests/input/api/starlarkRenderer1.pkl create mode 100644 pkl-core/src/test/files/LanguageSnippetTests/output/api/starlarkRenderer1.pcf create mode 100644 pkl-core/src/test/resources/org/pkl/core/rendererTest.starlark create mode 100644 stdlib/starlark.pkl 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" +} From af18ce14047b4ff9afb9343873e33ed915f65a3b Mon Sep 17 00:00:00 2001 From: Fabian Meumertzheim Date: Thu, 11 Jun 2026 16:00:59 +0200 Subject: [PATCH 2/4] Support comments and raw statements in module output * Add `starlark.Commented`, which attaches `#`-prefixed comment lines to the rendered form of a top-level statement. * Render top-level `RenderDirective`s verbatim instead of as assignments so that raw statements such as `load()` can be emitted. Co-Authored-By: Claude Fable 5 --- .../input/api/starlarkRenderer1.pkl | 12 ++++++++ .../output/api/starlarkRenderer1.pcf | 18 ++++++++++++ stdlib/starlark.pkl | 28 ++++++++++++++++++- 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/pkl-core/src/test/files/LanguageSnippetTests/input/api/starlarkRenderer1.pkl b/pkl-core/src/test/files/LanguageSnippetTests/input/api/starlarkRenderer1.pkl index 7cf69bfb0..babc53f5e 100644 --- a/pkl-core/src/test/files/LanguageSnippetTests/input/api/starlarkRenderer1.pkl +++ b/pkl-core/src/test/files/LanguageSnippetTests/input/api/starlarkRenderer1.pkl @@ -32,3 +32,15 @@ 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))) + +res23 = renderer.renderDocument(new Dynamic { + ["_load"] = new RenderDirective { text = "load(\"@rules_cc//cc:defs.bzl\", \"cc_library\")" } + ["lib"] = new starlark.Commented { + comment = "A commented rule.\n\nSecond paragraph." + value = new Person { name = "pigeon"; age = 42 } + } + ["VERSIONS"] = new starlark.Commented { + comment = "A commented assignment." + value = List("1.0", "1.1") + } +}) diff --git a/pkl-core/src/test/files/LanguageSnippetTests/output/api/starlarkRenderer1.pcf b/pkl-core/src/test/files/LanguageSnippetTests/output/api/starlarkRenderer1.pcf index a18951e33..e0a05324c 100644 --- a/pkl-core/src/test/files/LanguageSnippetTests/output/api/starlarkRenderer1.pcf +++ b/pkl-core/src/test/files/LanguageSnippetTests/output/api/starlarkRenderer1.pcf @@ -60,3 +60,21 @@ res19 = "Cannot render value of type `TypeAlias` as Starlark. Value: starlarkRen 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)" +res23 = """ + load("@rules_cc//cc:defs.bzl", "cc_library") + + # A commented rule. + # + # Second paragraph. + Person( + name = "lib", + age = 42, + ) + + # A commented assignment. + VERSIONS = [ + "1.0", + "1.1", + ] + + """ diff --git a/stdlib/starlark.pkl b/stdlib/starlark.pkl index f4a4c7220..e27596290 100644 --- a/stdlib/starlark.pkl +++ b/stdlib/starlark.pkl @@ -273,11 +273,22 @@ class Renderer extends ValueRenderer { .join("\n") local function renderTopLevelStatement(name: String, value: Any): String = - if (value is Typed && !(value is RenderDirective)) + if (value is Commented) + "\(renderCommentLines(value.comment))\n\(renderStatement(name, applyConverters(value.value)))" + else + renderStatement(name, value) + + local function renderStatement(name: String, value: Any): String = + if (value is RenderDirective) + "\(value.text)\n" + else if (value is Typed) renderRuleCall(name, value) else "\(name) = \(renderConverted(value, 0))\n" + local function renderCommentLines(comment: String): String = + comment.split("\n").map((line) -> if (line.isEmpty) "#" else "# \(line)").join("\n") + local function renderRuleCall(name: String, value: Typed): String = let (props = value.toMap()) let (filtered = if (omitNullProperties) props.filter((_, v) -> v != null) else props) @@ -301,3 +312,18 @@ class Renderer extends ValueRenderer { ) "\(value.getClass().simpleName)(\n\(lines.join(",\n")),\n)\n" } + +/// A top-level statement with attached comment lines. +/// +/// When rendered as part of a module or document, [comment] is emitted as `#`-prefixed lines +/// directly above [value]. Useful for attaching documentation to generated rule instantiations. +/// +/// Only meaningful at the top level of a module or document; in nested value positions, +/// instances render like any other [Typed] value. +class Commented { + /// The comment text, without the leading `# `. + comment: String + + /// The annotated value. + value: Any +} From fa48d5791aa4ae3819011f8c3f6bbeb27451f405 Mon Sep 17 00:00:00 2001 From: Fabian Meumertzheim Date: Thu, 11 Jun 2026 16:24:51 +0200 Subject: [PATCH 3/4] Emit `load()` statements via `@starlark.Load` class annotations Classes annotated with `@starlark.Load { label = "..." }` contribute a `load()` statement to the top of the rendered document whenever an instance is rendered. Statements are deduplicated per label, sorted by label, and load the annotated classes' simple names. Co-Authored-By: Claude Fable 5 --- .../input/api/starlarkRenderer1.pkl | 13 +++ .../output/api/starlarkRenderer1.pcf | 19 +++++ stdlib/starlark.pkl | 80 ++++++++++++++++++- 3 files changed, 108 insertions(+), 4 deletions(-) diff --git a/pkl-core/src/test/files/LanguageSnippetTests/input/api/starlarkRenderer1.pkl b/pkl-core/src/test/files/LanguageSnippetTests/input/api/starlarkRenderer1.pkl index babc53f5e..97a4f1204 100644 --- a/pkl-core/src/test/files/LanguageSnippetTests/input/api/starlarkRenderer1.pkl +++ b/pkl-core/src/test/files/LanguageSnippetTests/input/api/starlarkRenderer1.pkl @@ -44,3 +44,16 @@ res23 = renderer.renderDocument(new Dynamic { value = List("1.0", "1.1") } }) + +@starlark.Load { label = "@rules_cc//cc:defs.bzl" } +class cc_library { + srcs: Listing +} + +res24 = renderer.renderDocument(new Dynamic { + ["lib"] = new cc_library { srcs { "foo.cc" } } + ["lib2"] = new starlark.Commented { + comment = "Another library." + value = new cc_library { srcs { "bar.cc" } } + } +}) diff --git a/pkl-core/src/test/files/LanguageSnippetTests/output/api/starlarkRenderer1.pcf b/pkl-core/src/test/files/LanguageSnippetTests/output/api/starlarkRenderer1.pcf index e0a05324c..56bbf257e 100644 --- a/pkl-core/src/test/files/LanguageSnippetTests/output/api/starlarkRenderer1.pcf +++ b/pkl-core/src/test/files/LanguageSnippetTests/output/api/starlarkRenderer1.pcf @@ -77,4 +77,23 @@ res23 = """ "1.1", ] + """ +res24 = """ + load("@rules_cc//cc:defs.bzl", "cc_library") + + cc_library( + name = "lib", + srcs = [ + "foo.cc", + ], + ) + + # Another library. + cc_library( + name = "lib2", + srcs = [ + "bar.cc", + ], + ) + """ diff --git a/stdlib/starlark.pkl b/stdlib/starlark.pkl index e27596290..b37d312b1 100644 --- a/stdlib/starlark.pkl +++ b/stdlib/starlark.pkl @@ -18,6 +18,8 @@ @ModuleInfo { minPklVersion = "0.31.0" } module pkl.starlark +import "pkl:reflect" + /// Renders values as [Starlark](https://github.com/bazelbuild/starlark/blob/master/spec.md). /// /// Pkl values are mapped to Starlark values as follows: @@ -92,10 +94,11 @@ class Renderer extends ValueRenderer { /// 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) + renderLoadStatements(value) + + (if (value is Typed | Dynamic) + renderModuleObject(value) + else + renderValue(value)) function renderValue(value: Any): String = renderAny(value, 0) @@ -263,6 +266,56 @@ class Renderer extends ValueRenderer { ) "\(callName)(\n\(lines.join(",\n")),\n\(indentStr(depth)))" + // --- load() statement collection --- + + local function loadLabelOf(clazz: Class): String? = + let (annotation = reflect.Class(clazz).annotations.findOrNull((it) -> it is Load)) + if (annotation is Load) annotation.label else null + + local function mergeLoads( + a: Map>, + b: Map> + ): Map> = + b.entries.fold(a, (acc, e) -> acc.put(e.first, (acc.getOrNull(e.first) ?? Set()) + e.second)) + + // Collects the labels and symbols declared via [Load] annotations on the classes of all + // [Typed] values rendered as part of [value]. + local function collectLoads(value: Any): Map> = + let (v = applyConverters(value)) + if (v is RenderDirective) + Map() + else if (v is Commented) + collectLoads(v.value) + else if (v is Typed) + v.toMap().values.fold( + let (label = loadLabelOf(v.getClass())) + if (label == null) Map() else Map(label, Set(v.getClass().simpleName)), + (acc, elem) -> mergeLoads(acc, collectLoads(elem)) + ) + else if (v is Dynamic) + (v.toMap().values + v.toList()).fold(Map(), (acc, elem) -> mergeLoads(acc, collectLoads(elem))) + else if (v is List | Listing | Set) + (if (v is Set) v.toList() else toListValue(v)) + .fold(Map(), (acc, elem) -> mergeLoads(acc, collectLoads(elem))) + else if (v is Map | Mapping) + toMapValue(v).values.fold(Map(), (acc, elem) -> mergeLoads(acc, collectLoads(elem))) + else if (v is Pair) + mergeLoads(collectLoads(v.first), collectLoads(v.second)) + else + Map() + + local function renderLoadStatements(value: Any): String = + let (loads = collectLoads(value)) + if (loads.isEmpty) + "" + else + loads.entries + .sortBy((e) -> e.first) + .map((e) -> + "load(\(renderString(e.first)), \(e.second.toList().sort().map((s) -> renderString(s)).join(", ")))" + ) + .join("\n") + "\n\n" + // --- Module-level / document-level rendering --- local function renderModuleObject(value: Typed | Dynamic): String = @@ -327,3 +380,22 @@ class Commented { /// The annotated value. value: Any } + +/// Declares the Bazel `load()` statement that makes the annotated class's rule or macro +/// available in a BUILD file. +/// +/// When rendering a document, [Renderer] collects this annotation from the classes of all +/// rendered [Typed] values and emits the deduplicated `load()` statements, sorted by label, +/// at the top of the document. The loaded symbol is the annotated class's simple name. +/// +/// Example: +/// ``` +/// @starlark.Load { label = "@rules_cc//cc:defs.bzl" } +/// class cc_library { +/// srcs: Listing +/// } +/// ``` +class Load extends Annotation { + /// The label of the `.bzl` file that declares the symbol. + label: String +} From b04783f66c6f421cd580d9ebf50618ddfda1e21b Mon Sep 17 00:00:00 2001 From: Fabian Meumertzheim Date: Thu, 11 Jun 2026 17:47:19 +0200 Subject: [PATCH 4/4] Sort `load()` statements the way buildifier does Replaces the plain lexical sort with a port of buildtools' `compareLoadLabels`/`comparePaths`: labels with an explicit repository go first, the empty package precedes named packages, packages and file names compare segment-wise and case-insensitively, and relative labels go last. Verified against `buildifier --mode=diff`, which leaves the rendered output unchanged. Co-Authored-By: Claude Fable 5 --- .../input/api/starlarkRenderer1.pkl | 16 +++++ .../output/api/starlarkRenderer1.pcf | 26 +++++++ stdlib/starlark.pkl | 68 ++++++++++++++++++- 3 files changed, 107 insertions(+), 3 deletions(-) diff --git a/pkl-core/src/test/files/LanguageSnippetTests/input/api/starlarkRenderer1.pkl b/pkl-core/src/test/files/LanguageSnippetTests/input/api/starlarkRenderer1.pkl index 97a4f1204..1ef33d259 100644 --- a/pkl-core/src/test/files/LanguageSnippetTests/input/api/starlarkRenderer1.pkl +++ b/pkl-core/src/test/files/LanguageSnippetTests/input/api/starlarkRenderer1.pkl @@ -57,3 +57,19 @@ res24 = renderer.renderDocument(new Dynamic { value = new cc_library { srcs { "bar.cc" } } } }) + +@starlark.Load { label = ":local_rules.bzl" } +class my_local_rule {} + +@starlark.Load { label = "//:root.bzl" } +class my_root_rule {} + +@starlark.Load { label = "//tools:tools.bzl" } +class my_tool_rule {} + +res25 = renderer.renderDocument(new Dynamic { + ["lib"] = new cc_library { srcs { "foo.cc" } } + ["local"] = new my_local_rule {} + ["root"] = new my_root_rule {} + ["tool"] = new my_tool_rule {} +}) diff --git a/pkl-core/src/test/files/LanguageSnippetTests/output/api/starlarkRenderer1.pcf b/pkl-core/src/test/files/LanguageSnippetTests/output/api/starlarkRenderer1.pcf index 56bbf257e..901c18b0f 100644 --- a/pkl-core/src/test/files/LanguageSnippetTests/output/api/starlarkRenderer1.pcf +++ b/pkl-core/src/test/files/LanguageSnippetTests/output/api/starlarkRenderer1.pcf @@ -96,4 +96,30 @@ res24 = """ ], ) + """ +res25 = """ + load("@rules_cc//cc:defs.bzl", "cc_library") + load("//:root.bzl", "my_root_rule") + load("//tools:tools.bzl", "my_tool_rule") + load(":local_rules.bzl", "my_local_rule") + + cc_library( + name = "lib", + srcs = [ + "foo.cc", + ], + ) + + my_local_rule( + name = "local", + ) + + my_root_rule( + name = "root", + ) + + my_tool_rule( + name = "tool", + ) + """ diff --git a/stdlib/starlark.pkl b/stdlib/starlark.pkl index b37d312b1..7a98bbde5 100644 --- a/stdlib/starlark.pkl +++ b/stdlib/starlark.pkl @@ -304,13 +304,74 @@ class Renderer extends ValueRenderer { else Map() + // Parses a load label into repository, package, and target, mirroring buildtools' + // `labels.ParseRelative`. Relative labels get a pseudo-package that sorts after all real + // package names so that they go last. + local function parseLoadLabel(label: String): Dynamic = + if (!label.startsWith("@") && !label.startsWith("//")) + new Dynamic { + repository = "" + packagePath = "\u{7F}" + target = label.replaceFirst(Regex("^:+"), "") + } + else if (label.startsWith("@") && !label.replaceFirst(Regex("^@+"), "").contains("/")) + // "@foo" refers to "@foo//:foo". + let (repo = label.replaceFirst(Regex("^@+"), "")) + new Dynamic { repository = repo; packagePath = ""; target = repo } + else + let (repo = if (label.startsWith("@")) label.replaceFirst(Regex("^@+"), "").splitLimit("/", 2).first else "") + let (rest = if (label.startsWith("@")) "/" + label.replaceFirst(Regex("^@+"), "").splitLimit("/", 2)[1] else label) + let (parts = rest.splitLimit(":", 2)) + let (pkg = parts.first.replaceFirst(Regex("^//"), "")) + if (parts.length == 2 && !parts[1].isEmpty) + new Dynamic { repository = repo; packagePath = pkg; target = parts[1] } + else if (!rest.startsWith("//")) + // Maybe not really a label; store everything in the target. + new Dynamic { repository = repo; packagePath = ""; target = rest } + else + new Dynamic { repository = repo; packagePath = pkg; target = pkg.split("/").last } + + // Mirrors buildtools' `comparePaths`: compares paths segment by segment, case-insensitively, + // falling back to a case-sensitive comparison for determinism. + local function comparePaths(path1: String, path2: String): Boolean = + if (path1 == path2) + false + else + let (chunks1 = path1.split("/").map((c) -> c.toLowerCase())) + let (chunks2 = path2.split("/").map((c) -> c.toLowerCase())) + let (firstDiff = chunks1.zip(chunks2).findOrNull((p) -> p.first != p.second)) + if (firstDiff != null) + firstDiff.first < firstDiff.second + else if (chunks1.length > chunks2.length) + false + else + path1 <= path2 + + // Mirrors buildifier's `compareLoadLabels`: labels with an explicit repository go first; + // ties are broken by repository, then package (empty goes first, relative goes last), + // then file name. + local function compareLoadLabels(label1: String, label2: String): Boolean = + if (label1.startsWith("@") != label2.startsWith("@")) + label1.startsWith("@") + else + let (parsed1 = parseLoadLabel(label1)) + let (parsed2 = parseLoadLabel(label2)) + if (parsed1.repository != parsed2.repository) + parsed1.repository < parsed2.repository + else if (parsed1.packagePath.isEmpty != parsed2.packagePath.isEmpty) + parsed1.packagePath.isEmpty + else if (parsed1.packagePath != parsed2.packagePath) + comparePaths(parsed1.packagePath, parsed2.packagePath) + else + comparePaths(parsed1.target, parsed2.target) + local function renderLoadStatements(value: Any): String = let (loads = collectLoads(value)) if (loads.isEmpty) "" else loads.entries - .sortBy((e) -> e.first) + .sortWith((a, b) -> compareLoadLabels(a.first, b.first)) .map((e) -> "load(\(renderString(e.first)), \(e.second.toList().sort().map((s) -> renderString(s)).join(", ")))" ) @@ -385,8 +446,9 @@ class Commented { /// available in a BUILD file. /// /// When rendering a document, [Renderer] collects this annotation from the classes of all -/// rendered [Typed] values and emits the deduplicated `load()` statements, sorted by label, -/// at the top of the document. The loaded symbol is the annotated class's simple name. +/// rendered [Typed] values and emits the deduplicated `load()` statements at the top of the +/// document, ordered the same way `buildifier` sorts them. The loaded symbol is the annotated +/// class's simple name. /// /// Example: /// ```