feat: implement output.renderer.converters support#49
Conversation
pklr previously skipped pkl's `output` block entirely, which meant
converter functions (used to inject _type tags, coerce types, etc.)
were never applied. This caused consumers like hk to need post-processing
workarounds to normalize the JSON output.
Changes:
- Add `type_name: Option<String>` to ObjectSource to track which pkl
class an object was instantiated from
- Tag objects with their class name when evaluating `new ClassName { ... }`
- Parse the `output.renderer.converters` block from the AST and evaluate
converter lambdas
- Add `apply_converters()` to Evaluator, which recursively walks the
value tree and applies matching converter functions
- Call `apply_converters()` in `eval_to_json_with_options()` before
serializing to JSON
This enables pkl files like hk's Config.pkl to use converters for
type discrimination (_type tags), value coercion (string→array,
bool→string), and custom serialization without requiring downstream
workarounds.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request introduces comprehensive support for Pkl's output.renderer.converters feature within pklr. By integrating this functionality, pklr can now correctly interpret and apply transformations defined in Pkl modules' output blocks. This enhancement allows for native handling of complex serialization requirements, such as injecting type tags, coercing values between different types, and custom object serialization, directly within the Pkl evaluation process. The change significantly reduces the need for external post-processing steps, simplifying the workflow for consumers of pklr's JSON output. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request introduces a new feature for Pkl's output.renderer.converters, enabling custom transformations of evaluated objects based on their type. Key changes include adding a converters field to the Evaluator struct, storing type_name in ObjectSource during object creation and amendment, and implementing logic to extract and recursively apply these converter lambdas. New test cases validate this functionality. Feedback from the review suggests improving the readability of the converter extraction logic by flattening nested loops and optimizing type name matching to avoid unnecessary string allocations for better performance.
| let Entry::Property(renderer_prop) = entry else { | ||
| continue; | ||
| }; | ||
| if renderer_prop.name != "renderer" { | ||
| continue; | ||
| } | ||
| let Some(renderer_body) = &renderer_prop.body else { | ||
| continue; | ||
| }; | ||
| // Find `converters { ... }` inside renderer | ||
| for rentry in renderer_body { | ||
| let Entry::Property(converters_prop) = rentry else { | ||
| continue; | ||
| }; | ||
| if converters_prop.name != "converters" { | ||
| continue; | ||
| } | ||
| let Some(converters_body) = &converters_prop.body else { | ||
| continue; | ||
| }; | ||
| // Each converter is a DynProperty: [ClassName] = (x) -> expr | ||
| for centry in converters_body { | ||
| if let Entry::DynProperty(key_expr, val_expr) = centry { | ||
| // Extract the class name from the key expression | ||
| let class_name = match key_expr { | ||
| Expr::Ident(name) => name.clone(), | ||
| Expr::Field(_, name) => name.clone(), | ||
| Expr::String(s) => s.clone(), | ||
| _ => continue, | ||
| }; | ||
| // Evaluate the lambda value | ||
| if let Ok(lambda) = self.eval_expr(val_expr, scope, depth).await | ||
| && matches!(lambda, Value::Lambda(..)) | ||
| { | ||
| self.converters.push((class_name, lambda)); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
The nested loops for finding output.renderer.converters are quite deep, which can make the code harder to read and maintain. You could flatten this structure by using iterators and methods like find_map. This would make the logic for locating the converters block more linear and easier to follow.
// Find `renderer { ... }` inside output
let Some(renderer_body) = output_body.iter().find_map(|entry| {
if let Entry::Property(p) = entry && p.name == "renderer" {
p.body.as_ref()
} else {
None
}
}) else { return; };
// Find `converters { ... }` inside renderer
let Some(converters_body) = renderer_body.iter().find_map(|entry| {
if let Entry::Property(p) = entry && p.name == "converters" {
p.body.as_ref()
} else {
None
}
}) else { return; };
// Each converter is a DynProperty: [ClassName] = (x) -> expr
for centry in converters_body {
if let Entry::DynProperty(key_expr, val_expr) = centry {
// Extract the class name from the key expression
let class_name = match key_expr {
Expr::Ident(name) => name.clone(),
Expr::Field(_, name) => name.clone(),
Expr::String(s) => s.clone(),
_ => continue,
};
// Evaluate the lambda value
if let Ok(lambda) = self.eval_expr(val_expr, scope, depth).await {
if matches!(lambda, Value::Lambda(..)) {
self.converters.push((class_name, lambda));
}
}
}
}| if matches && let Value::Lambda(params, body, captured) = lambda { | ||
| let mut call_scope = Scope::default(); | ||
| for (k, v) in captured { |
There was a problem hiding this comment.
The current type name matching logic uses format! inside a loop, which can lead to unnecessary string allocations and impact performance, especially with many objects and converters. You can optimize this by using ends_with on string slices and checking the boundary character, avoiding allocations altogether.
let matches = if conv_name == tn {
true
} else if tn.len() > conv_name.len() && tn.ends_with(conv_name) {
// Check for `.<conv_name>` suffix
tn.as_bytes().get(tn.len() - conv_name.len() - 1) == Some(&b'.')
} else if conv_name.len() > tn.len() && conv_name.ends_with(tn) {
// Check for `.<tn>` suffix
conv_name.as_bytes().get(conv_name.len() - tn.len() - 1) == Some(&b'.')
} else {
false
};
Greptile SummaryThis PR implements Key changes:
Issues found:
Confidence Score: 3/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant Caller
participant lib as lib.rs (eval_to_json_with_options)
participant Evaluator
participant eval_module
participant extract_converters as extract_converters_from_ast
participant apply_converters as apply_converters_recursive
Caller->>lib: eval_to_json_with_options(path, options)
lib->>Evaluator: new()
lib->>Evaluator: eval_source(source, path)
Evaluator->>eval_module: eval_module(module, path, depth=0)
loop For each top-level property
alt prop.name == "output" && depth == 0
eval_module->>extract_converters: extract_converters_from_ast(prop, scope, depth)
extract_converters->>extract_converters: walk output→renderer→converters entries
extract_converters-->>Evaluator: push (class_name, lambda) to self.converters
eval_module->>eval_module: continue (skip output from result)
else Normal property
eval_module->>eval_module: eval_property → tag Value::Object with type_name
end
end
eval_module-->>lib: Value (with type_name tags)
lib->>Evaluator: apply_converters(value)
Evaluator->>apply_converters: apply_converters_recursive(value, converters)
loop Recursively walk value tree
alt Object with matching type_name
apply_converters->>Evaluator: eval_expr(lambda_body, call_scope, 0)
Evaluator-->>apply_converters: converted Value
apply_converters->>apply_converters: recurse on result
else Object without match / List
apply_converters->>apply_converters: recurse into children
end
end
apply_converters-->>lib: transformed Value
lib->>lib: value.to_json()
lib-->>Caller: serde_json::Value
Reviews (1): Last reviewed commit: "[autofix.ci] apply automated fixes" | Re-trigger Greptile |
| http_rewrites: Vec::new(), | ||
| converters: Vec::new(), | ||
| } |
There was a problem hiding this comment.
Stale converters across multiple
eval_source calls
self.converters is never cleared before a new evaluation begins. Since Evaluator exposes an import cache designed for reuse, a caller can reasonably invoke eval_source more than once on the same instance. Each call will append to self.converters rather than replace them, meaning converters extracted from a previous file bleed into the next.
// Example misuse that silently produces wrong output:
let mut ev = Evaluator::new();
let _ = ev.eval_source(file_a_src, file_a_path).await?; // populates converters from file A
let val = ev.eval_source(file_b_src, file_b_path).await?;
let val = ev.apply_converters(val).await?; // applies converters from BOTH A and BThe fix is to clear the list before the top-level evaluation begins. For example, in eval_source before calling eval_module, or at the start of eval_module when depth == 0:
pub async fn eval_source(&mut self, source: &str, path: &Path) -> Result<Value> {
self.converters.clear(); // reset per-file state
// ... rest of function| /// Looks for the structure: `output { renderer { converters { [Type] = (x) -> ... } } }`. | ||
| /// Converter keys are type identifiers (e.g., `[Regex]`), which we preserve as | ||
| /// strings for matching against `ObjectSource.type_name`. | ||
| #[async_recursion(?Send)] |
There was a problem hiding this comment.
Unnecessary
#[async_recursion] on non-recursive function
extract_converters_from_ast never calls itself, so the #[async_recursion(?Send)] attribute is not needed here. The macro is designed specifically to handle the infinite-type problem that arises when an async fn calls itself directly; applying it to a non-recursive function compiles fine but is misleading to readers of the code.
| #[async_recursion(?Send)] | |
| async fn extract_converters_from_ast( |
| let matches = conv_name == tn | ||
| || tn.ends_with(&format!(".{conv_name}")) | ||
| || conv_name.ends_with(&format!(".{tn}")); |
There was a problem hiding this comment.
Potential false-positive match for short type names
The third branch of the matching logic:
|| conv_name.ends_with(&format!(".{tn}"))is intended to handle cases like conv_name = "Config.Step" matching a type name tn = "Step". However, if a pkl module defines a class whose simple name (tn) happens to be a suffix of a different, unrelated converter name (after a .), that converter would also fire. For instance, a converter keyed on "Foo.Bar" would match any object whose type_name is "Bar", even if it comes from a completely unrelated class. This is only a concern in codebases with deeply-qualified converter names that share short suffixes with unrelated types, but it is worth documenting the assumption or adding a stricter check (e.g., requiring both sides to share a module prefix).
- Clear converters in eval_source to prevent stale state across calls - Flatten nested loops in extract_converters_from_ast using find_map - Remove unnecessary #[async_recursion] from non-recursive function - Replace format!-based type name matching with allocation-free byte-level dot boundary checks Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2c48a2f to
7ee68cf
Compare
## 🤖 New release
* `pklr`: 0.2.2 -> 0.3.0 (⚠ API breaking changes)
### ⚠ `pklr` breaking changes
```text
--- failure constructible_struct_adds_field: externally-constructible struct adds field ---
Description:
A pub struct constructible with a struct literal has a new pub field. Existing struct literals must be updated to include the new field.
ref: https://doc.rust-lang.org/reference/expressions/struct-expr.html
impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.46.0/src/lints/constructible_struct_adds_field.ron
Failed in:
field ObjectSource.type_name in /tmp/.tmpJNeWZq/pklr/src/value.rs:20
```
<details><summary><i><b>Changelog</b></i></summary><p>
<blockquote>
## [0.3.0](v0.2.2...v0.3.0) -
2026-03-24
### Added
- implement output.renderer.converters support
([#49](#49))
</blockquote>
</p></details>
---
This PR was generated with
[release-plz](https://github.com/release-plz/release-plz/).
## Summary Fixes the `missing field '_type'` error in hk's CI ([PR #769](jdx/hk#769)). PR #49 added `output.renderer.converters` support, but only extracted converters from the *current* module's body entries. When a file `amends` or `extends` a base module that defines converters (like hk's `hk.pkl` amending `Config.pkl`), those converters were never extracted. - Extract converters from the base module's AST during amends processing - Extract converters from the base module's AST during extends processing - Added `converter_inherited_from_amends_base` test with two files on disk ## Test plan - [x] New test: `converter_inherited_from_amends_base` — creates Base.pkl with converters + child.pkl that amends it, verifies converters are applied - [x] All 235 existing tests pass *This comment was generated by Claude Code.* --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Summary
pklr previously skipped pkl's
outputblock entirely, which meant converter functions were never applied. This caused consumers like hk to need post-processing workarounds (pklr_normalize_step_or_group) to normalize the JSON output.This PR implements
output.renderer.converterssupport:ObjectSource.type_nameoutput.renderer.convertersblock from the AST and evaluate converter lambdasEvaluator::apply_converters()which recursively walks the value tree and applies matching converter functionsapply_converters()ineval_to_json_with_options()before serializing to JSONThis enables pkl files like hk's
Config.pklto use converters for:_typetags for serde tagged enums)Without this, hk needed ~100 lines of Rust to replicate what pkl's converters do natively.
Test plan
converter_injects_type_tag— basic _type injectionconverter_coerces_values— string→array and bool→string coercionconverter_multiple_types— Group + Step converters with nestingconverter_no_converters_is_noop— no converters = unchanged outputconverter_output_not_in_result— output block excluded from JSONThis comment was generated by Claude Code.