use super::{
SHADOWABLE_BUILTIN_TYPES, pyi_docstring, python_safe_name, qualify_parameter_type, qualify_shadowed_builtin_types,
substitute_capsule_type,
};
use crate::backends::pyo3::type_map::{python_callback_return_type, python_type};
use crate::codegen::shared::substitute_excluded_types;
use crate::core::config::TraitBridgeConfig;
use crate::core::ir::ApiSurface;
const VISITOR_CONTEXT_DICT_ANNOTATION: &str = "dict[str, object]";
pub(super) fn gen_visitor_protocol_stub(
bridge: &TraitBridgeConfig,
api: &ApiSurface,
capsule_names: &std::collections::HashSet<&str>,
emit_docstrings: bool,
options_types: &std::collections::HashSet<String>,
pyclass_absent_types: &ahash::AHashSet<String>,
core_to_binding_convertible_types: &ahash::AHashSet<String>,
) -> Option<String> {
let methods = bridge.resolve_methods(api);
if methods.is_empty() {
return None;
}
let trait_def = api.types.iter().find(|t| t.name == bridge.trait_name)?;
let is_plugin_bridge = bridge.register_fn.is_some();
let (required, optional): (Vec<&crate::core::ir::MethodDef>, Vec<&crate::core::ir::MethodDef>) =
methods.iter().partition(|m| !(is_plugin_bridge && m.has_default_impl));
let shadowed: Vec<&str> = SHADOWABLE_BUILTIN_TYPES
.iter()
.copied()
.filter(|builtin| {
required
.iter()
.any(|method| !method.binding_excluded && python_safe_name(&method.name) == *builtin)
})
.collect();
let excluded: std::collections::HashSet<&str> = api
.excluded_type_paths
.keys()
.map(String::as_str)
.chain(api.types.iter().filter(|t| t.binding_excluded).map(|t| t.name.as_str()))
.collect();
let dict_fallback_context: Option<&str> = crate::backends::pyo3::trait_bridge::is_visitor_bridge(trait_def, bridge)
.then_some(bridge.context_type.as_deref())
.flatten()
.filter(|_| {
crate::backends::pyo3::trait_bridge::context_binding_class(
api,
bridge,
pyclass_absent_types,
core_to_binding_convertible_types,
)
.is_none()
});
let mut lines = vec![format!("class {}(Protocol):", bridge.trait_name)];
let mut doc = if emit_docstrings {
trait_def.doc.clone()
} else {
String::new()
};
if emit_docstrings && !optional.is_empty() {
let optional_list = optional
.iter()
.map(|m| format!("`{}`", python_safe_name(&m.name)))
.collect::<Vec<_>>()
.join(", ");
let lifecycle_note = if bridge.super_trait.is_some() {
" The lifecycle hooks `initialize()` and `shutdown()` (and `name()` / `version()`) are likewise optional."
} else {
""
};
if !doc.is_empty() {
doc.push_str("\n\n");
}
doc.push_str(&format!(
"Optional methods a backend may additionally implement — the bridge calls them when the object defines them, otherwise the trait's Rust default behavior applies: {optional_list}.{lifecycle_note}"
));
}
if let Some(docstring) = pyi_docstring(&doc, " ") {
lines.push(docstring);
}
let mut body_emitted = false;
for method in required {
if method.binding_excluded {
continue;
}
body_emitted = true;
let mut params: Vec<String> = vec!["self".to_string()];
for p in &method.params {
let param_type = match &p.ty {
crate::core::ir::TypeRef::Named(n) if Some(n.as_str()) == dict_fallback_context => {
VISITOR_CONTEXT_DICT_ANNOTATION.to_string()
}
crate::core::ir::TypeRef::Named(n) if is_plugin_bridge && options_types.contains(n) => {
format!("options.{n}")
}
_ => substitute_capsule_type(
&python_type(&substitute_excluded_types(&p.ty, &excluded)),
capsule_names,
),
};
let param_type = qualify_parameter_type(&p.name, ¶m_type);
let param_type = qualify_shadowed_builtin_types(¶m_type, &shadowed);
params.push(format!("{}: {}", p.name, param_type));
}
let mut_param_type = method
.params
.iter()
.find(|p| p.is_mut)
.filter(|_| matches!(method.return_type, crate::core::ir::TypeRef::Unit))
.map(|p| {
substitute_capsule_type(
&python_type(&substitute_excluded_types(&p.ty, &excluded)),
capsule_names,
)
});
let return_type = if let Some(mut_ty) = mut_param_type {
format!("{mut_ty} | None")
} else {
substitute_capsule_type(
&python_callback_return_type(&substitute_excluded_types(&method.return_type, &excluded)),
capsule_names,
)
};
let return_type = qualify_shadowed_builtin_types(&return_type, &shadowed);
let safe_name = python_safe_name(&method.name);
let signature = format!(" def {}({}) -> {}: ...", safe_name, params.join(", "), return_type);
lines.push(signature);
}
if !body_emitted {
lines.push(" ...".to_string());
}
Some(lines.join("\n"))
}
#[cfg(test)]
mod tests {
use crate::codegen::visitor_context::test_support::neutral_visitor_fixture;
use crate::core::config::TraitBridgeConfig;
use crate::core::ir::{ApiSurface, MethodDef, ParamDef, TypeRef};
use ahash::AHashSet;
fn class_path_fixture() -> (ApiSurface, TraitBridgeConfig) {
let (mut api, _, bridge) = neutral_visitor_fixture();
api.types
.iter_mut()
.find(|type_def| type_def.name == "TraversalState")
.expect("neutral visitor fixture should include its context type")
.is_clone = true;
(api, bridge)
}
fn assert_on_the_class_path(api: &ApiSurface) {
let context = api
.types
.iter()
.find(|type_def| type_def.name == "TraversalState")
.expect("context type stays in the surface");
let convertible = crate::codegen::conversions::core_to_binding_convertible_types(api, &[]);
assert!(
context.is_clone,
"fixture must be Clone: the generated From takes the core value by value while the \
bridge holds only &core::T"
);
assert!(
crate::codegen::conversions::core_to_binding_from_impl_emitted(context, &convertible),
"fixture must have an emitted core-to-binding From impl, or the control below asserts \
the dict fallback against a context that was already on the dict path"
);
}
fn render(api: &ApiSurface, bridge: &TraitBridgeConfig, pyclass_absent_types: &AHashSet<String>) -> String {
let convertible = crate::codegen::conversions::core_to_binding_convertible_types(api, &[]);
super::gen_visitor_protocol_stub(
bridge,
api,
&std::collections::HashSet::new(),
false,
&std::collections::HashSet::new(),
pyclass_absent_types,
&convertible,
)
.expect("visitor protocol stub should generate")
}
#[test]
fn an_excluded_visitor_context_is_annotated_as_a_dict_and_an_unexcluded_one_keeps_its_class() {
let (api, bridge) = class_path_fixture();
assert_on_the_class_path(&api);
let included = render(&api, &bridge, &AHashSet::new());
assert!(
included.contains("state: TraversalState"),
"control: an unexcluded context must keep its generated class, or the exclusion \
assertion below proves nothing:\n{included}"
);
assert!(
!included.contains("dict[str, object]"),
"control: an unexcluded context must not be annotated as a dict:\n{included}"
);
let excluded_names: AHashSet<String> = ["TraversalState".to_string()].into_iter().collect();
let excluded = render(&api, &bridge, &excluded_names);
assert!(
excluded.contains("state: dict[str, object]"),
"a context whose #[pyclass] the config removed must be annotated as the dict the \
bridge actually passes:\n{excluded}"
);
assert!(
!excluded.contains("state: TraversalState"),
"the stub must not name a class the emitter skipped:\n{excluded}"
);
}
#[test]
fn a_plugin_bridge_parameter_is_never_annotated_with_the_visitor_dict_fallback() {
let (mut api, _, mut bridge) = neutral_visitor_fixture();
bridge.register_fn = Some("register_walker".to_string());
api.types
.iter_mut()
.find(|type_def| type_def.name == "DocumentWalker")
.expect("neutral visitor fixture should include its trait")
.methods
.iter_mut()
.for_each(|method| method.has_default_impl = false);
let excluded_names: AHashSet<String> = ["TraversalState".to_string()].into_iter().collect();
let stub = render(&api, &bridge, &excluded_names);
assert!(
!stub.contains("dict[str, object]"),
"a plugin bridge has no visitor context fallback to describe:\n{stub}"
);
}
#[test]
fn protocol_method_named_bytes_qualifies_sibling_annotations() {
let (mut api, _, bridge) = neutral_visitor_fixture();
let trait_def = api
.types
.iter_mut()
.find(|type_def| type_def.name == bridge.trait_name)
.expect("neutral visitor fixture should include its trait");
trait_def.methods = vec![
MethodDef {
name: "bytes".to_string(),
return_type: TypeRef::Unit,
..Default::default()
},
MethodDef {
name: "consume".to_string(),
params: vec![ParamDef {
name: "payload".to_string(),
ty: TypeRef::Bytes,
..Default::default()
}],
return_type: TypeRef::Bytes,
..Default::default()
},
];
let stub = render(&api, &bridge, &AHashSet::new());
assert!(
stub.contains("def bytes("),
"fixture must emit the shadowing method:\n{stub}"
);
assert!(
stub.contains("def consume(self, payload: builtins.bytes) -> builtins.bytes"),
"protocol methods share their class annotation scope:\n{stub}"
);
}
#[test]
fn protocol_method_named_object_qualifies_sibling_annotations() {
let (mut api, bridge) = class_path_fixture();
let trait_def = api
.types
.iter_mut()
.find(|type_def| type_def.name == bridge.trait_name)
.expect("neutral visitor fixture should include its trait");
trait_def.methods.insert(
0,
MethodDef {
name: "object".to_string(),
return_type: TypeRef::Unit,
has_default_impl: true,
..Default::default()
},
);
let absent: AHashSet<String> = ["TraversalState".to_string()].into_iter().collect();
let stub = render(&api, &bridge, &absent);
assert!(
stub.contains("def object("),
"fixture must emit the shadowing method:\n{stub}"
);
assert!(
stub.contains("dict[str, builtins.object]"),
"Protocol method names shadow object annotations throughout the class:\n{stub}"
);
let Ok(pyrefly) = which::which("pyrefly") else {
return;
};
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("protocol.pyi");
std::fs::write(
dir.path().join("pyproject.toml"),
"[tool.pyrefly]\npreset = \"strict\"\nproject-includes = [\"*.pyi\"]\n",
)
.expect("write strict pyrefly config");
let source = format!("import builtins\nfrom typing import Protocol\nclass WalkOutcome: ...\n\n{stub}\n");
std::fs::write(&path, &source).expect("write generated Protocol stub");
let clean = std::process::Command::new(&pyrefly)
.arg("check")
.arg(dir.path())
.output()
.expect("run pyrefly on generated Protocol stub");
assert!(
clean.status.success(),
"qualified generated Protocol must pass pyrefly:\n{}{}",
String::from_utf8_lossy(&clean.stdout),
String::from_utf8_lossy(&clean.stderr)
);
std::fs::write(&path, source.replace("builtins.object", "object")).expect("write adversarial Protocol stub");
let broken = std::process::Command::new(pyrefly)
.arg("check")
.arg(dir.path())
.output()
.expect("run pyrefly negative control");
let diagnostics = format!(
"{}{}",
String::from_utf8_lossy(&broken.stdout),
String::from_utf8_lossy(&broken.stderr)
);
assert!(!broken.status.success(), "negative control must fail pyrefly");
assert!(
diagnostics.contains("[not-a-type]"),
"negative control must reproduce the object shadowing diagnostic:\n{diagnostics}"
);
}
}