Skip to main content

plissken_core/render/
crossref_renderer.rs

1//! Cross-reference link generation for Python-Rust documentation
2//!
3//! This module provides utilities for generating Markdown links between
4//! Python items and their Rust implementations, and vice versa.
5
6use crate::model::{CrossRef, CrossRefKind, RustItemRef};
7
8/// Generate a relative Markdown link from a Python page to a Rust item.
9///
10/// # Arguments
11///
12/// * `rust_ref` - Reference to the Rust item
13/// * `from_python_path` - The Python module path (e.g., "mypackage.submodule")
14///
15/// # Returns
16///
17/// A Markdown link string like `[RustStruct](../rust/crate/module.md#struct-ruststruct)`
18///
19/// # Example
20///
21/// ```rust
22/// use plissken_core::model::RustItemRef;
23/// use plissken_core::render::link_to_rust;
24///
25/// let rust_ref = RustItemRef::new("crate::utils", "Config");
26/// let link = link_to_rust(&rust_ref, "mypackage.config");
27/// assert!(link.contains("rust/crate/utils.md"));
28/// assert!(link.contains("#struct-config"));
29/// ```
30pub fn link_to_rust(rust_ref: &RustItemRef, from_python_path: &str) -> String {
31    let rust_page_path = rust_path_to_file_path(&rust_ref.path);
32    let anchor = item_to_anchor(&rust_ref.name, "struct");
33    // from_python_path is now python/module, rust is rust/module
34    let from_path = format!("python/{}", from_python_path.replace('.', "/"));
35    let relative_path = compute_relative_path(&from_path, &format!("rust/{}", rust_page_path));
36
37    format!("[{}]({}#{})", rust_ref.name, relative_path, anchor)
38}
39
40/// Generate a relative Markdown link from a Rust page to a Python item.
41///
42/// # Arguments
43///
44/// * `python_path` - Full Python path (e.g., "mypackage.module.ClassName")
45/// * `from_rust_path` - The Rust module path (e.g., "crate::utils")
46///
47/// # Returns
48///
49/// A Markdown link string like `[ClassName](../../mypackage/module.md#class-classname)`
50///
51/// # Example
52///
53/// ```rust
54/// use plissken_core::render::link_to_python;
55///
56/// let link = link_to_python("mypackage.utils.Config", "crate::utils");
57/// assert!(link.contains("mypackage/utils.md"));
58/// assert!(link.contains("#class-config"));
59/// ```
60pub fn link_to_python(python_path: &str, from_rust_path: &str) -> String {
61    let (module_path, item_name) = split_python_path(python_path);
62    let python_page_path = python_path_to_file_path(&module_path);
63    let rust_page_path = rust_path_to_file_path(from_rust_path);
64    // python_page_path now includes python/ prefix
65    let relative_path =
66        compute_relative_path(&format!("rust/{}", rust_page_path), &python_page_path);
67    let anchor = item_to_anchor(&item_name, "class");
68
69    format!("[{}]({}#{})", item_name, relative_path, anchor)
70}
71
72/// Generate a cross-reference link based on the CrossRef relationship.
73///
74/// Returns a tuple of (link_text, link_url, relationship_badge).
75///
76/// `from_path` should be the module path without python/ or rust/ prefix.
77pub fn crossref_link(xref: &CrossRef, from_path: &str, from_language: Language) -> CrossRefLink {
78    match from_language {
79        Language::Python => {
80            // Generate link from Python to Rust
81            let (module_path, item_name) = split_rust_path(&xref.rust_path);
82            let rust_page = rust_path_to_file_path(&module_path);
83            // from_path is Python module path, needs python/ prefix
84            let from_full = format!("python/{}", from_path.replace('.', "/"));
85            let relative = compute_relative_path(&from_full, &format!("rust/{}", rust_page));
86            let anchor = item_to_anchor(&item_name, "struct");
87
88            CrossRefLink {
89                text: item_name,
90                url: format!("{}#{}", relative, anchor),
91                relationship: xref.relationship.clone(),
92            }
93        }
94        Language::Rust => {
95            // Generate link from Rust to Python
96            let (module_path, item_name) = split_python_path(&xref.python_path);
97            let python_page = python_path_to_file_path(&module_path);
98            let rust_page = rust_path_to_file_path(from_path);
99            let relative = compute_relative_path(&format!("rust/{}", rust_page), &python_page);
100            let anchor = item_to_anchor(&item_name, "class");
101
102            CrossRefLink {
103                text: item_name,
104                url: format!("{}#{}", relative, anchor),
105                relationship: xref.relationship.clone(),
106            }
107        }
108    }
109}
110
111/// Represents a generated cross-reference link
112#[derive(Debug, Clone)]
113pub struct CrossRefLink {
114    /// Display text for the link
115    pub text: String,
116    /// Relative URL path
117    pub url: String,
118    /// Type of cross-reference relationship
119    pub relationship: CrossRefKind,
120}
121
122impl CrossRefLink {
123    /// Render as a Markdown link
124    pub fn to_markdown(&self) -> String {
125        format!("[{}]({})", self.text, self.url)
126    }
127
128    /// Render as a Markdown link with relationship indicator
129    pub fn to_markdown_with_badge(&self) -> String {
130        let indicator = match self.relationship {
131            CrossRefKind::Binding => "[binding]",
132            CrossRefKind::Wraps => "[wraps]",
133            CrossRefKind::Delegates => "[delegates]",
134        };
135        format!("{} [{}]({})", indicator, self.text, self.url)
136    }
137}
138
139/// Language enum for determining link direction
140#[derive(Debug, Clone, Copy, PartialEq)]
141pub enum Language {
142    Python,
143    Rust,
144}
145
146/// Render a collapsible details block with a link to the Rust implementation.
147///
148/// This is an enhanced version that includes an actual clickable link.
149pub fn render_rust_impl_details(rust_ref: &RustItemRef, from_python_path: &str) -> String {
150    let link = link_to_rust(rust_ref, from_python_path);
151
152    format!(
153        "<details>\n\
154         <summary>Rust Implementation</summary>\n\n\
155         Implemented by {} in `{}`\n\n\
156         </details>",
157        link, rust_ref.path
158    )
159}
160
161/// Render a collapsible details block with a link to the Python exposure.
162///
163/// For Rust items that are exposed to Python.
164pub fn render_python_exposure_details(python_path: &str, from_rust_path: &str) -> String {
165    let link = link_to_python(python_path, from_rust_path);
166
167    format!(
168        "<details>\n\
169         <summary>Python API</summary>\n\n\
170         Exposed as {} in `{}`\n\n\
171         </details>",
172        link, python_path
173    )
174}
175
176// =============================================================================
177// Path Utilities
178// =============================================================================
179
180/// Convert a Rust module path to a file path.
181///
182/// `crate::utils::helpers` -> `crate/utils/helpers.md`
183fn rust_path_to_file_path(rust_path: &str) -> String {
184    format!("{}.md", rust_path.replace("::", "/"))
185}
186
187/// Convert a Python module path to a file path.
188///
189/// `mypackage.utils.helpers` -> `python/mypackage/utils/helpers.md`
190fn python_path_to_file_path(python_path: &str) -> String {
191    format!("python/{}.md", python_path.replace('.', "/"))
192}
193
194/// Split a Python path into module path and item name.
195///
196/// `mypackage.utils.Config` -> ("mypackage.utils", "Config")
197fn split_python_path(path: &str) -> (String, String) {
198    if let Some(pos) = path.rfind('.') {
199        (path[..pos].to_string(), path[pos + 1..].to_string())
200    } else {
201        (path.to_string(), path.to_string())
202    }
203}
204
205/// Split a Rust path into module path and item name.
206///
207/// `crate::utils::Config` -> ("crate::utils", "Config")
208fn split_rust_path(path: &str) -> (String, String) {
209    if let Some(pos) = path.rfind("::") {
210        (path[..pos].to_string(), path[pos + 2..].to_string())
211    } else {
212        (path.to_string(), path.to_string())
213    }
214}
215
216/// Convert an item name to a Markdown anchor.
217///
218/// Uses the common Markdown anchor format: lowercase, spaces to hyphens.
219fn item_to_anchor(name: &str, item_type: &str) -> String {
220    format!("{}-{}", item_type, name.to_lowercase().replace(' ', "-"))
221}
222
223/// Compute relative path from one documentation page to another.
224///
225/// Both paths should be relative to the docs root.
226fn compute_relative_path(from_path: &str, to_path: &str) -> String {
227    // Count directory depth of from_path
228    let from_depth = from_path.matches('/').count();
229
230    // Build the relative prefix (../ for each directory level)
231    let prefix = if from_depth > 0 {
232        "../".repeat(from_depth)
233    } else {
234        "./".to_string()
235    };
236
237    format!("{}{}", prefix, to_path)
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use crate::model::RustItemRef;
244
245    #[test]
246    fn test_rust_path_to_file_path() {
247        assert_eq!(rust_path_to_file_path("crate::utils"), "crate/utils.md");
248        assert_eq!(
249            rust_path_to_file_path("crate::module::sub"),
250            "crate/module/sub.md"
251        );
252    }
253
254    #[test]
255    fn test_python_path_to_file_path() {
256        assert_eq!(
257            python_path_to_file_path("mypackage.utils"),
258            "python/mypackage/utils.md"
259        );
260        assert_eq!(
261            python_path_to_file_path("mypackage.sub.module"),
262            "python/mypackage/sub/module.md"
263        );
264    }
265
266    #[test]
267    fn test_split_python_path() {
268        let (module, item) = split_python_path("mypackage.utils.Config");
269        assert_eq!(module, "mypackage.utils");
270        assert_eq!(item, "Config");
271
272        let (module, item) = split_python_path("toplevel");
273        assert_eq!(module, "toplevel");
274        assert_eq!(item, "toplevel");
275    }
276
277    #[test]
278    fn test_split_rust_path() {
279        let (module, item) = split_rust_path("crate::utils::Config");
280        assert_eq!(module, "crate::utils");
281        assert_eq!(item, "Config");
282
283        let (module, item) = split_rust_path("single");
284        assert_eq!(module, "single");
285        assert_eq!(item, "single");
286    }
287
288    #[test]
289    fn test_item_to_anchor() {
290        assert_eq!(item_to_anchor("Config", "struct"), "struct-config");
291        assert_eq!(item_to_anchor("MyClass", "class"), "class-myclass");
292        assert_eq!(item_to_anchor("process_data", "fn"), "fn-process_data");
293    }
294
295    #[test]
296    fn test_compute_relative_path_same_level() {
297        let rel = compute_relative_path("module_a", "module_b.md");
298        assert_eq!(rel, "./module_b.md");
299    }
300
301    #[test]
302    fn test_compute_relative_path_one_level_deep() {
303        let rel = compute_relative_path("package/module", "rust/crate/utils.md");
304        assert_eq!(rel, "../rust/crate/utils.md");
305    }
306
307    #[test]
308    fn test_compute_relative_path_two_levels_deep() {
309        let rel = compute_relative_path("package/sub/module", "rust/crate/utils.md");
310        assert_eq!(rel, "../../rust/crate/utils.md");
311    }
312
313    #[test]
314    fn test_link_to_rust() {
315        let rust_ref = RustItemRef::new("crate::utils", "Config");
316        let link = link_to_rust(&rust_ref, "mypackage.config");
317
318        assert!(link.contains("[Config]"));
319        assert!(link.contains("rust/crate/utils.md"));
320        assert!(link.contains("#struct-config"));
321    }
322
323    #[test]
324    fn test_link_to_rust_nested_module() {
325        let rust_ref = RustItemRef::new("crate::module::sub", "Helper");
326        let link = link_to_rust(&rust_ref, "mypackage.helpers");
327
328        assert!(link.contains("[Helper]"));
329        assert!(link.contains("rust/crate/module/sub.md"));
330        assert!(link.contains("#struct-helper"));
331    }
332
333    #[test]
334    fn test_link_to_python() {
335        let link = link_to_python("mypackage.utils.Config", "crate::utils");
336
337        assert!(link.contains("[Config]"));
338        assert!(link.contains("mypackage/utils.md"));
339        assert!(link.contains("#class-config"));
340    }
341
342    #[test]
343    fn test_link_to_python_from_nested_rust() {
344        let link = link_to_python("mypackage.Config", "crate::module::sub");
345
346        assert!(link.contains("[Config]"));
347        assert!(link.contains("mypackage.md"));
348        // Should go up multiple levels from rust/crate/module/sub.md
349        assert!(link.contains("../"));
350    }
351
352    #[test]
353    fn test_crossref_link_from_python() {
354        let xref = CrossRef::binding("mypackage.Config", "crate::utils::Config");
355        let link = crossref_link(&xref, "mypackage", Language::Python);
356
357        assert_eq!(link.text, "Config");
358        assert!(link.url.contains("rust/crate/utils.md"));
359        assert!(matches!(link.relationship, CrossRefKind::Binding));
360    }
361
362    #[test]
363    fn test_crossref_link_from_rust() {
364        let xref = CrossRef::binding("mypackage.utils.Config", "crate::utils");
365        let link = crossref_link(&xref, "crate::utils", Language::Rust);
366
367        assert_eq!(link.text, "Config");
368        assert!(link.url.contains("mypackage/utils.md"));
369        assert!(matches!(link.relationship, CrossRefKind::Binding));
370    }
371
372    #[test]
373    fn test_crossref_link_markdown() {
374        let xref = CrossRef::binding("pkg.Class", "crate::Class");
375        let link = crossref_link(&xref, "pkg", Language::Python);
376
377        let md = link.to_markdown();
378        assert!(md.starts_with("[Class]("));
379        assert!(md.contains(".md#"));
380
381        let md_badge = link.to_markdown_with_badge();
382        assert!(md_badge.starts_with("[binding]"));
383    }
384
385    #[test]
386    fn test_crossref_link_wraps_relationship() {
387        let xref = CrossRef::wraps("pkg.Wrapper", "crate::Inner");
388        let link = crossref_link(&xref, "pkg", Language::Python);
389
390        assert!(matches!(link.relationship, CrossRefKind::Wraps));
391        let md_badge = link.to_markdown_with_badge();
392        assert!(md_badge.contains("[wraps]"));
393    }
394
395    #[test]
396    fn test_crossref_link_delegates_relationship() {
397        let xref = CrossRef::delegates("pkg.Client", "crate::http::Client");
398        let link = crossref_link(&xref, "pkg", Language::Python);
399
400        assert!(matches!(link.relationship, CrossRefKind::Delegates));
401        let md_badge = link.to_markdown_with_badge();
402        assert!(md_badge.contains("[delegates]"));
403    }
404
405    #[test]
406    fn test_render_rust_impl_details() {
407        let rust_ref = RustItemRef::new("crate::utils", "Config");
408        let details = render_rust_impl_details(&rust_ref, "mypackage.config");
409
410        assert!(details.contains("<details>"));
411        assert!(details.contains("Rust Implementation"));
412        assert!(details.contains("[Config]")); // Should be a link now
413        assert!(details.contains("rust/crate/utils.md"));
414        assert!(details.contains("</details>"));
415    }
416
417    #[test]
418    fn test_render_python_exposure_details() {
419        let details = render_python_exposure_details("mypackage.utils.Config", "crate::utils");
420
421        assert!(details.contains("<details>"));
422        assert!(details.contains("Python API"));
423        assert!(details.contains("[Config]")); // Should be a link
424        assert!(details.contains("mypackage/utils.md"));
425        assert!(details.contains("</details>"));
426    }
427
428    #[test]
429    fn test_handles_missing_crossref_gracefully() {
430        // When there's no cross-reference, we shouldn't crash
431        // This is tested by the fact that all functions take explicit references
432        // and None cases are handled at the call site
433        let rust_ref = RustItemRef::new("crate::standalone", "Foo");
434        let link = link_to_rust(&rust_ref, "module");
435        assert!(!link.is_empty());
436    }
437}