Skip to content

Commit 0ae50f8

Browse files
committed
deeper jinja integration
1 parent e1ae3b7 commit 0ae50f8

File tree

6 files changed

+213
-45
lines changed

6 files changed

+213
-45
lines changed

src/recipe/custom_yaml.rs

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,6 @@ impl Render<Node> for SequenceNode {
228228

229229
impl Render<Node> for SequenceNodeInternal {
230230
fn render(&self, jinja: &Jinja, name: &str) -> Result<Node, Vec<PartialParsingError>> {
231-
println!("Rendering: {:?}", self);
232231
match self {
233232
Self::Simple(n) => n.render(jinja, name),
234233
Self::Conditional(if_sel) => {
@@ -391,6 +390,15 @@ pub struct ScalarNode {
391390
may_coerce: bool,
392391
}
393392

393+
/// Convert a string to a boolean
394+
pub fn string_to_bool(value: &str) -> Option<bool> {
395+
match value {
396+
"true" | "True" | "TRUE" => Some(true),
397+
"false" | "False" | "FALSE" => Some(false),
398+
_ => None,
399+
}
400+
}
401+
394402
impl ScalarNode {
395403
/// Create a new scalar node with a span
396404
pub fn new(span: marked_yaml::Span, value: String, may_coerce: bool) -> Self {
@@ -427,11 +435,8 @@ impl ScalarNode {
427435
if !self.may_coerce {
428436
return None;
429437
}
430-
match self.value.as_str() {
431-
"true" | "True" | "TRUE" => Some(true),
432-
"false" | "False" | "FALSE" => Some(false),
433-
_ => None,
434-
}
438+
439+
string_to_bool(&self.value)
435440
}
436441

437442
/// Convert the scalar node to an integer and follow coercion rules

src/recipe/custom_yaml/rendered.rs

Lines changed: 90 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,59 @@ impl RenderedNode {
133133
_ => None,
134134
}
135135
}
136+
137+
pub fn from_jinja_value(
138+
source: String,
139+
value: minijinja::Value,
140+
span: Span,
141+
coercible: bool,
142+
) -> Result<Self, PartialParsingError> {
143+
if !coercible {
144+
return Ok(RenderedNode::Scalar(RenderedScalarNode::new(
145+
span,
146+
value.to_string(),
147+
value.to_string(),
148+
false,
149+
)));
150+
}
151+
152+
match value.kind() {
153+
minijinja::value::ValueKind::Map => {
154+
todo!("Maps not supported yet");
155+
}
156+
minijinja::value::ValueKind::Seq => {
157+
let mut rendered: Vec<RenderedNode> = Vec::new();
158+
for elem in value.try_iter().unwrap() {
159+
let node =
160+
RenderedNode::from_jinja_value(source.clone(), elem, span.clone(), true)?;
161+
rendered.push(node);
162+
}
163+
Ok(RenderedNode::Sequence(RenderedSequenceNode::from(rendered)))
164+
}
165+
minijinja::value::ValueKind::String
166+
| minijinja::value::ValueKind::Bool
167+
| minijinja::value::ValueKind::Number => {
168+
let value = value.to_string();
169+
if value.is_empty() {
170+
return Ok(RenderedNode::Null(RenderedScalarNode::new(
171+
span,
172+
source,
173+
String::new(),
174+
false,
175+
)));
176+
}
177+
Ok(RenderedNode::Scalar(RenderedScalarNode::new(
178+
span, source, value, true,
179+
)))
180+
}
181+
minijinja::value::ValueKind::None | minijinja::value::ValueKind::Undefined => Ok(
182+
RenderedNode::Null(RenderedScalarNode::new(span, source, String::new(), false)),
183+
),
184+
_ => {
185+
todo!("Other types not supported yet");
186+
}
187+
}
188+
}
136189
}
137190

138191
impl HasSpan for RenderedNode {
@@ -265,6 +318,14 @@ impl RenderedScalarNode {
265318
)
266319
}
267320

321+
pub fn new_string(value: String) -> Self {
322+
Self::new(marked_yaml::Span::new_blank(), value.clone(), value, false)
323+
}
324+
325+
pub fn new_coerceable(value: String) -> Self {
326+
Self::new(marked_yaml::Span::new_blank(), value.clone(), value, true)
327+
}
328+
268329
/// Treat the scalar node as a string
269330
///
270331
/// Since scalars are always stringish, this is always safe.
@@ -662,27 +723,44 @@ impl Render<RenderedNode> for Node {
662723

663724
impl Render<RenderedNode> for ScalarNode {
664725
fn render(&self, jinja: &Jinja, _name: &str) -> Result<RenderedNode, Vec<PartialParsingError>> {
665-
let (rendered, may_coerce) = jinja.render_str(self.as_str()).map_err(|err| {
726+
// println!("Rendering scalar node: {}", self.as_str());
727+
// let (rendered, may_coerce) = jinja.render_str(self.as_str()).map_err(|err| {
728+
// vec![_partialerror!(
729+
// *self.span(),
730+
// ErrorKind::JinjaRendering(err),
731+
// label = jinja_error_to_label(&err),
732+
// )]
733+
// })?;
734+
735+
let (value, can_coerce) = jinja.render_to_value(self).map_err(|err| {
666736
vec![_partialerror!(
667737
*self.span(),
668738
ErrorKind::JinjaRendering(err),
669739
label = jinja_error_to_label(&err),
670740
)]
671741
})?;
672742

673-
// unsure whether this should be allowed to coerce // check if it's quoted?
674-
let rendered = RenderedScalarNode::new(
743+
return Ok(RenderedNode::from_jinja_value(
744+
self.to_string(),
745+
value,
675746
*self.span(),
676-
self.as_str().to_string(),
677-
rendered,
678-
self.may_coerce && may_coerce,
679-
);
747+
self.may_coerce && can_coerce,
748+
)
749+
.unwrap());
680750

681-
if rendered.is_empty() {
682-
Ok(RenderedNode::Null(rendered))
683-
} else {
684-
Ok(RenderedNode::Scalar(rendered))
685-
}
751+
// unsure whether this should be allowed to coerce // check if it's quoted?
752+
// let rendered = RenderedScalarNode::new(
753+
// *self.span(),
754+
// self.as_str().to_string(),
755+
// rendered,
756+
// self.may_coerce && may_coerce,
757+
// );
758+
759+
// if rendered.is_empty() {
760+
// Ok(RenderedNode::Null(rendered))
761+
// } else {
762+
// Ok(RenderedNode::Scalar(rendered))
763+
// }
686764
}
687765
}
688766

src/recipe/jinja.rs

Lines changed: 77 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use crate::render::pin::PinArgs;
1717
pub use crate::render::pin::{Pin, PinExpression};
1818
pub use crate::selectors::SelectorConfig;
1919

20+
use super::custom_yaml::ScalarNode;
2021
use super::parser::{Dependency, PinCompatible, PinSubpackage};
2122
use super::variable::Variable;
2223

@@ -54,6 +55,32 @@ pub struct Jinja {
5455
context: BTreeMap<String, Value>,
5556
}
5657

58+
/// If we have a template that is _only_ an expression, we want to strip the
59+
/// `${{` and `}}` from it so that we can evaluate it as an expression instead of
60+
/// rendering it as a string.
61+
///
62+
/// The function checks for:
63+
/// - Whitespace before/after the expression
64+
/// - Single expression with no nested expressions
65+
/// - Proper opening `${{` and closing `}}`
66+
fn strip_expression(template: &str) -> Option<&str> {
67+
let trimmed = template.trim();
68+
if !trimmed.starts_with("${{") || !trimmed.ends_with("}}") {
69+
return None;
70+
}
71+
72+
// Extract content between ${{ and }}
73+
let content = &trimmed[3..trimmed.len() - 2];
74+
let content = content.trim();
75+
76+
// Check for nested expressions
77+
if content.contains("${{") || content.contains("}}") {
78+
return None;
79+
}
80+
81+
Some(content)
82+
}
83+
5784
impl Jinja {
5885
/// Create a new Jinja instance with the given selector configuration.
5986
pub fn new(config: SelectorConfig) -> Self {
@@ -94,26 +121,42 @@ impl Jinja {
94121
&mut self.context
95122
}
96123

97-
/// Render a template with the current context.
98-
pub fn render_str(&self, template: &str) -> Result<(String, bool), minijinja::Error> {
99-
if template.starts_with("${{") && template.ends_with("}}") {
100-
// render as expression so that we know the type of the result, and can stringify accordingly
101-
// If we find something like "${{ foo }}" then we want to evaluate it type-safely and make sure that the MiniJinja type is kept
102-
let tmplt = &template[3..template.len() - 2];
103-
let expr = self.env.compile_expression(tmplt)?;
104-
let evaled = expr.eval(self.context())?;
105-
if let Some(s) = evaled.to_str() {
106-
// Make sure that the string stays a string by returning can_coerce: false
107-
return Ok((s.to_string(), false));
108-
} else {
109-
return Ok((evaled.to_string(), true));
110-
}
124+
/// Render the given template to a Jinja value.
125+
pub fn render_to_value(
126+
&self,
127+
template: &ScalarNode,
128+
) -> Result<(Value, bool), minijinja::Error> {
129+
if let Some(simple_expr) = strip_expression(template) {
130+
// render as expression so that we know the type of the result
131+
let expr = self.env.compile_expression(simple_expr)?;
132+
return Ok((expr.eval(self.context())?, true));
111133
}
112134

135+
// Otherwise just render it as string
113136
let rendered = self.env.render_str(template, &self.context)?;
114-
Ok((rendered, !template.contains("${{")))
137+
Ok((Value::from(rendered), !template.contains("${{")))
115138
}
116139

140+
/// Render a template with the current context.
141+
// pub fn render_str(&self, template: &str) -> Result<(String, bool), minijinja::Error> {
142+
// if template.starts_with("${{") && template.ends_with("}}") {
143+
// // render as expression so that we know the type of the result, and can stringify accordingly
144+
// // If we find something like "${{ foo }}" then we want to evaluate it type-safely and make sure that the MiniJinja type is kept
145+
// let tmplt = &template[3..template.len() - 2];
146+
// let expr = self.env.compile_expression(tmplt)?;
147+
// let evaled = expr.eval(self.context())?;
148+
// if let Some(s) = evaled.to_str() {
149+
// // Make sure that the string stays a string by returning can_coerce: false
150+
// return Ok((s.to_string(), false));
151+
// } else {
152+
// return Ok((evaled.to_string(), true));
153+
// }
154+
// }
155+
156+
// let rendered = self.env.render_str(template, &self.context)?;
157+
// Ok((rendered, !template.contains("${{")))
158+
// }
159+
117160
/// Render, compile and evaluate a expr string with the current context.
118161
pub fn eval(&self, str: &str) -> Result<Value, minijinja::Error> {
119162
let expr = self.env.compile_expression(str)?;
@@ -1381,4 +1424,23 @@ mod tests {
13811424
default_compiler(platform, "cuda").unwrap().to_string()
13821425
);
13831426
}
1427+
1428+
#[test]
1429+
fn test_strip_expression() {
1430+
// Valid cases
1431+
assert_eq!(strip_expression("${{ expr }}"), Some("expr"));
1432+
assert_eq!(strip_expression(" ${{ expr }} "), Some("expr"));
1433+
assert_eq!(strip_expression("${{expr}}"), Some("expr"));
1434+
assert_eq!(
1435+
strip_expression("${{ expr with spaces }}"),
1436+
Some("expr with spaces")
1437+
);
1438+
1439+
// Invalid cases
1440+
assert_eq!(strip_expression("not an expression"), None);
1441+
assert_eq!(strip_expression("${{ nested ${{ expr }} }}"), None);
1442+
assert_eq!(strip_expression("${{ unmatched"), None);
1443+
assert_eq!(strip_expression("unmatched }}"), None);
1444+
assert_eq!(strip_expression("text ${{ expr }} text"), None);
1445+
}
13841446
}

src/recipe/parser.rs

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ pub use self::{
5555

5656
use crate::recipe::{custom_yaml::Node, variable::Variable};
5757

58+
use super::custom_yaml::string_to_bool;
59+
5860
/// A recipe that has been parsed and validated.
5961
#[derive(Debug, Clone, Serialize, Deserialize)]
6062
pub struct Recipe {
@@ -149,19 +151,35 @@ impl Recipe {
149151
help = "`context` values must always be scalars (booleans, integers or strings) or uniform lists of scalars"
150152
)]
151153
})?;
152-
let rendered: Option<ScalarNode> = val.render(jinja, &format!("context.{}", k.as_str()))?;
153-
if let Some(rendered) = rendered {
154-
let variable = if let Some(value) = rendered.as_bool() {
155-
Variable::from(value)
156-
} else if let Some(value) = rendered.as_integer() {
157-
Variable::from(value)
154+
// val.render(jinja, &format!("context.{}", k.as_str()))?;
155+
let (value, can_coerce) = jinja.render_to_value(&val).unwrap();
156+
157+
// See if we have to coerce a string-type to a boolean or integer
158+
if can_coerce && value.as_str().is_some() {
159+
// let's see if the value should be an integer or a boolean
160+
let stringified = value.to_string();
161+
if let Some(boolean) = string_to_bool(&stringified) {
162+
return Ok(Some(Variable::from(boolean)));
163+
} else if let Some(integer) = stringified.parse::<i64>().ok() {
164+
return Ok(Some(Variable::from(integer)));
158165
} else {
159-
Variable::from_string(&rendered)
160-
};
161-
Ok(Some(variable))
162-
} else {
163-
Ok(None)
166+
return Ok(Some(Variable::from_string(&stringified)));
167+
}
164168
}
169+
// TODO handle null
170+
return Ok(Some(Variable::from_value(value)));
171+
// if let Some(rendered) = rendered {
172+
// let variable = if let Some(value) = rendered.as_bool() {
173+
// Variable::from(value)
174+
// } else if let Some(value) = rendered.as_integer() {
175+
// Variable::from(value)
176+
// } else {
177+
// Variable::from_string(&rendered)
178+
// };
179+
// Ok(Some(variable))
180+
// } else {
181+
// Ok(None)
182+
// }
165183
}
166184

167185
/// Create recipes from a YAML [`Node`] structure.

src/recipe/snapshots/rattler_build__recipe__parser__tests__jinja_error.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ expression: err
66

77
Error:
88
× failed to render Jinja expression: unknown function: zcompiler is unknown
9-
│ (in <string>:1)
9+
│ (in <expression>:1)
1010
╭─[7:7]
1111
6 │ host:
1212
7 │ - ${{ zcompiler('c') }}

src/recipe/variable.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ impl Variable {
6161
pub fn from_string(value: &str) -> Self {
6262
Variable(Value::from_safe_string(value.to_string()))
6363
}
64+
65+
/// Create a variable from a `minijinja::Value`
66+
pub fn from_value(value: Value) -> Self {
67+
Variable(value)
68+
}
6469
}
6570

6671
impl Display for Variable {

0 commit comments

Comments
 (0)