Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion rust/python-bindings/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ fn parse_yaml_template(
) -> PyResult<Arc<YamlStreamNode>> {
yaml_parse_cache()
.get_or_try_insert_with(&template_cache_key(template, profile.as_str()), || {
tstring_yaml::parse_template_with_profile(template.input(), profile)
tstring_yaml::parse_validated_template_with_profile(template.input(), profile)
})
.map_err(backend_error_to_py)
}
Expand Down
97 changes: 95 additions & 2 deletions rust/yaml-tstring-rs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2048,11 +2048,36 @@ pub fn parse_template(template: &TemplateInput) -> BackendResult<YamlStreamNode>
parse_template_with_profile(template, YamlProfile::default())
}

pub fn parse_validated_template_with_profile(
template: &TemplateInput,
profile: YamlProfile,
) -> BackendResult<YamlStreamNode> {
let stream = parse_template_with_profile(template, profile)?;
validate_template_stream(&stream)?;
Ok(stream)
}

pub fn parse_validated_template(template: &TemplateInput) -> BackendResult<YamlStreamNode> {
parse_validated_template_with_profile(template, YamlProfile::default())
}

pub fn validate_template_with_profile(
template: &TemplateInput,
profile: YamlProfile,
) -> BackendResult<()> {
let stream = parse_template_with_profile(template, profile)?;
validate_template_stream(&stream)
}

pub fn validate_template(template: &TemplateInput) -> BackendResult<()> {
validate_template_with_profile(template, YamlProfile::default())
}

pub fn check_template_with_profile(
template: &TemplateInput,
profile: YamlProfile,
) -> BackendResult<()> {
parse_template_with_profile(template, profile).map(|_| ())
validate_template_with_profile(template, profile)
}

pub fn check_template(template: &TemplateInput) -> BackendResult<()> {
Expand All @@ -2063,14 +2088,82 @@ pub fn format_template_with_profile(
template: &TemplateInput,
profile: YamlProfile,
) -> BackendResult<String> {
let stream = parse_template_with_profile(template, profile)?;
let stream = parse_validated_template_with_profile(template, profile)?;
format_yaml_stream(template, &stream)
}

pub fn format_template(template: &TemplateInput) -> BackendResult<String> {
format_template_with_profile(template, YamlProfile::default())
}

fn validate_template_stream(stream: &YamlStreamNode) -> BackendResult<()> {
for document in &stream.documents {
validate_value_node(&document.value)?;
}
Ok(())
}

fn validate_value_node(node: &YamlValueNode) -> BackendResult<()> {
match node {
YamlValueNode::Scalar(YamlScalarNode::Plain(node)) => validate_plain_scalar_node(node),
YamlValueNode::Mapping(node) => {
for entry in &node.entries {
validate_key_node(&entry.key)?;
validate_value_node(&entry.value)?;
}
Ok(())
}
YamlValueNode::Sequence(node) => {
for item in &node.items {
validate_value_node(item)?;
}
Ok(())
}
YamlValueNode::Decorated(node) => validate_value_node(&node.value),
YamlValueNode::Interpolation(_)
| YamlValueNode::Scalar(
YamlScalarNode::DoubleQuoted(_)
| YamlScalarNode::SingleQuoted(_)
| YamlScalarNode::Block(_)
| YamlScalarNode::Alias(_),
) => Ok(()),
}
}

fn validate_key_node(node: &YamlKeyNode) -> BackendResult<()> {
match &node.value {
YamlKeyValue::Scalar(YamlScalarNode::Plain(node)) => validate_plain_scalar_node(node),
YamlKeyValue::Complex(node) => validate_value_node(node),
YamlKeyValue::Interpolation(_)
| YamlKeyValue::Scalar(
YamlScalarNode::DoubleQuoted(_)
| YamlScalarNode::SingleQuoted(_)
| YamlScalarNode::Block(_)
| YamlScalarNode::Alias(_),
) => Ok(()),
}
}

fn validate_plain_scalar_node(node: &YamlPlainScalarNode) -> BackendResult<()> {
let has_interpolation = node
.chunks
.iter()
.any(|chunk| matches!(chunk, YamlChunk::Interpolation(_)));
let has_whitespace_text = node.chunks.iter().any(|chunk| {
matches!(chunk, YamlChunk::Text(text) if text.value.chars().any(char::is_whitespace))
});

if has_interpolation && has_whitespace_text {
return Err(BackendError::parse_at(
"yaml.parse",
"Quote YAML plain scalars that mix whitespace and interpolations.",
Some(node.span.clone()),
));
}

Ok(())
}

pub fn normalize_documents_with_profile(
documents: &[YamlOwned],
_profile: YamlProfile,
Expand Down
44 changes: 43 additions & 1 deletion rust/yaml-tstring-rs/tests/parser.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use tstring_syntax::{TemplateInput, TemplateInterpolation, TemplateSegment};
use tstring_yaml::{YamlValueNode, check_template, format_template, parse_template};
use tstring_yaml::{
YamlValueNode, check_template, format_template, parse_template, validate_template,
};

fn interpolation(index: usize, expression: &str) -> TemplateSegment {
TemplateSegment::Interpolation(TemplateInterpolation {
Expand Down Expand Up @@ -62,6 +64,46 @@ fn checks_valid_yaml_templates() {
check_template(&template).expect("expected check success");
}

#[test]
fn rejects_plain_scalars_that_mix_whitespace_and_interpolation() {
let template = TemplateInput::from_segments(vec![
TemplateSegment::StaticText("replicas: fdsa fff fds".to_owned()),
TemplateSegment::Interpolation(TemplateInterpolation {
expression: "replicas".to_owned(),
conversion: None,
format_spec: String::new(),
interpolation_index: 0,
raw_source: Some("{replicas}".to_owned()),
}),
TemplateSegment::StaticText("\n".to_owned()),
]);

let error = check_template(&template).expect_err("expected YAML validation failure");
assert_eq!(error.diagnostics[0].code, "yaml.parse");
assert!(
error
.message
.contains("Quote YAML plain scalars that mix whitespace and interpolations")
);
}

#[test]
fn validates_token_like_plain_scalars_with_interpolation() {
let template = TemplateInput::from_segments(vec![
TemplateSegment::StaticText("plain: item-".to_owned()),
TemplateSegment::Interpolation(TemplateInterpolation {
expression: "user".to_owned(),
conversion: None,
format_spec: String::new(),
interpolation_index: 0,
raw_source: Some("{user}".to_owned()),
}),
TemplateSegment::StaticText("\n".to_owned()),
]);

validate_template(&template).expect("expected YAML validation success");
}

#[test]
fn formats_yaml_templates_with_raw_interpolations() {
let template = TemplateInput::from_segments(vec![
Expand Down
6 changes: 6 additions & 0 deletions yaml-tstring/tests/test_yaml_tstring.py
Original file line number Diff line number Diff line change
Expand Up @@ -1259,6 +1259,12 @@ def test_yaml_errors_cover_parse_render_and_metadata_paths() -> None:
with pytest.raises(TemplateParseError, match="Expected ':' in YAML template"):
render_text(Template("value: {a: 1, , b: 2}\n"))

with pytest.raises(
TemplateParseError,
match="Quote YAML plain scalars that mix whitespace and interpolations",
):
render_text(t"value: fdsa fff fds{1}")

with pytest.raises(UnrepresentableValueError, match="fragment"):
render_text(t'label: "hi-{bad}"')

Expand Down
Loading