use std::io::Write as IoWrite;
use std::process::{Command, Stdio};
use crate::core::config::{ResolvedCrateConfig, TraitBridgeConfig};
use crate::core::ir::{FieldDef, MethodDef, ReceiverKind, TypeDef, TypeRef};
use crate::e2e::config::{CallConfig, E2eConfig};
use crate::e2e::fixture::Fixture;
use super::super::helpers::core_to_binding_convertible_types;
use super::super::visitor_context::{distinct_context_probes, visitor_callback_probes};
use super::super::visitors::{emit_python_visitor_context_probes, emit_python_visitor_method};
use super::render_test_file;
const DRIVER_SOURCE: &str = include_str!("visitor_context_runtime_driver.py");
const TRAIT_NAME: &str = "DocumentWalker";
const CONTEXT_TYPE: &str = "TraversalState";
const CALLBACK: &str = "visit_heading";
fn python_available() -> bool {
which::which("python3").is_ok()
}
fn context_field(name: &str) -> FieldDef {
FieldDef {
name: name.to_string(),
ty: TypeRef::String,
..FieldDef::default()
}
}
fn context_method(name: &str) -> MethodDef {
MethodDef {
name: name.to_string(),
return_type: TypeRef::String,
receiver: Some(ReceiverKind::Ref),
..MethodDef::default()
}
}
fn context_type_defs(fields: &[&str], methods: &[&str]) -> Vec<TypeDef> {
vec![
TypeDef {
name: TRAIT_NAME.to_string(),
rust_path: format!("sample_core::{TRAIT_NAME}"),
is_trait: true,
methods: vec![MethodDef {
name: CALLBACK.to_string(),
has_default_impl: true,
..MethodDef::default()
}],
..TypeDef::default()
},
TypeDef {
name: CONTEXT_TYPE.to_string(),
rust_path: format!("sample_core::{CONTEXT_TYPE}"),
is_clone: true,
fields: fields.iter().map(|name| context_field(name)).collect(),
methods: methods.iter().map(|name| context_method(name)).collect(),
..TypeDef::default()
},
]
}
fn bridge_config() -> ResolvedCrateConfig {
ResolvedCrateConfig {
trait_bridges: vec![TraitBridgeConfig {
trait_name: TRAIT_NAME.to_string(),
type_alias: Some(format!("{TRAIT_NAME}Handle")),
context_type: Some(CONTEXT_TYPE.to_string()),
..TraitBridgeConfig::default()
}],
..ResolvedCrateConfig::default()
}
}
fn visitor_fixture() -> Fixture {
serde_json::from_value(serde_json::json!({
"id": "visits_headings",
"description": "Visits headings",
"visitor": {"callbacks": {"visit_heading": {"action": "skip"}}},
}))
.expect("fixture must parse")
}
fn e2e_config() -> E2eConfig {
E2eConfig {
call: CallConfig {
function: "do_thing".to_string(),
module: "mypackage".to_string(),
result_var: "result".to_string(),
..Default::default()
},
..Default::default()
}
}
fn generated_visitor_class(fields: &[&str], methods: &[&str]) -> String {
let type_defs = context_type_defs(fields, methods);
let config = bridge_config();
let fixture = visitor_fixture();
let convertible = core_to_binding_convertible_types(&type_defs, &[]);
let callbacks = visitor_callback_probes(&config, &type_defs, &[], &convertible, &fixture);
let distinct = distinct_context_probes(&callbacks);
assert!(
!distinct.is_empty(),
"the fixture produced no context probe, so every assertion below would run over an empty \
class body and pass without testing anything"
);
let mut body = String::new();
emit_python_visitor_context_probes(&mut body, &distinct);
for (method_name, action, probe) in &callbacks {
emit_python_visitor_method(
&mut body,
method_name,
action,
probe.as_ref().map(|probe| probe.probe_method.as_str()),
);
}
let shipped = render_test_file(
"visitor",
&[&fixture],
&e2e_config(),
&config,
&type_defs,
&[],
&[],
&[],
false,
);
assert!(
shipped.contains(&body),
"the class body under execution is not the one the generator ships:\n--- executed ---\n{body}\n\
--- rendered test file ---\n{shipped}"
);
let dedented: String = body
.lines()
.map(|line| format!("{}\n", line.strip_prefix(" ").unwrap_or(line)))
.collect();
format!("class _TestVisitor:\n{dedented}")
}
fn run_driver(class_source: &str, attributes: &[&str], methods: &[&str]) -> (bool, String) {
let dir = tempfile::tempdir().expect("tempdir");
let driver_path = dir.path().join("visitor_context_runtime_driver.py");
std::fs::write(&driver_path, DRIVER_SOURCE).expect("write driver");
let job = serde_json::json!({
"class_source": class_source,
"callback": CALLBACK,
"attributes": attributes,
"methods": methods,
})
.to_string();
let mut child = Command::new("python3")
.arg(&driver_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn python3");
child
.stdin
.take()
.expect("driver stdin")
.write_all(job.as_bytes())
.expect("write job");
let output = child.wait_with_output().expect("run driver");
let combined = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
(output.status.success(), combined)
}
#[test]
fn a_class_shaped_context_is_accepted_and_a_dict_shaped_one_is_rejected() {
if !python_available() {
return;
}
let attributes = ["node_kind", "depth"];
let methods = ["attributes"];
let class_source = generated_visitor_class(&attributes, &methods);
let (ok, report) = run_driver(&class_source, &attributes, &methods);
assert!(
ok,
"generated probe failed to separate the context shapes:\n{report}\n--- executed ---\n{class_source}"
);
}
#[test]
fn a_dict_whose_names_collide_with_the_dict_api_is_rejected() {
if !python_available() {
return;
}
let attributes = ["values"];
let methods = ["items"];
let class_source = generated_visitor_class(&attributes, &methods);
assert!(
class_source.contains("\"values\",") && class_source.contains("\"items\","),
"the collision surface never reached the generated probe, so the dict below would be \
rejected by an ordinary name probe instead:\n{class_source}"
);
let (ok, report) = run_driver(&class_source, &attributes, &methods);
assert!(
ok,
"a dict whose keys and methods shadow the declared surface must still be rejected:\n{report}\n\
--- executed ---\n{class_source}"
);
}
#[test]
fn the_driver_fails_when_the_probe_records_nothing() {
if !python_available() {
return;
}
let inert = concat!(
"class _TestVisitor:\n",
" def __init__(self):\n",
" self.context_errors = []\n",
" self.context_reads = 0\n",
"\n",
" def visit_heading(self, ctx, level, text, id):\n",
" self.context_reads += 1\n",
" return \"skip\"\n",
);
let (ok, report) = run_driver(inert, &["values"], &["items"]);
assert!(!ok, "the driver must be able to fail; it reported:\n{report}");
assert!(
report.contains("dict-shaped context was accepted"),
"the driver must name the expectation that failed, got:\n{report}"
);
}