syncdoc_migrate/
write.rs

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