Skip to content
Merged
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
144 changes: 115 additions & 29 deletions src/eval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,7 @@ use std::path::{Path, PathBuf};

use crate::error::{Error, Result};
use crate::lexer;
use crate::parser::{
self, Annotation, BinOp, Entry, Expr, Modifier, Module, Property, StringInterpPart, UnOp,
};
use crate::parser::{self, BinOp, Entry, Expr, Modifier, Module, Property, StringInterpPart, UnOp};
use crate::value::{ObjectSource, Value};

/// Evaluates pkl source files to [`Value`].
Expand All @@ -32,6 +30,10 @@ pub struct Evaluator {
/// Converters extracted from `output.renderer.converters`.
/// Each entry maps a class name to a converter lambda.
converters: Vec<(String, Value)>,
/// Set of (property_name, message) pairs already warned about.
/// Used to deduplicate `@Deprecated` warnings so a deprecated property
/// referenced inside a loop or template doesn't flood stderr.
warned_deprecated: std::collections::HashSet<(String, Option<String>)>,
}

impl Default for Evaluator {
Expand All @@ -45,6 +47,7 @@ impl Default for Evaluator {
package_dirs: HashMap::new(),
http_rewrites: Vec::new(),
converters: Vec::new(),
warned_deprecated: std::collections::HashSet::new(),
}
}
}
Expand Down Expand Up @@ -84,6 +87,28 @@ impl Evaluator {
.collect();
}

/// If `field` is marked `@Deprecated` in `source`, emit the warning at
/// most once per (field, message) pair. Called from field-access
/// expressions so the warning fires when a deprecated property is *used*,
/// not when its containing module loads. Per-call dedup mirrors pkl-jvm,
/// which avoids flooding stderr when a deprecated property is referenced
/// inside a loop or template.
fn warn_if_deprecated_access(&mut self, source: &Option<Arc<ObjectSource>>, field: &str) {
let Some(src) = source else { return };
let Some(message) = src.deprecated.get(field) else {
return;
};
let key = (field.to_string(), message.clone());
if !self.warned_deprecated.insert(key) {
return;
}
if let Some(msg) = message {
eprintln!("[pklr] WARNING: property '{field}' is deprecated: {msg}");
} else {
eprintln!("[pklr] WARNING: property '{field}' is deprecated");
}
}

/// Apply rewrite rules to a URL. Returns the rewritten URL or the original.
pub fn rewrite_url<'a>(&self, url: &'a str) -> std::borrow::Cow<'a, str> {
if self.http_rewrites.is_empty() {
Expand Down Expand Up @@ -674,7 +699,6 @@ impl Evaluator {
if has_modifier(mods, Modifier::Local) {
continue; // already collected
}
check_deprecated(&prop.annotations, &prop.name);
// Extract renderer converters from the `output` block AST,
// then skip it (it's not included in the output).
// Clear any base-inherited converters so child overrides take precedence.
Expand Down Expand Up @@ -748,7 +772,23 @@ impl Evaluator {
}
out.retain(|_, v| !matches!(v, Value::Lambda(..)));
}
Ok(Value::Object(Arc::new(out), None))
// If the module declares any `@Deprecated` properties, attach a
// minimal ObjectSource carrying just the deprecation map so field
// access can warn lazily. Modules without @Deprecated keep `None`
// source to avoid changing amend behavior in the common case.
let deprecated = collect_deprecated(&module.body);
let source = if deprecated.is_empty() {
None
} else {
Some(Arc::new(ObjectSource {
entries: Vec::new(),
scope: IndexMap::new(),
is_open: true,
type_name: None,
deprecated,
}))
};
Ok(Value::Object(Arc::new(out), source))
}

#[async_recursion(?Send)]
Expand Down Expand Up @@ -863,7 +903,6 @@ impl Evaluator {
if has_modifier(mods, Modifier::Local) {
continue;
}
check_deprecated(&prop.annotations, &prop.name);
// Skip the `default` property — it's a template, not an output entry
if prop.name == "default" && default_template.is_some() {
continue;
Expand Down Expand Up @@ -918,6 +957,7 @@ impl Evaluator {
scope: IndexMap::new(),
Comment thread
greptile-apps[bot] marked this conversation as resolved.
is_open: true,
type_name: Some(tn.clone()),
deprecated: merge_deprecated(&src.deprecated, body),
},
};
*result_src = Some(std::sync::Arc::new(new_src));
Expand Down Expand Up @@ -985,6 +1025,7 @@ impl Evaluator {
scope: child_scope.flatten(),
is_open: true, // default: allow new properties
type_name: None,
deprecated: collect_deprecated(entries),
};
Ok(Value::Object(Arc::new(map), Some(Arc::new(source))))
}
Expand Down Expand Up @@ -1379,11 +1420,13 @@ impl Evaluator {
let mut source_scope = scope.flatten();
source_scope.shift_remove("outer");
source_scope.shift_remove("this");
let deprecated = collect_deprecated(&src_entries);
let source = ObjectSource {
entries: src_entries,
scope: source_scope,
is_open: true,
type_name: None,
deprecated,
};
Ok(Value::Object(Arc::new(map), Some(Arc::new(source))))
}
Expand Down Expand Up @@ -1469,25 +1512,36 @@ impl Evaluator {
scope: IndexMap::new(),
is_open,
type_name: tn,
deprecated: merge_deprecated(&base_src.deprecated, entries),
}
};
*src_slot = Some(Arc::new(new_src));
}
Ok(result)
} else if let Some(Value::Object(base_map, _)) = base {
} else if let Some(Value::Object(base_map, base_src)) = base {
// Fallback: eager merge
let overlay = self.eval_entries(entries, scope, depth + 1).await?;
let mut merged: IndexMap<String, Value> = (*base_map).clone();
if let Value::Object(overlay_map, _) = overlay {
let mut deprecated = base_src
.as_ref()
.map(|s| s.deprecated.clone())
.unwrap_or_default();
if let Value::Object(overlay_map, overlay_src) = &overlay {
merged.extend(
overlay_map.iter().map(|(k, v)| (k.clone(), v.clone())),
);
if let Some(os) = overlay_src.as_ref() {
for (k, v) in &os.deprecated {
deprecated.insert(k.clone(), v.clone());
}
}
}
let src = ObjectSource {
entries: Vec::new(),
scope: IndexMap::new(),
is_open: true,
type_name: type_name.clone(),
deprecated,
};
Ok(Value::Object(Arc::new(merged), Some(Arc::new(src))))
} else {
Expand Down Expand Up @@ -1540,10 +1594,14 @@ impl Evaluator {
_ => {}
}
match &obj {
Value::Object(map, _) => map
.get(field)
.cloned()
.ok_or_else(|| Error::Eval(format!("field not found: {field}"))),
Value::Object(map, source) => {
let val = map
.get(field)
.cloned()
.ok_or_else(|| Error::Eval(format!("field not found: {field}")))?;
self.warn_if_deprecated_access(source, field);
Ok(val)
}
_ => Err(Error::Eval(format!(
"cannot access field '{field}' on {}",
value_type_name(&obj)
Expand All @@ -1554,7 +1612,13 @@ impl Evaluator {
let obj = self.eval_expr(obj_expr, scope, depth + 1).await?;
match &obj {
Value::Null => Ok(Value::Null),
Value::Object(map, _) => Ok(map.get(field).cloned().unwrap_or(Value::Null)),
Value::Object(map, source) => {
let val = map.get(field).cloned().unwrap_or(Value::Null);
if !matches!(val, Value::Null) {
self.warn_if_deprecated_access(source, field);
}
Ok(val)
}
_ => Err(Error::Eval(format!(
Comment on lines +1615 to 1622

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Null-value deprecated property silently skips warning

map.get(field).cloned().unwrap_or(Value::Null) returns Value::Null both when the field is absent from the map and when it is present with a null value. The !matches!(val, Value::Null) guard was meant to suppress warnings for absent fields, but it also suppresses the warning for a deprecated property that legitimately holds null. Any PKL property declared both @Deprecated and nullable (or simply set to null) will be silently accessed via ?. without the expected stderr warning.

Fix by checking presence separately from value:

Suggested change
Value::Object(map, source) => {
let val = map.get(field).cloned().unwrap_or(Value::Null);
if !matches!(val, Value::Null) {
self.warn_if_deprecated_access(source, field);
}
Ok(val)
}
_ => Err(Error::Eval(format!(
Value::Object(map, source) => {
if map.contains_key(field) {
self.warn_if_deprecated_access(source, field);
}
let val = map.get(field).cloned().unwrap_or(Value::Null);
Ok(val)
}

Fix in Claude Code

"cannot access field '{field}' on {}",
value_type_name(&obj)
Expand Down Expand Up @@ -2204,6 +2268,7 @@ impl Evaluator {
scope: IndexMap::new(),
is_open: true,
type_name: Some(tn.clone()),
deprecated: merge_deprecated(&src.deprecated, body),
},
};
*result_src = Some(std::sync::Arc::new(new_src));
Expand Down Expand Up @@ -2472,26 +2537,47 @@ fn resolve_package_uri(uri: &str) -> Result<PackageSource> {
Err(Error::Eval(format!("unsupported package URI: {uri}")))
}

fn check_deprecated(annotations: &[Annotation], prop_name: &str) {
for ann in annotations {
if ann.name == "Deprecated" {
// Look for a "message" property in the annotation body
let mut message = None;
for entry in &ann.body {
if let Entry::Property(p) = entry
&& p.name == "message"
&& let Some(Expr::String(s)) = &p.value
{
message = Some(s.clone());
/// Collect `@Deprecated` annotations from a list of entries into a map of
/// property name → optional message. Used to populate `ObjectSource.deprecated`
/// so field access can warn lazily, instead of warning eagerly when a module
/// or object body is evaluated.
fn collect_deprecated(entries: &[Entry]) -> IndexMap<String, Option<String>> {
let mut out: IndexMap<String, Option<String>> = IndexMap::new();
for entry in entries {
if let Entry::Property(prop) = entry {
for ann in &prop.annotations {
if ann.name != "Deprecated" {
continue;
}
}
if let Some(msg) = message {
eprintln!("[pklr] WARNING: property '{prop_name}' is deprecated: {msg}");
} else {
eprintln!("[pklr] WARNING: property '{prop_name}' is deprecated");
let mut message = None;
for e in &ann.body {
if let Entry::Property(p) = e
&& p.name == "message"
&& let Some(Expr::String(s)) = &p.value
{
message = Some(s.clone());
}
}
out.insert(prop.name.clone(), message);
}
}
}
out
}

/// Combine a base deprecation map with any `@Deprecated` annotations on a
/// list of overlay entries, with overlay winning on conflict. Used by the
/// amend/merge code paths so an amended object's `ObjectSource.deprecated`
/// reflects deprecations from both the base and the overlay.
fn merge_deprecated(
base: &IndexMap<String, Option<String>>,
overlay_entries: &[Entry],
) -> IndexMap<String, Option<String>> {
let mut out = base.clone();
for (k, v) in collect_deprecated(overlay_entries) {
out.insert(k, v);
}
out
}

/// Resolve a potentially dotted name (e.g. "Foo.Bar") in scope.
Expand Down
5 changes: 5 additions & 0 deletions src/value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ pub struct ObjectSource {
/// The pkl class name this object was instantiated from (e.g., "Step", "Group").
/// Used by `output.renderer.converters` to apply type-specific transforms.
pub type_name: Option<String>,
/// Map of property name → optional deprecation message for properties
/// annotated with `@Deprecated`. Consulted on field access so the
/// warning fires when a deprecated property is *used*, not when the
/// containing module is loaded.
pub deprecated: IndexMap<String, Option<String>>,
}

/// A pkl runtime value.
Expand Down
Loading