Skip to main content

plissken_core/parser/
rust.rs

1//! Rust source code parser using syn
2
3use 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    /// Parse a Rust source file.
21    ///
22    /// # Errors
23    ///
24    /// Returns `PlisskenError::FileRead` if the file cannot be read,
25    /// `PlisskenError::Parse` if the Rust syntax is invalid.
26    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    /// Parse Rust source from a string.
35    ///
36    /// # Errors
37    ///
38    /// Returns `PlisskenError::Parse` if the Rust syntax is invalid.
39    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        // Extract module doc comment from inner attributes
50        let doc_comment = extract_inner_doc_comments(&syntax.attrs);
51        // Parse doc comment into structured form
52        let parsed_doc = doc_comment.as_ref().map(|d| parse_rust_doc(d));
53
54        // Extract items
55        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                // For structs with braces, find the closing brace
95                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        // Get the end of the function block
149        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                    // Trait methods may or may not have a default implementation
214                    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                    // Get the end of the method block
276                    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
336// ============================================================================
337// Helper Functions
338// ============================================================================
339
340fn 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        // Join doc lines and trim leading space from each line
376        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            // Inner doc comments have style = Inner
390            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    // Get span from fn keyword to end of block (or signature if no block)
606    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    // Extract source text
641    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
658// =============================================================================
659// Parser trait implementation
660// =============================================================================
661
662impl 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        // Check module doc
833        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        // Count items - should have structs and impl blocks
843        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        // Check PyTask struct has pyclass
861        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        // Check pymethods impl
878        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        // Should parse without PyO3 attributes
904        assert!(result.doc_comment.is_some());
905
906        // No pyclass items expected
907        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}