use panache_parser::parser::yaml::{
ShadowYamlOptions, ShadowYamlOutcome, YamlInputKind, parse_basic_mapping_tree, parse_shadow,
};
use panache_parser::syntax::SyntaxNode as ParserSyntaxNode;
use serde_json::json;
use std::fs;
use std::path::{Path, PathBuf};
const FIXTURE_DIR: &str = "tests/fixtures/yaml-test-suite";
const ALLOWLIST_PATH: &str = "tests/yaml/allowlist.txt";
const BLOCKED_PATH: &str = "tests/yaml/blocked.txt";
fn read_lines(path: &Path) -> Vec<String> {
let content = fs::read_to_string(path)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display()));
content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(ToOwned::to_owned)
.collect()
}
fn fixture_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join(FIXTURE_DIR)
}
fn fixture_case_path(case_id: &str) -> PathBuf {
fixture_root().join(case_id)
}
fn allowlisted_case_paths() -> Vec<(String, PathBuf)> {
let allowlist = Path::new(env!("CARGO_MANIFEST_DIR")).join(ALLOWLIST_PATH);
assert!(
allowlist.exists(),
"missing allowlist file: {}",
allowlist.display()
);
let case_ids = read_lines(&allowlist);
assert!(
!case_ids.is_empty(),
"allowlist must include at least one case"
);
case_ids
.into_iter()
.map(|case_id| {
let case_path = fixture_case_path(&case_id);
assert!(
case_path.exists(),
"fixture case directory missing for {} ({})",
case_id,
case_path.display()
);
(case_id, case_path)
})
.collect()
}
fn blocked_case_paths() -> Vec<(String, PathBuf)> {
let blocked = Path::new(env!("CARGO_MANIFEST_DIR")).join(BLOCKED_PATH);
assert!(
blocked.exists(),
"missing blocked file: {}",
blocked.display()
);
read_lines(&blocked)
.into_iter()
.map(|line| {
let case_id = line
.split_once(':')
.map(|(id, _)| id.trim())
.unwrap_or(line.as_str())
.to_owned();
let case_path = fixture_case_path(&case_id);
assert!(
case_path.exists(),
"fixture case directory missing for {} ({})",
case_id,
case_path.display()
);
(case_id, case_path)
})
.collect()
}
fn fixture_case_events(case_path: &Path) -> Vec<String> {
let event_path = case_path.join("test.event");
let event_text = fs::read_to_string(&event_path)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", event_path.display()));
event_text
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(ToOwned::to_owned)
.collect()
}
fn cst_yaml_projected_events(input: &str) -> Vec<String> {
fn quoted_val_event(text: &str) -> String {
if text.starts_with('\'') {
let trimmed = text.trim_end_matches('\'');
format!("=VAL {}", trimmed.replace('\\', "\\\\"))
} else {
format!("=VAL {}", text.trim_end_matches('"'))
}
}
fn long_tag(tag: &str) -> Option<&'static str> {
match tag {
"!!str" => Some("<tag:yaml.org,2002:str>"),
"!!int" => Some("<tag:yaml.org,2002:int>"),
"!!bool" => Some("<tag:yaml.org,2002:bool>"),
_ => None,
}
}
let Some(tree) = parse_basic_mapping_tree(input) else {
return Vec::new();
};
let mut values = Vec::new();
for entry in tree
.descendants()
.filter(|n| n.kind() == panache_parser::syntax::SyntaxKind::YAML_BLOCK_MAP_ENTRY)
{
let key_node = entry
.children()
.find(|n| n.kind() == panache_parser::syntax::SyntaxKind::YAML_BLOCK_MAP_KEY)
.expect("key node");
let value_node = entry
.children()
.find(|n| n.kind() == panache_parser::syntax::SyntaxKind::YAML_BLOCK_MAP_VALUE)
.expect("value node");
let key_tag = key_node
.children_with_tokens()
.filter_map(|el| el.into_token())
.find(|tok| tok.kind() == panache_parser::syntax::SyntaxKind::YAML_TAG)
.map(|tok| tok.text().to_string());
let key_text = key_node
.children_with_tokens()
.filter_map(|el| el.into_token())
.find(|tok| tok.kind() == panache_parser::syntax::SyntaxKind::YAML_KEY)
.map(|tok| tok.text().to_string())
.expect("key token");
let value_tag = value_node
.children_with_tokens()
.filter_map(|el| el.into_token())
.find(|tok| tok.kind() == panache_parser::syntax::SyntaxKind::YAML_TAG)
.map(|tok| tok.text().to_string());
let value_text = value_node
.children_with_tokens()
.filter_map(|el| el.into_token())
.find(|tok| tok.kind() == panache_parser::syntax::SyntaxKind::YAML_SCALAR)
.map(|tok| tok.text().to_string())
.expect("value token");
let key_event = if let Some(tag) = key_tag {
if let Some(long) = long_tag(&tag) {
format!("=VAL {long} :{key_text}")
} else {
format!("=VAL :{key_text}")
}
} else if let Some(rest) = key_text.strip_prefix('&') {
if let Some((anchor, value)) = rest.split_once(' ') {
format!("=VAL &{} :{}", anchor, value)
} else {
format!("=VAL &{} :", rest)
}
} else if key_text.starts_with('"') || key_text.starts_with('\'') {
quoted_val_event(&key_text)
} else if key_text.starts_with('*') {
format!("=ALI {}", key_text.trim_end())
} else {
format!("=VAL :{key_text}")
};
values.push(key_event);
let value_event = if let Some(tag) = value_tag {
if let Some(long) = long_tag(&tag) {
format!("=VAL {long} :{value_text}")
} else {
format!("=VAL :{value_text}")
}
} else if value_text.starts_with('"') || value_text.starts_with('\'') {
quoted_val_event(&value_text)
} else if let Some(rest) = value_text.strip_prefix("!local &") {
let (anchor, value) = rest.split_once(' ').expect("local tag anchor/value split");
format!("=VAL &{} <!local> :{}", anchor, value)
} else if let Some(rest) = value_text.strip_prefix('&') {
if let Some((anchor, value)) = rest.split_once(' ') {
format!("=VAL &{} :{}", anchor, value)
} else {
format!("=VAL &{} :", rest)
}
} else if value_text.starts_with('*') {
format!("=ALI {value_text}")
} else {
format!("=VAL :{value_text}")
};
values.push(value_event);
}
let mut events = Vec::with_capacity(values.len() + 6);
events.push("+STR".to_string());
events.push("+DOC".to_string());
events.push("+MAP".to_string());
events.append(&mut values);
events.push("-MAP".to_string());
events.push("-DOC".to_string());
events.push("-STR".to_string());
events
}
fn cst_to_json(node: &ParserSyntaxNode) -> serde_json::Value {
let children: Vec<serde_json::Value> = node
.children_with_tokens()
.map(|element| match element {
rowan::NodeOrToken::Node(child_node) => cst_to_json(&child_node),
rowan::NodeOrToken::Token(token) => json!({
"kind": format!("{:?}", token.kind()),
"range": {
"start": u32::from(token.text_range().start()),
"end": u32::from(token.text_range().end()),
},
"text": token.text().to_string(),
}),
})
.collect();
json!({
"kind": format!("{:?}", node.kind()),
"range": {
"start": u32::from(node.text_range().start()),
"end": u32::from(node.text_range().end()),
},
"children": children,
})
}
fn render_shadow_report(
label: &str,
report: &panache_parser::parser::yaml::ShadowYamlReport,
) -> String {
format!(
"{label}\noutcome={:?}\nreason={}\nkind={:?}\nbytes={}\nlines={}\nnormalized={:?}\n",
report.outcome,
report.shadow_reason,
report.input_kind,
report.input_len_bytes,
report.line_count,
report.normalized_input
)
}
#[test]
fn yaml_allowlist_cases_snapshot() {
let fixture_root = fixture_root();
assert!(
fixture_root.exists(),
"yaml-test-suite fixtures missing; run `task update-yaml-fixtures`"
);
let blocked = Path::new(env!("CARGO_MANIFEST_DIR")).join(BLOCKED_PATH);
assert!(
blocked.exists(),
"missing blocked file: {}",
blocked.display()
);
for (case_id, case_path) in allowlisted_case_paths() {
let in_yaml = case_path.join("in.yaml");
let test_event = case_path.join("test.event");
let error_file = case_path.join("error");
assert!(
test_event.exists(),
"allowlisted case {} must include test.event ({})",
case_id,
test_event.display()
);
assert!(
!error_file.exists(),
"allowlisted case {} must not include error fixture ({})",
case_id,
error_file.display()
);
let input = fs::read_to_string(&in_yaml).unwrap_or_else(|e| {
panic!(
"failed to read case {} ({}): {e}",
case_id,
in_yaml.display()
)
});
let parsed = parse_basic_mapping_tree(&input).is_some();
let snapshot =
format!("case_id: {case_id}\ninput: {input:?}\nparsed_mapping_tree: {parsed}\n");
insta::assert_snapshot!(format!("yaml_suite_{}", case_id), snapshot);
}
}
#[test]
fn yaml_allowlist_cases_cst_snapshot() {
let fixture_root = fixture_root();
assert!(
fixture_root.exists(),
"yaml-test-suite fixtures missing; run `task update-yaml-fixtures`"
);
for (case_id, case_path) in allowlisted_case_paths() {
let in_yaml = case_path.join("in.yaml");
let input = fs::read_to_string(&in_yaml).unwrap_or_else(|e| {
panic!(
"failed to read case {} ({}): {e}",
case_id,
in_yaml.display()
)
});
let tree = parse_basic_mapping_tree(&input);
let snapshot_json = json!({
"case_id": case_id,
"input": input,
"cst": tree.as_ref().map(cst_to_json),
});
insta::assert_json_snapshot!(format!("yaml_cst_suite_{}", case_id), snapshot_json);
}
}
#[test]
fn yaml_allowlist_losslessness_raw_input() {
for (case_id, case_path) in allowlisted_case_paths() {
let input_path = case_path.join("in.yaml");
let input = fs::read_to_string(&input_path)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", input_path.display()));
let tree = parse_basic_mapping_tree(&input)
.unwrap_or_else(|| panic!("failed to parse raw input for {}", case_id));
let tree_text = tree.text().to_string();
assert_eq!(
input, tree_text,
"yaml raw losslessness mismatch for {}",
case_id
);
}
}
#[test]
fn yaml_allowlist_projected_event_parity() {
for (case_id, case_path) in allowlisted_case_paths() {
let input_path = case_path.join("in.yaml");
let input = fs::read_to_string(&input_path)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", input_path.display()));
let expected_events = fixture_case_events(&case_path);
let actual_events = cst_yaml_projected_events(&input);
assert_eq!(
actual_events, expected_events,
"projected event stream mismatch for {}",
case_id
);
}
}
#[test]
fn yaml_shadow_defaults_to_noop_and_does_not_replace_pipeline() {
let report = parse_shadow("title: Shadow", ShadowYamlOptions::default());
assert_eq!(report.outcome, ShadowYamlOutcome::SkippedDisabled);
assert_eq!(report.shadow_reason, "shadow-disabled");
assert!(report.normalized_input.is_none());
let parsed = parse_basic_mapping_tree("title: Shadow");
assert!(parsed.is_some());
}
#[test]
fn yaml_shadow_report_snapshot_shape() {
let disabled = parse_shadow("title: Snapshot", ShadowYamlOptions::default());
let enabled_plain = parse_shadow(
"title: Snapshot",
ShadowYamlOptions {
enabled: true,
input_kind: YamlInputKind::Plain,
},
);
let enabled_hashpipe = parse_shadow(
"#| title: Snapshot",
ShadowYamlOptions {
enabled: true,
input_kind: YamlInputKind::Hashpipe,
},
);
let snapshot = [
render_shadow_report("[disabled]", &disabled),
render_shadow_report("[enabled-plain]", &enabled_plain),
render_shadow_report("[enabled-hashpipe]", &enabled_hashpipe),
]
.join("\n");
let expected = "[disabled]
outcome=SkippedDisabled
reason=shadow-disabled
kind=Plain
bytes=15
lines=1
normalized=None
[enabled-plain]
outcome=PrototypeParsed
reason=prototype-basic-mapping-parsed
kind=Plain
bytes=15
lines=1
normalized=Some(\"title: Snapshot\")
[enabled-hashpipe]
outcome=PrototypeParsed
reason=prototype-basic-mapping-parsed
kind=Hashpipe
bytes=18
lines=1
normalized=Some(\"title: Snapshot\")
";
assert_eq!(snapshot, expected);
}
#[test]
fn yaml_shadow_report_snapshot_multiline_crlf_shape() {
let plain_multiline = parse_shadow(
"title: Snapshot\r\nauthor: Me\r\n",
ShadowYamlOptions {
enabled: true,
input_kind: YamlInputKind::Plain,
},
);
let hashpipe_multiline = parse_shadow(
"#| title: Snapshot\r\n#| author: Me\r\n",
ShadowYamlOptions {
enabled: true,
input_kind: YamlInputKind::Hashpipe,
},
);
let snapshot = [
render_shadow_report("[enabled-plain-crlf-multiline]", &plain_multiline),
render_shadow_report("[enabled-hashpipe-crlf-multiline]", &hashpipe_multiline),
]
.join("\n");
let expected = "[enabled-plain-crlf-multiline]
outcome=PrototypeParsed
reason=prototype-basic-mapping-parsed
kind=Plain
bytes=29
lines=2
normalized=Some(\"title: Snapshot\\r\\nauthor: Me\\r\\n\")
[enabled-hashpipe-crlf-multiline]
outcome=PrototypeParsed
reason=prototype-basic-mapping-parsed
kind=Hashpipe
bytes=35
lines=2
normalized=Some(\"title: Snapshot\\nauthor: Me\")
";
assert_eq!(snapshot, expected);
}
#[test]
fn yaml_blocked_error_cases_reject_mapping_tree() {
let mut exercised = 0usize;
let allowlisted: std::collections::HashSet<_> = allowlisted_case_paths()
.into_iter()
.map(|(id, _)| id)
.collect();
for (case_id, case_path) in blocked_case_paths() {
let error_file = case_path.join("error");
if !error_file.exists() {
continue;
}
exercised += 1;
assert!(
!allowlisted.contains(&case_id),
"error-contract case {} must not be allowlisted",
case_id
);
let in_yaml = case_path.join("in.yaml");
let input = fs::read_to_string(&in_yaml).unwrap_or_else(|e| {
panic!(
"failed to read blocked case {} ({}): {e}",
case_id,
in_yaml.display()
)
});
let expected_events = fixture_case_events(&case_path);
let actual_events = cst_yaml_projected_events(&input);
assert_ne!(
actual_events, expected_events,
"error-contract case {} unexpectedly matches success event parity",
case_id
);
}
assert!(
exercised > 0,
"expected at least one blocked yaml-test-suite case with an error contract"
);
}