syncdoc_migrate/
write.rs

1// syncdoc-migrate/src/write.rs
2
3use crate::discover::ParsedFile;
4use crate::extract::extract_doc_content;
5use proc_macro2::TokenStream;
6use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9use syncdoc_core::parse::{
10    EnumSig, EnumVariant, ImplBlockSig, ModuleContent, ModuleItem, ModuleSig, StructField,
11    StructSig, TraitSig,
12};
13use unsynn::*;
14
15/// Represents a documentation extraction with its target path and metadata
16#[derive(Debug, Clone, PartialEq)]
17pub struct DocExtraction {
18    /// Path where the markdown file should be written
19    pub markdown_path: PathBuf,
20    /// The documentation content to write
21    pub content: String,
22    /// Source location in file:line format
23    pub source_location: String,
24}
25
26impl DocExtraction {
27    /// Creates a new DocExtraction and ensures content ends with a newline
28    pub fn new(markdown_path: PathBuf, mut content: String, source_location: String) -> Self {
29        if !content.ends_with('\n') {
30            content.push('\n');
31        }
32        Self {
33            markdown_path,
34            content,
35            source_location,
36        }
37    }
38}
39
40/// Report of write operation results
41#[derive(Debug, Default)]
42pub struct WriteReport {
43    pub files_written: usize,
44    pub files_skipped: usize,
45    pub errors: Vec<String>,
46}
47
48/// Extracts all documentation from a parsed file
49///
50/// Returns a vector of `DocExtraction` structs, each representing a documentation
51/// comment that should be written to a markdown file.
52pub fn extract_all_docs(parsed: &ParsedFile, docs_root: &str) -> Vec<DocExtraction> {
53    let mut extractions = Vec::new();
54
55    // Extract module path from the source file
56    let module_path = syncdoc_core::path_utils::extract_module_path(&parsed.path.to_string_lossy());
57
58    // Extract module-level (inner) documentation
59    if let Some(inner_doc) = crate::extract::extract_inner_doc_content(&parsed.content.inner_attrs)
60    {
61        // For lib.rs -> docs/lib.md, for main.rs -> docs/main.md, etc.
62        let file_stem = parsed
63            .path
64            .file_stem()
65            .and_then(|s| s.to_str())
66            .unwrap_or("module");
67
68        let path = if module_path.is_empty() {
69            format!("{}/{}.md", docs_root, file_stem)
70        } else {
71            format!("{}/{}.md", docs_root, module_path)
72        };
73
74        extractions.push(DocExtraction::new(
75            PathBuf::from(path),
76            inner_doc,
77            format!("{}:1", parsed.path.display()),
78        ));
79    }
80
81    // Start context with module path if not empty
82    let mut context = Vec::new();
83    if !module_path.is_empty() {
84        context.push(module_path);
85    }
86
87    for item_delimited in &parsed.content.items.0 {
88        let item = &item_delimited.value;
89        extractions.extend(extract_item_docs(
90            item,
91            context.clone(),
92            docs_root,
93            &parsed.path,
94        ));
95    }
96
97    extractions
98}
99
100/// Recursively extracts documentation from a single module item
101fn extract_item_docs(
102    item: &ModuleItem,
103    context: Vec<String>,
104    base_path: &str,
105    source_file: &Path,
106) -> Vec<DocExtraction> {
107    let mut extractions = Vec::new();
108
109    match item {
110        ModuleItem::Function(func_sig) => {
111            if let Some(content) = extract_doc_content(&func_sig.attributes) {
112                let path = build_path(base_path, &context, &func_sig.name.to_string());
113                let location = format!(
114                    "{}:{}",
115                    source_file.display(),
116                    func_sig.name.span().start().line
117                );
118                extractions.push(DocExtraction::new(PathBuf::from(path), content, location));
119            }
120        }
121
122        ModuleItem::ImplBlock(impl_block) => {
123            extractions.extend(extract_impl_docs(
124                impl_block,
125                context,
126                base_path,
127                source_file,
128            ));
129        }
130
131        ModuleItem::Module(module) => {
132            extractions.extend(extract_module_docs(module, context, base_path, source_file));
133        }
134
135        ModuleItem::Trait(trait_def) => {
136            extractions.extend(extract_trait_docs(
137                trait_def,
138                context,
139                base_path,
140                source_file,
141            ));
142        }
143
144        ModuleItem::Enum(enum_sig) => {
145            extractions.extend(extract_enum_docs(enum_sig, context, base_path, source_file));
146        }
147
148        ModuleItem::Struct(struct_sig) => {
149            extractions.extend(extract_struct_docs(
150                struct_sig,
151                context,
152                base_path,
153                source_file,
154            ));
155        }
156
157        ModuleItem::TypeAlias(type_alias) => {
158            if let Some(content) = extract_doc_content(&type_alias.attributes) {
159                let path = build_path(base_path, &context, &type_alias.name.to_string());
160                let location = format!(
161                    "{}:{}",
162                    source_file.display(),
163                    type_alias.name.span().start().line
164                );
165                extractions.push(DocExtraction::new(PathBuf::from(path), content, location));
166            }
167        }
168
169        ModuleItem::Const(const_sig) => {
170            if let Some(content) = extract_doc_content(&const_sig.attributes) {
171                let path = build_path(base_path, &context, &const_sig.name.to_string());
172                let location = format!(
173                    "{}:{}",
174                    source_file.display(),
175                    const_sig.name.span().start().line
176                );
177                extractions.push(DocExtraction::new(PathBuf::from(path), content, location));
178            }
179        }
180
181        ModuleItem::Static(static_sig) => {
182            if let Some(content) = extract_doc_content(&static_sig.attributes) {
183                let path = build_path(base_path, &context, &static_sig.name.to_string());
184                let location = format!(
185                    "{}:{}",
186                    source_file.display(),
187                    static_sig.name.span().start().line
188                );
189                extractions.push(DocExtraction::new(PathBuf::from(path), content, location));
190            }
191        }
192
193        // No documentation to extract from other items
194        ModuleItem::Other(_) => {}
195    }
196
197    extractions
198}
199
200/// Extracts documentation from an impl block and its methods
201fn extract_impl_docs(
202    impl_block: &ImplBlockSig,
203    context: Vec<String>,
204    base_path: &str,
205    source_file: &Path,
206) -> Vec<DocExtraction> {
207    let mut extractions = Vec::new();
208
209    // Extract type name from target_type - reuse logic from token_processors.rs
210    let type_name = if let Some(first) = impl_block.target_type.0.first() {
211        if let proc_macro2::TokenTree::Ident(ident) = &first.value.second {
212            ident.to_string()
213        } else {
214            "Unknown".to_string()
215        }
216    } else {
217        "Unknown".to_string()
218    };
219
220    let mut new_context = context;
221    new_context.push(type_name);
222
223    // Parse the body content
224    let body_stream = extract_brace_content(&impl_block.body);
225    if let Ok(content) = body_stream.into_token_iter().parse::<ModuleContent>() {
226        for item_delimited in &content.items.0 {
227            extractions.extend(extract_item_docs(
228                &item_delimited.value,
229                new_context.clone(),
230                base_path,
231                source_file,
232            ));
233        }
234    }
235
236    extractions
237}
238
239/// Extracts documentation from a module and its contents
240fn extract_module_docs(
241    module: &ModuleSig,
242    context: Vec<String>,
243    base_path: &str,
244    source_file: &Path,
245) -> Vec<DocExtraction> {
246    let mut extractions = Vec::new();
247
248    // Extract module's own documentation if present
249    if let Some(content) = extract_doc_content(&module.attributes) {
250        let path = build_path(base_path, &context, &module.name.to_string());
251        let location = format!(
252            "{}:{}",
253            source_file.display(),
254            module.name.span().start().line
255        );
256        extractions.push(DocExtraction::new(PathBuf::from(path), content, location));
257    }
258
259    // Update context with module name
260    let mut new_context = context;
261    new_context.push(module.name.to_string());
262
263    // Extract the module body content
264    let body_stream = extract_brace_content(&module.body);
265    if let Ok(content) = body_stream.into_token_iter().parse::<ModuleContent>() {
266        for item_delimited in &content.items.0 {
267            extractions.extend(extract_item_docs(
268                &item_delimited.value,
269                new_context.clone(),
270                base_path,
271                source_file,
272            ));
273        }
274    }
275
276    extractions
277}
278
279/// Extracts documentation from a trait and its methods
280fn extract_trait_docs(
281    trait_def: &TraitSig,
282    context: Vec<String>,
283    base_path: &str,
284    source_file: &Path,
285) -> Vec<DocExtraction> {
286    let mut extractions = Vec::new();
287
288    // Extract trait's own documentation if present
289    if let Some(content) = extract_doc_content(&trait_def.attributes) {
290        let path = build_path(base_path, &context, &trait_def.name.to_string());
291        let location = format!(
292            "{}:{}",
293            source_file.display(),
294            trait_def.name.span().start().line
295        );
296        extractions.push(DocExtraction::new(PathBuf::from(path), content, location));
297    }
298
299    // Update context with trait name
300    let mut new_context = context;
301    new_context.push(trait_def.name.to_string());
302
303    // Extract the trait body content
304    let body_stream = extract_brace_content(&trait_def.body);
305    if let Ok(content) = body_stream.into_token_iter().parse::<ModuleContent>() {
306        for item_delimited in &content.items.0 {
307            extractions.extend(extract_item_docs(
308                &item_delimited.value,
309                new_context.clone(),
310                base_path,
311                source_file,
312            ));
313        }
314    }
315
316    extractions
317}
318
319/// Extracts documentation from an enum and its variants
320fn extract_enum_docs(
321    enum_sig: &EnumSig,
322    context: Vec<String>,
323    base_path: &str,
324    source_file: &Path,
325) -> Vec<DocExtraction> {
326    let mut extractions = Vec::new();
327    let enum_name = enum_sig.name.to_string();
328
329    // Extract enum's own documentation
330    if let Some(content) = extract_doc_content(&enum_sig.attributes) {
331        let path = build_path(base_path, &context, &enum_name);
332        let location = format!(
333            "{}:{}",
334            source_file.display(),
335            enum_sig.name.span().start().line
336        );
337        extractions.push(DocExtraction::new(PathBuf::from(path), content, location));
338    }
339
340    // Extract variant documentation
341    let body_stream = extract_brace_content(&enum_sig.body);
342    if let Ok(variants) = body_stream
343        .into_token_iter()
344        .parse::<CommaDelimitedVec<EnumVariant>>()
345    {
346        for variant_delimited in &variants.0 {
347            let variant = &variant_delimited.value;
348            if let Some(content) = extract_doc_content(&variant.attributes) {
349                let path = build_path(
350                    base_path,
351                    &context,
352                    &format!("{}/{}", enum_name, variant.name),
353                );
354                extractions.push(DocExtraction::new(
355                    PathBuf::from(path),
356                    content,
357                    format!(
358                        "{}:{}",
359                        source_file.display(),
360                        variant.name.span().start().line
361                    ),
362                ));
363            }
364        }
365    }
366
367    extractions
368}
369
370/// Extracts documentation from a struct and its fields
371fn extract_struct_docs(
372    struct_sig: &StructSig,
373    context: Vec<String>,
374    base_path: &str,
375    source_file: &Path,
376) -> Vec<DocExtraction> {
377    let mut extractions = Vec::new();
378    let struct_name = struct_sig.name.to_string();
379
380    // Extract struct's own documentation
381    if let Some(content) = extract_doc_content(&struct_sig.attributes) {
382        let path = build_path(base_path, &context, &struct_name);
383        let location = format!(
384            "{}:{}",
385            source_file.display(),
386            struct_sig.name.span().start().line
387        );
388        extractions.push(DocExtraction::new(PathBuf::from(path), content, location));
389    }
390
391    // Extract field documentation (only for named fields)
392    if let syncdoc_core::parse::StructBody::Named(brace_group) = &struct_sig.body {
393        let body_stream = extract_brace_content(brace_group);
394
395        if let Ok(fields) = body_stream
396            .into_token_iter()
397            .parse::<CommaDelimitedVec<StructField>>()
398        {
399            for field_delimited in &fields.0 {
400                let field = &field_delimited.value;
401                if let Some(content) = extract_doc_content(&field.attributes) {
402                    let path = build_path(
403                        base_path,
404                        &context,
405                        &format!("{}/{}", struct_name, field.name),
406                    );
407                    extractions.push(DocExtraction::new(
408                        PathBuf::from(path),
409                        content,
410                        format!(
411                            "{}:{}",
412                            source_file.display(),
413                            field.name.span().start().line
414                        ),
415                    ));
416                }
417            }
418        }
419    }
420
421    extractions
422}
423
424/// Writes documentation extractions to markdown files
425///
426/// If `dry_run` is true, validates paths and reports what would be written
427/// without actually creating files.
428pub fn write_extractions(
429    extractions: &[DocExtraction],
430    dry_run: bool,
431) -> std::io::Result<WriteReport> {
432    let mut report = WriteReport::default();
433
434    // Group by parent directory for efficient directory creation
435    let mut dirs: HashMap<PathBuf, Vec<&DocExtraction>> = HashMap::new();
436    for extraction in extractions {
437        if let Some(parent) = extraction.markdown_path.parent() {
438            dirs.entry(parent.to_path_buf())
439                .or_default()
440                .push(extraction);
441        }
442    }
443
444    // Create directories
445    for dir in dirs.keys() {
446        if !dry_run {
447            if let Err(e) = fs::create_dir_all(dir) {
448                report.errors.push(format!(
449                    "Failed to create directory {}: {}",
450                    dir.display(),
451                    e
452                ));
453                continue;
454            }
455        }
456    }
457
458    // Write files
459    for extraction in extractions {
460        if dry_run {
461            println!("Would write: {}", extraction.markdown_path.display());
462            report.files_written += 1;
463        } else {
464            match fs::write(&extraction.markdown_path, &extraction.content) {
465                Ok(_) => {
466                    report.files_written += 1;
467                }
468                Err(e) => {
469                    report.errors.push(format!(
470                        "Failed to write {}: {}",
471                        extraction.markdown_path.display(),
472                        e
473                    ));
474                    report.files_skipped += 1;
475                }
476            }
477        }
478    }
479
480    Ok(report)
481}
482
483// Helper functions
484
485fn build_path(base_path: &str, context: &[String], item_name: &str) -> String {
486    let mut parts = vec![base_path.to_string()];
487    parts.extend(context.iter().cloned());
488    parts.push(format!("{}.md", item_name));
489    parts.join("/")
490}
491
492fn extract_brace_content(brace_group: &BraceGroup) -> TokenStream {
493    let mut ts = TokenStream::new();
494    unsynn::ToTokens::to_tokens(brace_group, &mut ts);
495    if let Some(proc_macro2::TokenTree::Group(g)) = ts.into_iter().next() {
496        g.stream()
497    } else {
498        TokenStream::new()
499    }
500}
501
502#[cfg(test)]
503mod tests;