Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions internal/schema/pkl/schema/forma.pkl
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@
* SPDX-License-Identifier: FSL-1.1-ALv2
*/

module Forma
open module Forma

import "formae.pkl"

description: formae.Description?
properties: Dynamic?
properties: Any?

/// fill: entry point used by the self-injecting output below; constructs a
/// typed instance of the user's properties class from external `prop:` values.
function fill(clazz: Class): Typed = formae.fillProps(clazz)
hidden forma: Listing<formae.Resource|formae.Resolvable|formae.Stack|formae.Target|formae.Policy>

/// defaultStack: returns the first defined Stack
Expand All @@ -22,17 +26,27 @@ hidden defaultTarget: formae.TargetResolvable =
forma.toList().filterIsInstance(formae.Target).firstOrNull?.res
?? forma.toList().filterIsInstance(formae.TargetResolvable).firstOrNull ?? throw("no default target found")

// hidden guard for the self-injecting output: the first evaluation amends this
// module with a filled `properties` instance (reading `prop:` values), the
// second renders normally with the injected values visible everywhere via
// late binding. Same mechanism pkl:Command's runtime uses ("synthesize a
// module that amends the command module and sets options").
hidden injected: Boolean = false

// NOTE: a lot of this would be uneeded if bugs or unfinished implementation issues were resolved in PKL
output {
value =
if (formae.mode() == "properties")
if (!injected && properties != null && properties is Typed)
let (m = module)
((m) { injected = true; properties = m.fill(m.properties.getClass()) }).output.value
else if (formae.mode() == "properties")
new formae.FormaRender {
Properties = module.properties
Properties = formae.renderProperties(module.properties)
}
else
new formae.FormaRender {
Description = module.description
Properties = module.properties
Properties = formae.renderProperties(module.properties)
Stacks = forma.toList().filterIsInstance(formae.Stack).toListing()
Targets = forma.toList().filterIsInstance(formae.Target).toListing()
Policies = forma.toList().filterIsInstance(formae.Policy).map((p) -> p.render()).toListing()
Expand Down
48 changes: 47 additions & 1 deletion internal/schema/pkl/schema/formae.pkl
Original file line number Diff line number Diff line change
Expand Up @@ -432,12 +432,58 @@ open class Prop {
fixed Value: (String|Boolean|Int|Float)? = value
}

// ---- typed CLI properties (RFC-105) ----------------------------------------

/// Resolve a reflected type to its simple name, unwrapping nullables.
function typeName(t): String =
if (t is reflect.DeclaredType) t.referent.name
else if (t is reflect.NullableType) typeName(t.member)
else "unknown"

/// fillProps constructs a typed instance of `clazz`, reading `prop:<member>`
/// per member, coercing to the declared type, and falling back to the declared
/// default. Type constraints on the class members are validated by `toTyped`.
/// Prior art: pkl-pantry `pkl.experimental.structuredRead`.
function fillProps(clazz: Class): Typed =
new Mapping {
for (n, p in reflect.Class(clazz).properties) {
[n] = let (raw = read?("prop:\(n)"))
if (raw == null) p.defaultValue
else
let (tn = typeName(p.type))
if (tn == "Int") raw.toInt()
else if (tn == "Float") raw.toFloat()
else if (tn == "Boolean") raw.toBoolean()
else raw
}
}.toMap().toTyped(clazz)

/// renderProperties turns the module's `properties` value into the manifest the
/// CLI/agent consume. Typed instances (the extends shape) are reflected into
/// `{ name -> Prop }`; legacy Dynamic blocks pass through unchanged. The
/// manifest Value always comes from the INSTANCE (never a self-reading Prop),
/// so manifest and resources cannot disagree.
function renderProperties(opts: Any?): Any? =
if (opts == null) new Mapping {}
else if (!(opts is Typed)) opts
else
let (inst = opts.toDynamic().toMap())
new Mapping {
for (name, p in reflect.Class(opts.getClass()).properties) {
[name] = new Prop {
flag = name
default = p.defaultValue as (String|Boolean|Int|Float)?
value = inst[name] as (String|Boolean|Int|Float)?
}
}
}

/// FormaeRender is an ouput class notice the casing difference
class FormaRender {
local self = this

Description: Description?
Properties: Dynamic?
Properties: Any?
Stacks: Listing<Stack>
Targets: Listing<Target>
Policies: Listing<Dynamic>?
Expand Down
39 changes: 39 additions & 0 deletions internal/schema/pkl/testdata/forma/extends_props_test.pkl
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* © 2025 Platform Engineering Labs Inc.
*
* SPDX-License-Identifier: FSL-1.1-ALv2
*/

/// RFC-105 target shape: extends + pure-schema typed properties.
extends "@formae/forma.pkl"

import "@formae/formae.pkl"
import "@fakeaws/fakeaws.pkl"
import "@fakeaws/sqs/queue.pkl"

properties: Props

class Props {
/// The name to deploy
name: String = "pel"

/// Port to listen on
port: Int(this > 0) = 8080
}

forma {
new formae.Stack {
label = "\(properties.name)-stack"
description = "stack for \(properties.name)"
}

new formae.Target {
label = "default-target"
config = new fakeaws.Config { region = "us-east-1" }
}

new queue.Queue {
label = "q"
queueName = "\(properties.name)-\(properties.port)-queue"
}
}
Loading