use crate::e2e::escape::escape_python;
use crate::e2e::fixture::{CallbackAction, TemplateReturnForm};
use super::visitor_context::VisitorContextProbe;
pub(super) fn emit_python_visitor_context_probes(out: &mut String, probes: &[&VisitorContextProbe]) {
if probes.is_empty() {
return;
}
out.push_str(&crate::e2e::template_env::render(
"python/visitor_context_probe.jinja",
minijinja::context! { probes => probes },
));
}
pub(super) fn emit_python_visitor_context_assertions(out: &mut String) {
out.push_str(&crate::e2e::template_env::render(
"python/visitor_context_assertions.jinja",
minijinja::context! {},
));
}
pub(super) fn emit_python_visitor_method(
out: &mut String,
method_name: &str,
action: &CallbackAction,
probe_method: Option<&str>,
) {
let params = match method_name {
"visit_link" => "self, ctx, href, text, title",
"visit_image" => "self, ctx, src, alt, title",
"visit_heading" => "self, ctx, level, text, id",
"visit_code_block" => "self, ctx, lang, code",
"visit_code_inline"
| "visit_strong"
| "visit_emphasis"
| "visit_strikethrough"
| "visit_underline"
| "visit_subscript"
| "visit_superscript"
| "visit_mark"
| "visit_button"
| "visit_summary"
| "visit_figcaption"
| "visit_definition_term"
| "visit_definition_description" => "self, ctx, text",
"visit_text" => "self, ctx, text",
"visit_list_item" => "self, ctx, ordered, marker, text",
"visit_blockquote" => "self, ctx, content, depth",
"visit_table_row" => "self, ctx, cells, is_header",
"visit_custom_element" => "self, ctx, tag_name, html",
"visit_form" => "self, ctx, action_url, method",
"visit_input" => "self, ctx, input_type, name, value",
"visit_audio" | "visit_video" | "visit_iframe" => "self, ctx, src",
"visit_details" => "self, ctx, is_open",
"visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
"self, ctx, output, *args"
}
"visit_list_start" => "self, ctx, ordered, *args",
"visit_list_end" => "self, ctx, ordered, output, *args",
_ => "self, ctx, *args",
};
let (action_type, action_value, action_template, return_form) = match action {
CallbackAction::Skip => ("skip", String::new(), String::new(), "dict"),
CallbackAction::Continue => ("continue", String::new(), String::new(), "dict"),
CallbackAction::PreserveHtml => ("preserve_html", String::new(), String::new(), "dict"),
CallbackAction::Custom { output } => {
let escaped = escape_python(output);
("custom", escaped, String::new(), "dict")
}
CallbackAction::CustomTemplate { template, return_form } => {
let escaped_template = template
.replace('\\', "\\\\")
.replace('\'', "\\'")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t");
let form = match return_form {
TemplateReturnForm::Dict => "dict",
TemplateReturnForm::BareString => "bare_string",
};
("custom_template", String::new(), escaped_template, form)
}
};
let needs_a002 = params.split(", ").any(|p| p == "id");
let rendered = crate::e2e::template_env::render(
"python/visitor_method.jinja",
minijinja::context! {
method_name => method_name,
params => params,
needs_a002 => needs_a002,
action_type => action_type,
action_value => action_value,
action_template => action_template,
return_form => return_form,
probe_method => probe_method,
},
);
out.push_str(&rendered);
}
#[cfg(test)]
mod tests {
use super::*;
fn probe(probe_method: &str, attributes: &[&str], methods: &[&str]) -> VisitorContextProbe {
VisitorContextProbe {
probe_method: probe_method.to_string(),
attributes: attributes.iter().map(|name| (*name).to_string()).collect(),
methods: methods.iter().map(|name| (*name).to_string()).collect(),
}
}
#[test]
fn emit_python_visitor_method_skip_returns_skip() {
let mut out = String::new();
emit_python_visitor_method(&mut out, "visit_text", &CallbackAction::Skip, None);
assert!(out.contains("return \"skip\""), "got: {out}");
}
#[test]
fn emit_python_visitor_method_uses_method_name_as_is() {
let mut out = String::new();
emit_python_visitor_method(&mut out, "visit_list_item", &CallbackAction::Continue, None);
assert!(out.contains("visit_list_item"), "got: {out}");
}
#[test]
fn emit_python_custom_uses_adjacent_tag_and_output_payload() {
let mut out = String::new();
emit_python_visitor_method(
&mut out,
"visit_text",
&CallbackAction::Custom {
output: "replacement".to_string(),
},
None,
);
assert!(out.contains(r#"return {"type": "custom", "output": "replacement"}"#));
}
#[test]
fn emit_python_visitor_method_dereferences_context_when_probing() {
let mut out = String::new();
emit_python_visitor_method(
&mut out,
"visit_text",
&CallbackAction::Skip,
Some("_probe_node_context"),
);
assert!(out.contains("self._probe_node_context(ctx)"), "got: {out}");
}
#[test]
fn emit_python_visitor_context_probes_emits_one_helper_per_context_type() {
let first = probe("_probe_node_context", &["node_type"], &["attributes"]);
let second = probe("_probe_frame_context", &["frame_id"], &[]);
let mut out = String::new();
emit_python_visitor_context_probes(&mut out, &[&first, &second]);
assert!(out.contains("def _probe_node_context(self, ctx)"), "got: {out}");
assert!(out.contains("def _probe_frame_context(self, ctx)"), "got: {out}");
assert!(out.contains("\"node_type\","), "got: {out}");
assert!(out.contains("\"frame_id\","), "got: {out}");
assert_eq!(
out.matches("def __init__").count(),
1,
"one recorder for both probes: {out}"
);
}
#[test]
fn emit_python_visitor_context_probes_calls_zero_arg_methods() {
let only = probe("_probe_node_context", &["node_type"], &["attributes"]);
let mut out = String::new();
emit_python_visitor_context_probes(&mut out, &[&only]);
assert!(
out.contains("getattr(ctx, name)()"),
"declared methods must be called: {out}"
);
assert!(
out.contains("getattr(ctx, name)\n"),
"plain attributes must still be read: {out}"
);
assert!(out.contains("except AttributeError as exc:"), "got: {out}");
assert!(out.contains("self.context_reads += 1"), "got: {out}");
}
#[test]
fn emit_python_visitor_context_probes_is_silent_without_probes() {
let mut out = String::new();
emit_python_visitor_context_probes(&mut out, &[]);
assert_eq!(out, "");
}
#[test]
fn emit_python_visitor_context_probes_checks_the_shape_before_any_name() {
let first = probe("_probe_node_context", &["values"], &["items"]);
let second = probe("_probe_frame_context", &["frame_id"], &[]);
let mut out = String::new();
emit_python_visitor_context_probes(&mut out, &[&first, &second]);
assert_eq!(
out.matches("def _record_dict_shaped_context").count(),
1,
"one shape check shared by every probe: {out}"
);
assert_eq!(
out.matches("if self._record_dict_shaped_context(").count(),
2,
"every probe must run the shape check, not just the first: {out}"
);
assert!(out.contains("if not isinstance(ctx, dict):"), "got: {out}");
let shape_check = out
.find(r#"self._record_dict_shaped_context("_probe_node_context", ctx)"#)
.expect("the first probe must run the shape check");
let first_name = out
.find(r#""values","#)
.expect("the first probe must read its declared field");
assert!(
shape_check < first_name,
"the shape check must precede the name probes, or a colliding dict passes them first: {out}"
);
}
#[test]
fn emit_python_visitor_context_assertions_fails_the_test_on_recorded_errors() {
let mut out = String::new();
emit_python_visitor_context_assertions(&mut out);
assert!(out.contains("assert not _visitor.context_errors"), "got: {out}");
assert!(out.contains("assert _visitor.context_reads > 0"), "got: {out}");
}
}