1use crate::docstring::parse_rust_doc;
4use crate::model::*;
5use quote::ToTokens;
6use std::path::Path;
7use syn::{
8 Attribute, Fields, FnArg, GenericParam, Generics, ImplItem, Item, ItemConst, ItemEnum, ItemFn,
9 ItemImpl, ItemStruct, ItemTrait, ItemType, Meta, Pat, ReturnType, TraitItem,
10 Visibility as SynVisibility, spanned::Spanned,
11};
12
13pub struct RustParser;
14
15impl RustParser {
16 pub fn new() -> Self {
17 Self
18 }
19
20 pub fn parse_file(&self, path: &Path) -> crate::error::Result<RustModule> {
27 use crate::error::PlisskenError;
28
29 let content =
30 std::fs::read_to_string(path).map_err(|e| PlisskenError::file_read(path, e))?;
31 self.parse_str(&content, path)
32 }
33
34 pub fn parse_str(&self, content: &str, path: &Path) -> crate::error::Result<RustModule> {
40 use crate::error::PlisskenError;
41
42 let syntax = syn::parse_file(content).map_err(|e| PlisskenError::Parse {
43 language: "Rust".into(),
44 path: path.to_path_buf(),
45 line: Some(e.span().start().line),
46 message: e.to_string(),
47 })?;
48
49 let doc_comment = extract_inner_doc_comments(&syntax.attrs);
51 let parsed_doc = doc_comment.as_ref().map(|d| parse_rust_doc(d));
53
54 let items = syntax
56 .items
57 .iter()
58 .filter_map(|item| self.extract_item(item, content, path))
59 .collect();
60
61 Ok(RustModule {
62 path: path.display().to_string(),
63 doc_comment,
64 parsed_doc,
65 items,
66 source: SourceSpan::new(
67 path.to_path_buf(),
68 1,
69 content.lines().count().max(1),
70 content,
71 ),
72 })
73 }
74
75 fn extract_item(&self, item: &Item, content: &str, path: &Path) -> Option<RustItem> {
76 match item {
77 Item::Struct(s) => Some(RustItem::Struct(self.extract_struct(s, content, path))),
78 Item::Enum(e) => Some(RustItem::Enum(self.extract_enum(e, content, path))),
79 Item::Fn(f) => Some(RustItem::Function(self.extract_function(f, content, path))),
80 Item::Trait(t) => Some(RustItem::Trait(self.extract_trait(t, content, path))),
81 Item::Impl(i) => Some(RustItem::Impl(self.extract_impl(i, content, path))),
82 Item::Const(c) => Some(RustItem::Const(self.extract_const(c, content, path))),
83 Item::Type(t) => Some(RustItem::TypeAlias(
84 self.extract_type_alias(t, content, path),
85 )),
86 _ => None,
87 }
88 }
89
90 fn extract_struct(&self, s: &ItemStruct, content: &str, path: &Path) -> RustStruct {
91 let span = get_source_span(
92 &s.struct_token.span,
93 &s.semi_token.map(|t| t.span).unwrap_or_else(|| {
94 s.fields.span()
96 }),
97 content,
98 path,
99 );
100
101 let doc_comment = extract_doc_comments(&s.attrs);
102 let parsed_doc = doc_comment.as_ref().map(|d| parse_rust_doc(d));
103
104 RustStruct {
105 name: s.ident.to_string(),
106 visibility: convert_visibility(&s.vis),
107 doc_comment,
108 parsed_doc,
109 generics: extract_generics(&s.generics),
110 fields: extract_fields(&s.fields),
111 derives: extract_derives(&s.attrs),
112 pyclass: extract_pyclass(&s.attrs),
113 source: span,
114 }
115 }
116
117 fn extract_enum(&self, e: &ItemEnum, content: &str, path: &Path) -> RustEnum {
118 let span = get_source_span(
119 &e.enum_token.span,
120 &e.brace_token.span.close(),
121 content,
122 path,
123 );
124
125 let doc_comment = extract_doc_comments(&e.attrs);
126 let parsed_doc = doc_comment.as_ref().map(|d| parse_rust_doc(d));
127
128 RustEnum {
129 name: e.ident.to_string(),
130 visibility: convert_visibility(&e.vis),
131 doc_comment,
132 parsed_doc,
133 generics: extract_generics(&e.generics),
134 variants: e
135 .variants
136 .iter()
137 .map(|v| RustVariant {
138 name: v.ident.to_string(),
139 doc_comment: extract_doc_comments(&v.attrs),
140 fields: extract_fields(&v.fields),
141 })
142 .collect(),
143 source: span,
144 }
145 }
146
147 fn extract_function(&self, f: &ItemFn, content: &str, path: &Path) -> RustFunction {
148 let block_end = f.block.brace_token.span.close();
150 extract_function_common(
151 &f.sig.ident.to_string(),
152 &f.vis,
153 &f.attrs,
154 &f.sig,
155 Some(&block_end),
156 content,
157 path,
158 )
159 }
160
161 fn extract_trait(&self, t: &ItemTrait, content: &str, path: &Path) -> RustTrait {
162 let span = get_source_span(
163 &t.trait_token.span,
164 &t.brace_token.span.close(),
165 content,
166 path,
167 );
168
169 let bounds = if t.supertraits.is_empty() {
170 None
171 } else {
172 Some(
173 t.supertraits
174 .iter()
175 .map(|b| b.to_token_stream().to_string())
176 .collect::<Vec<_>>()
177 .join(" + "),
178 )
179 };
180
181 let associated_types = t
182 .items
183 .iter()
184 .filter_map(|item| {
185 if let TraitItem::Type(ty) = item {
186 Some(RustAssociatedType {
187 name: ty.ident.to_string(),
188 doc_comment: extract_doc_comments(&ty.attrs),
189 generics: extract_generics(&ty.generics),
190 bounds: if ty.bounds.is_empty() {
191 None
192 } else {
193 Some(
194 ty.bounds
195 .iter()
196 .map(|b| b.to_token_stream().to_string())
197 .collect::<Vec<_>>()
198 .join(" + "),
199 )
200 },
201 })
202 } else {
203 None
204 }
205 })
206 .collect();
207
208 let methods = t
209 .items
210 .iter()
211 .filter_map(|item| {
212 if let TraitItem::Fn(f) = item {
213 let block_end = f
215 .default
216 .as_ref()
217 .map(|block| block.brace_token.span.close());
218 Some(extract_function_common(
219 &f.sig.ident.to_string(),
220 &SynVisibility::Inherited,
221 &f.attrs,
222 &f.sig,
223 block_end.as_ref(),
224 content,
225 path,
226 ))
227 } else {
228 None
229 }
230 })
231 .collect();
232
233 let doc_comment = extract_doc_comments(&t.attrs);
234 let parsed_doc = doc_comment.as_ref().map(|d| parse_rust_doc(d));
235
236 RustTrait {
237 name: t.ident.to_string(),
238 visibility: convert_visibility(&t.vis),
239 doc_comment,
240 parsed_doc,
241 generics: extract_generics(&t.generics),
242 bounds,
243 associated_types,
244 methods,
245 source: span,
246 }
247 }
248
249 fn extract_impl(&self, i: &ItemImpl, content: &str, path: &Path) -> RustImpl {
250 let span = get_source_span(
251 &i.impl_token.span,
252 &i.brace_token.span.close(),
253 content,
254 path,
255 );
256
257 let trait_ = i
258 .trait_
259 .as_ref()
260 .map(|(_, path, _)| path.to_token_stream().to_string());
261
262 let where_clause = i
263 .generics
264 .where_clause
265 .as_ref()
266 .map(|w| w.to_token_stream().to_string());
267
268 let pymethods = i.attrs.iter().any(|attr| attr.path().is_ident("pymethods"));
269
270 let methods = i
271 .items
272 .iter()
273 .filter_map(|item| {
274 if let ImplItem::Fn(f) = item {
275 let block_end = f.block.brace_token.span.close();
277 Some(extract_function_common(
278 &f.sig.ident.to_string(),
279 &f.vis,
280 &f.attrs,
281 &f.sig,
282 Some(&block_end),
283 content,
284 path,
285 ))
286 } else {
287 None
288 }
289 })
290 .collect();
291
292 RustImpl {
293 generics: extract_generics(&i.generics),
294 target: i.self_ty.to_token_stream().to_string(),
295 trait_,
296 where_clause,
297 methods,
298 pymethods,
299 source: span,
300 }
301 }
302
303 fn extract_const(&self, c: &ItemConst, content: &str, path: &Path) -> RustConst {
304 let span = get_source_span(&c.const_token.span, &c.semi_token.span, content, path);
305
306 RustConst {
307 name: c.ident.to_string(),
308 visibility: convert_visibility(&c.vis),
309 doc_comment: extract_doc_comments(&c.attrs),
310 ty: c.ty.to_token_stream().to_string(),
311 value: Some(c.expr.to_token_stream().to_string()),
312 source: span,
313 }
314 }
315
316 fn extract_type_alias(&self, t: &ItemType, content: &str, path: &Path) -> RustTypeAlias {
317 let span = get_source_span(&t.type_token.span, &t.semi_token.span, content, path);
318
319 RustTypeAlias {
320 name: t.ident.to_string(),
321 visibility: convert_visibility(&t.vis),
322 doc_comment: extract_doc_comments(&t.attrs),
323 generics: extract_generics(&t.generics),
324 ty: t.ty.to_token_stream().to_string(),
325 source: span,
326 }
327 }
328}
329
330impl Default for RustParser {
331 fn default() -> Self {
332 Self::new()
333 }
334}
335
336fn convert_visibility(vis: &SynVisibility) -> Visibility {
341 match vis {
342 SynVisibility::Public(_) => Visibility::Public,
343 SynVisibility::Restricted(r) => {
344 let path = r.path.to_token_stream().to_string();
345 if path == "crate" {
346 Visibility::PubCrate
347 } else if path == "super" {
348 Visibility::PubSuper
349 } else {
350 Visibility::Private
351 }
352 }
353 SynVisibility::Inherited => Visibility::Private,
354 }
355}
356
357fn extract_doc_comments(attrs: &[Attribute]) -> Option<String> {
358 let docs: Vec<String> = attrs
359 .iter()
360 .filter_map(|attr| {
361 if attr.path().is_ident("doc")
362 && let Meta::NameValue(nv) = &attr.meta
363 && let syn::Expr::Lit(lit) = &nv.value
364 && let syn::Lit::Str(s) = &lit.lit
365 {
366 return Some(s.value());
367 }
368 None
369 })
370 .collect();
371
372 if docs.is_empty() {
373 None
374 } else {
375 Some(
377 docs.iter()
378 .map(|s| s.strip_prefix(' ').unwrap_or(s))
379 .collect::<Vec<_>>()
380 .join("\n"),
381 )
382 }
383}
384
385fn extract_inner_doc_comments(attrs: &[Attribute]) -> Option<String> {
386 let docs: Vec<String> = attrs
387 .iter()
388 .filter_map(|attr| {
389 if attr.path().is_ident("doc")
391 && let Meta::NameValue(nv) = &attr.meta
392 && let syn::Expr::Lit(lit) = &nv.value
393 && let syn::Lit::Str(s) = &lit.lit
394 {
395 return Some(s.value());
396 }
397 None
398 })
399 .collect();
400
401 if docs.is_empty() {
402 None
403 } else {
404 Some(
405 docs.iter()
406 .map(|s| s.strip_prefix(' ').unwrap_or(s))
407 .collect::<Vec<_>>()
408 .join("\n"),
409 )
410 }
411}
412
413fn extract_generics(generics: &Generics) -> Option<String> {
414 if generics.params.is_empty() {
415 return None;
416 }
417
418 let params: Vec<String> = generics
419 .params
420 .iter()
421 .map(|p| match p {
422 GenericParam::Type(t) => {
423 let mut s = t.ident.to_string();
424 if !t.bounds.is_empty() {
425 s.push_str(": ");
426 s.push_str(
427 &t.bounds
428 .iter()
429 .map(|b| b.to_token_stream().to_string())
430 .collect::<Vec<_>>()
431 .join(" + "),
432 );
433 }
434 s
435 }
436 GenericParam::Lifetime(l) => l.to_token_stream().to_string(),
437 GenericParam::Const(c) => {
438 format!("const {}: {}", c.ident, c.ty.to_token_stream())
439 }
440 })
441 .collect();
442
443 Some(format!("<{}>", params.join(", ")))
444}
445
446fn extract_fields(fields: &Fields) -> Vec<RustField> {
447 match fields {
448 Fields::Named(named) => named
449 .named
450 .iter()
451 .map(|f| RustField {
452 name: f.ident.as_ref().map(|i| i.to_string()).unwrap_or_default(),
453 ty: f.ty.to_token_stream().to_string(),
454 visibility: convert_visibility(&f.vis),
455 doc_comment: extract_doc_comments(&f.attrs),
456 })
457 .collect(),
458 Fields::Unnamed(unnamed) => unnamed
459 .unnamed
460 .iter()
461 .enumerate()
462 .map(|(i, f)| RustField {
463 name: format!("{}", i),
464 ty: f.ty.to_token_stream().to_string(),
465 visibility: convert_visibility(&f.vis),
466 doc_comment: extract_doc_comments(&f.attrs),
467 })
468 .collect(),
469 Fields::Unit => vec![],
470 }
471}
472
473fn extract_derives(attrs: &[Attribute]) -> Vec<String> {
474 attrs
475 .iter()
476 .filter_map(|attr| {
477 if attr.path().is_ident("derive")
478 && let Meta::List(list) = &attr.meta
479 {
480 let tokens = list.tokens.to_string();
481 return Some(
482 tokens
483 .split(',')
484 .map(|s| s.trim().to_string())
485 .collect::<Vec<_>>(),
486 );
487 }
488 None
489 })
490 .flatten()
491 .collect()
492}
493
494fn extract_pyclass(attrs: &[Attribute]) -> Option<PyClassMeta> {
495 for attr in attrs {
496 if attr.path().is_ident("pyclass") {
497 let mut meta = PyClassMeta::new();
498
499 if let Meta::List(list) = &attr.meta {
500 let tokens = list.tokens.to_string();
501 for part in tokens.split(',') {
502 let part = part.trim();
503 if let Some(name) = part.strip_prefix("name") {
504 let name = name.trim_start_matches([' ', '=']);
505 let name = name.trim_matches('"');
506 meta.name = Some(name.to_string());
507 } else if let Some(module) = part.strip_prefix("module") {
508 let module = module.trim_start_matches([' ', '=']);
509 let module = module.trim_matches('"');
510 meta.module = Some(module.to_string());
511 }
512 }
513 }
514
515 return Some(meta);
516 }
517 }
518 None
519}
520
521fn extract_pyfunction(attrs: &[Attribute]) -> Option<PyFunctionMeta> {
522 let mut meta = PyFunctionMeta::new();
523 let mut found = false;
524
525 for attr in attrs {
526 if attr.path().is_ident("pyfunction") {
527 found = true;
528 if let Meta::List(list) = &attr.meta {
529 let tokens = list.tokens.to_string();
530 for part in tokens.split(',') {
531 let part = part.trim();
532 if let Some(name) = part.strip_prefix("name") {
533 let name = name.trim_start_matches([' ', '=']);
534 let name = name.trim_matches('"');
535 meta.name = Some(name.to_string());
536 }
537 }
538 }
539 } else if attr.path().is_ident("pyo3")
540 && let Meta::List(list) = &attr.meta
541 {
542 let tokens = list.tokens.to_string();
543 if let Some(sig_start) = tokens.find("signature")
544 && let Some(eq_pos) = tokens[sig_start..].find('=')
545 {
546 let sig = tokens[sig_start + eq_pos + 1..].trim();
547 meta.signature = Some(sig.to_string());
548 }
549 }
550 }
551
552 if found || meta.signature.is_some() {
553 Some(meta)
554 } else {
555 None
556 }
557}
558
559fn extract_function_common(
560 name: &str,
561 vis: &SynVisibility,
562 attrs: &[Attribute],
563 sig: &syn::Signature,
564 block_end: Option<&proc_macro2::Span>,
565 content: &str,
566 path: &Path,
567) -> RustFunction {
568 let signature_str = sig.to_token_stream().to_string();
569
570 let params: Vec<RustParam> = sig
571 .inputs
572 .iter()
573 .map(|arg| match arg {
574 FnArg::Receiver(r) => RustParam {
575 name: "self".to_string(),
576 ty: if r.mutability.is_some() {
577 "&mut self".to_string()
578 } else if r.reference.is_some() {
579 "&self".to_string()
580 } else {
581 "self".to_string()
582 },
583 default: None,
584 },
585 FnArg::Typed(t) => {
586 let name = if let Pat::Ident(ident) = &*t.pat {
587 ident.ident.to_string()
588 } else {
589 t.pat.to_token_stream().to_string()
590 };
591 RustParam {
592 name,
593 ty: t.ty.to_token_stream().to_string(),
594 default: None,
595 }
596 }
597 })
598 .collect();
599
600 let return_type = match &sig.output {
601 ReturnType::Default => None,
602 ReturnType::Type(_, ty) => Some(ty.to_token_stream().to_string()),
603 };
604
605 let end_span = block_end.unwrap_or(&sig.fn_token.span);
607 let span = get_source_span(&sig.fn_token.span, end_span, content, path);
608
609 let doc_comment = extract_doc_comments(attrs);
610 let parsed_doc = doc_comment.as_ref().map(|d| parse_rust_doc(d));
611
612 RustFunction {
613 name: name.to_string(),
614 visibility: convert_visibility(vis),
615 doc_comment,
616 parsed_doc,
617 generics: extract_generics(&sig.generics),
618 signature_str,
619 signature: RustFunctionSig {
620 params,
621 return_type,
622 },
623 is_async: sig.asyncness.is_some(),
624 is_unsafe: sig.unsafety.is_some(),
625 is_const: sig.constness.is_some(),
626 pyfunction: extract_pyfunction(attrs),
627 source: span,
628 }
629}
630
631fn get_source_span(
632 start: &proc_macro2::Span,
633 end: &proc_macro2::Span,
634 content: &str,
635 path: &Path,
636) -> SourceSpan {
637 let start_line = start.start().line;
638 let end_line = end.end().line;
639
640 let lines: Vec<&str> = content.lines().collect();
642 let source = if start_line > 0 && end_line <= lines.len() {
643 lines[start_line - 1..end_line].join("\n")
644 } else {
645 String::new()
646 };
647
648 SourceSpan {
649 location: SourceLocation {
650 file: path.to_path_buf(),
651 line_start: start_line,
652 line_end: end_line,
653 },
654 source,
655 }
656}
657
658impl super::traits::Parser for RustParser {
663 fn parse_file(&mut self, path: &Path) -> crate::error::Result<super::traits::Module> {
664 RustParser::parse_file(self, path).map(super::traits::Module::Rust)
665 }
666
667 fn parse_str(
668 &mut self,
669 content: &str,
670 virtual_path: &Path,
671 ) -> crate::error::Result<super::traits::Module> {
672 RustParser::parse_str(self, content, virtual_path).map(super::traits::Module::Rust)
673 }
674
675 fn language(&self) -> super::traits::ParserLanguage {
676 super::traits::ParserLanguage::Rust
677 }
678
679 fn name(&self) -> &'static str {
680 "Rust"
681 }
682
683 fn extensions(&self) -> &'static [&'static str] {
684 &["rs"]
685 }
686}
687
688#[cfg(test)]
689mod tests {
690 use super::*;
691
692 #[test]
693 fn test_parse_empty() {
694 let parser = RustParser::new();
695 let result = parser.parse_str("", Path::new("test.rs"));
696 assert!(result.is_ok());
697 }
698
699 #[test]
700 fn test_parse_struct() {
701 let parser = RustParser::new();
702 let code = r#"
703/// A simple struct
704#[derive(Debug, Clone)]
705pub struct MyStruct {
706 /// The name field
707 pub name: String,
708 count: usize,
709}
710"#;
711 let result = parser.parse_str(code, Path::new("test.rs")).unwrap();
712 assert_eq!(result.items.len(), 1);
713
714 if let RustItem::Struct(s) = &result.items[0] {
715 assert_eq!(s.name, "MyStruct");
716 assert_eq!(s.visibility, Visibility::Public);
717 assert!(s.doc_comment.as_ref().unwrap().contains("simple struct"));
718 assert_eq!(s.derives, vec!["Debug", "Clone"]);
719 assert_eq!(s.fields.len(), 2);
720 assert_eq!(s.fields[0].name, "name");
721 assert_eq!(s.fields[0].visibility, Visibility::Public);
722 assert_eq!(s.fields[1].name, "count");
723 assert_eq!(s.fields[1].visibility, Visibility::Private);
724 } else {
725 panic!("Expected struct");
726 }
727 }
728
729 #[test]
730 fn test_parse_pyclass() {
731 let parser = RustParser::new();
732 let code = r#"
733/// A Python class
734#[pyclass(name = "MyClass")]
735pub struct PyMyClass {
736 value: i32,
737}
738"#;
739 let result = parser.parse_str(code, Path::new("test.rs")).unwrap();
740
741 if let RustItem::Struct(s) = &result.items[0] {
742 assert!(s.pyclass.is_some());
743 let pyclass = s.pyclass.as_ref().unwrap();
744 assert_eq!(pyclass.name, Some("MyClass".to_string()));
745 } else {
746 panic!("Expected struct");
747 }
748 }
749
750 #[test]
751 fn test_parse_function() {
752 let parser = RustParser::new();
753 let code = r#"
754/// Process some data
755pub async fn process(data: &[u8], count: usize) -> Result<(), Error> {
756 Ok(())
757}
758"#;
759 let result = parser.parse_str(code, Path::new("test.rs")).unwrap();
760
761 if let RustItem::Function(f) = &result.items[0] {
762 assert_eq!(f.name, "process");
763 assert!(f.is_async);
764 assert!(!f.is_unsafe);
765 assert_eq!(f.signature.params.len(), 2);
766 assert_eq!(f.signature.params[0].name, "data");
767 assert!(f.signature.return_type.is_some());
768 } else {
769 panic!("Expected function");
770 }
771 }
772
773 #[test]
774 fn test_parse_impl_with_pymethods() {
775 let parser = RustParser::new();
776 let code = r#"
777#[pymethods]
778impl MyClass {
779 /// Create new instance
780 #[new]
781 fn new() -> Self {
782 Self {}
783 }
784
785 /// Get the value
786 #[getter]
787 fn value(&self) -> i32 {
788 42
789 }
790}
791"#;
792 let result = parser.parse_str(code, Path::new("test.rs")).unwrap();
793
794 if let RustItem::Impl(i) = &result.items[0] {
795 assert!(i.pymethods);
796 assert_eq!(i.target, "MyClass");
797 assert_eq!(i.methods.len(), 2);
798 } else {
799 panic!("Expected impl");
800 }
801 }
802
803 #[test]
804 fn test_parse_module_doc() {
805 let parser = RustParser::new();
806 let code = r#"//! Module documentation
807//!
808//! More details here.
809
810pub struct Foo;
811"#;
812 let result = parser.parse_str(code, Path::new("test.rs")).unwrap();
813 assert!(result.doc_comment.is_some());
814 assert!(
815 result
816 .doc_comment
817 .as_ref()
818 .unwrap()
819 .contains("Module documentation")
820 );
821 }
822
823 #[test]
824 fn test_parse_hybrid_binary_fixture() {
825 use crate::test_fixtures::hybrid_binary;
826
827 let parser = RustParser::new();
828 let fixture_path = hybrid_binary::rust_lib();
829
830 let result = parser.parse_file(&fixture_path).unwrap();
831
832 assert!(result.doc_comment.is_some());
834 assert!(
835 result
836 .doc_comment
837 .as_ref()
838 .unwrap()
839 .contains("task runner library")
840 );
841
842 let struct_count = result
844 .items
845 .iter()
846 .filter(|i| matches!(i, RustItem::Struct(_)))
847 .count();
848 let impl_count = result
849 .items
850 .iter()
851 .filter(|i| matches!(i, RustItem::Impl(_)))
852 .count();
853
854 assert!(
855 struct_count >= 3,
856 "Expected at least 3 structs (PyTask, PyRunner, PyRunResult)"
857 );
858 assert!(impl_count >= 2, "Expected at least 2 impl blocks");
859
860 let py_task = result.items.iter().find_map(|i| {
862 if let RustItem::Struct(s) = i {
863 if s.name == "PyTask" {
864 return Some(s);
865 }
866 }
867 None
868 });
869 assert!(py_task.is_some(), "PyTask struct not found");
870 let py_task = py_task.unwrap();
871 assert!(py_task.pyclass.is_some(), "PyTask should have pyclass");
872 assert_eq!(
873 py_task.pyclass.as_ref().unwrap().name,
874 Some("Task".to_string())
875 );
876
877 let pymethods_impl = result.items.iter().find_map(|i| {
879 if let RustItem::Impl(imp) = i {
880 if imp.pymethods && imp.target == "PyTask" {
881 return Some(imp);
882 }
883 }
884 None
885 });
886 assert!(pymethods_impl.is_some(), "PyTask pymethods impl not found");
887 let pymethods_impl = pymethods_impl.unwrap();
888 assert!(
889 pymethods_impl.methods.len() >= 4,
890 "Expected at least 4 methods in PyTask"
891 );
892 }
893
894 #[test]
895 fn test_parse_pure_rust_fixture() {
896 use crate::test_fixtures::pure_rust;
897
898 let parser = RustParser::new();
899 let fixture_path = pure_rust::lib();
900
901 let result = parser.parse_file(&fixture_path).unwrap();
902
903 assert!(result.doc_comment.is_some());
905
906 let has_pyclass = result.items.iter().any(|i| {
908 if let RustItem::Struct(s) = i {
909 s.pyclass.is_some()
910 } else {
911 false
912 }
913 });
914 assert!(!has_pyclass, "pure_rust should have no pyclass");
915 }
916}