ron2_doc/
link.rs

1//! Cross-reference link resolution for TypeRef.
2
3use ahash::{HashMap, HashMapExt};
4use ron2::schema::TypeKind;
5
6use crate::discovery::DiscoveredSchema;
7
8/// A resolver for TypeRef paths to markdown file paths.
9pub struct LinkResolver<'a> {
10    /// Map from TypeRef path to the discovered schema's type_path
11    type_ref_to_path: HashMap<&'a str, &'a str>,
12    /// Map from short name to type_path for fallback matching
13    short_name_to_path: HashMap<&'a str, &'a str>,
14    /// Map from short name to count of types with that name (for collision detection)
15    short_name_counts: HashMap<&'a str, usize>,
16    /// Base URL for links
17    base_url: Option<&'a str>,
18}
19
20impl<'a> LinkResolver<'a> {
21    /// Create a new link resolver from discovered schemas.
22    pub fn new(schemas: &'a [DiscoveredSchema], base_url: Option<&'a str>) -> Self {
23        let mut type_ref_to_path = HashMap::new();
24        let mut short_name_to_path = HashMap::new();
25        let mut short_name_counts: HashMap<&str, usize> = HashMap::new();
26
27        for schema in schemas {
28            // Map the full type path to itself
29            type_ref_to_path.insert(schema.type_path.as_str(), schema.type_path.as_str());
30
31            // Also map short name for fallback resolution and track collision counts
32            let short_name = type_path_short_name(&schema.type_path);
33            short_name_to_path.insert(short_name, schema.type_path.as_str());
34            *short_name_counts.entry(short_name).or_insert(0) += 1;
35
36            // Also check if any TypeRefs in this schema should map to discovered schemas
37            // This handles cases where TypeRef uses full path but file is at short path
38            collect_type_refs(&schema.schema.kind, &mut |type_ref| {
39                // Try to find a matching schema by short name
40                let ref_short_name = type_path_short_name(type_ref);
41                for s in schemas {
42                    if type_path_short_name(&s.type_path) == ref_short_name {
43                        type_ref_to_path.insert(type_ref, s.type_path.as_str());
44                        break;
45                    }
46                }
47            });
48        }
49
50        Self {
51            type_ref_to_path,
52            short_name_to_path,
53            short_name_counts,
54            base_url,
55        }
56    }
57
58    /// Check if a short name has collisions (multiple types with the same short name).
59    pub fn has_collision(&self, short_name: &str) -> bool {
60        self.short_name_counts
61            .get(short_name)
62            .is_some_and(|c| *c > 1)
63    }
64
65    /// Get the display name for a type path.
66    /// Returns the short name if unambiguous, otherwise the qualified path.
67    pub fn display_name(&self, type_path: &str) -> String {
68        let short_name = type_path_short_name(type_path);
69        if self.has_collision(short_name) {
70            type_path.to_string()
71        } else {
72            short_name.to_string()
73        }
74    }
75
76    /// Convert a type path to an anchor ID for single-page mode.
77    /// Uses the full path to guarantee uniqueness.
78    pub fn type_path_to_anchor(type_path: &str) -> String {
79        type_path.replace("::", "-").to_lowercase()
80    }
81
82    /// Resolve a TypeRef path to a markdown link path.
83    fn resolve_type_ref(&self, type_ref: &str) -> String {
84        // First try to find in our direct map
85        if let Some(resolved_path) = self.type_ref_to_path.get(type_ref) {
86            return type_path_to_md_path(resolved_path);
87        }
88
89        // Try short name fallback
90        let short_name = type_path_short_name(type_ref);
91        if let Some(resolved_path) = self.short_name_to_path.get(short_name) {
92            return type_path_to_md_path(resolved_path);
93        }
94
95        // Fall back to converting the TypeRef directly
96        type_path_to_md_path(type_ref)
97    }
98
99    /// Format a TypeKind as a markdown string with links for TypeRefs.
100    pub fn type_to_markdown(&self, type_kind: &TypeKind) -> String {
101        match type_kind {
102            // Primitives - just inline code
103            TypeKind::Bool => "`bool`".to_string(),
104            TypeKind::I8 => "`i8`".to_string(),
105            TypeKind::I16 => "`i16`".to_string(),
106            TypeKind::I32 => "`i32`".to_string(),
107            TypeKind::I64 => "`i64`".to_string(),
108            TypeKind::I128 => "`i128`".to_string(),
109            TypeKind::U8 => "`u8`".to_string(),
110            TypeKind::U16 => "`u16`".to_string(),
111            TypeKind::U32 => "`u32`".to_string(),
112            TypeKind::U64 => "`u64`".to_string(),
113            TypeKind::U128 => "`u128`".to_string(),
114            TypeKind::F32 => "`f32`".to_string(),
115            TypeKind::F64 => "`f64`".to_string(),
116            TypeKind::Char => "`char`".to_string(),
117            TypeKind::String => "`String`".to_string(),
118            TypeKind::Unit => "`()`".to_string(),
119
120            // Compound types - format with inner type links
121            TypeKind::Option(inner) => {
122                format!("Option<{}>", self.type_to_markdown(inner))
123            }
124            TypeKind::List(inner) => {
125                format!("List<{}>", self.type_to_markdown(inner))
126            }
127            TypeKind::Map { key, value } => {
128                format!(
129                    "Map<{}, {}>",
130                    self.type_to_markdown(key),
131                    self.type_to_markdown(value)
132                )
133            }
134            TypeKind::Tuple(types) => {
135                let items: Vec<_> = types.iter().map(|t| self.type_to_markdown(t)).collect();
136                format!("({})", items.join(", "))
137            }
138
139            // TypeRef - create a link
140            TypeKind::TypeRef(path) => {
141                let display_name = type_path_short_name(path);
142                let md_path = self.resolve_type_ref(path);
143
144                if let Some(base) = self.base_url {
145                    format!(
146                        "[`{}`]({}/{})",
147                        display_name,
148                        base.trim_end_matches('/'),
149                        md_path
150                    )
151                } else {
152                    format!("[`{}`](./{})", display_name, md_path)
153                }
154            }
155
156            // Struct and Enum inline - just show the kind
157            TypeKind::Struct { .. } => "`struct`".to_string(),
158            TypeKind::Enum { .. } => "`enum`".to_string(),
159        }
160    }
161
162    /// Format a TypeKind as markdown with anchor links for single-page mode.
163    pub fn type_to_markdown_anchor(&self, type_kind: &TypeKind) -> String {
164        match type_kind {
165            // Primitives - just inline code
166            TypeKind::Bool => "`bool`".to_string(),
167            TypeKind::I8 => "`i8`".to_string(),
168            TypeKind::I16 => "`i16`".to_string(),
169            TypeKind::I32 => "`i32`".to_string(),
170            TypeKind::I64 => "`i64`".to_string(),
171            TypeKind::I128 => "`i128`".to_string(),
172            TypeKind::U8 => "`u8`".to_string(),
173            TypeKind::U16 => "`u16`".to_string(),
174            TypeKind::U32 => "`u32`".to_string(),
175            TypeKind::U64 => "`u64`".to_string(),
176            TypeKind::U128 => "`u128`".to_string(),
177            TypeKind::F32 => "`f32`".to_string(),
178            TypeKind::F64 => "`f64`".to_string(),
179            TypeKind::Char => "`char`".to_string(),
180            TypeKind::String => "`String`".to_string(),
181            TypeKind::Unit => "`()`".to_string(),
182
183            // Compound types - format with inner type links
184            TypeKind::Option(inner) => {
185                format!("Option<{}>", self.type_to_markdown_anchor(inner))
186            }
187            TypeKind::List(inner) => {
188                format!("List<{}>", self.type_to_markdown_anchor(inner))
189            }
190            TypeKind::Map { key, value } => {
191                format!(
192                    "Map<{}, {}>",
193                    self.type_to_markdown_anchor(key),
194                    self.type_to_markdown_anchor(value)
195                )
196            }
197            TypeKind::Tuple(types) => {
198                let items: Vec<_> = types
199                    .iter()
200                    .map(|t| self.type_to_markdown_anchor(t))
201                    .collect();
202                format!("({})", items.join(", "))
203            }
204
205            // TypeRef - create an anchor link
206            TypeKind::TypeRef(path) => {
207                let resolved = self.resolve_type_ref_path(path);
208                let display = self.display_name(&resolved);
209                let anchor = Self::type_path_to_anchor(&resolved);
210                format!("[`{}`](#{})", display, anchor)
211            }
212
213            // Struct and Enum inline - just show the kind
214            TypeKind::Struct { .. } => "`struct`".to_string(),
215            TypeKind::Enum { .. } => "`enum`".to_string(),
216        }
217    }
218
219    /// Resolve a TypeRef path to the actual type_path (for use in anchors).
220    fn resolve_type_ref_path(&self, type_ref: &str) -> String {
221        // First try to find in our direct map
222        if let Some(resolved_path) = self.type_ref_to_path.get(type_ref) {
223            return (*resolved_path).to_string();
224        }
225
226        // Try short name fallback
227        let short_name = type_path_short_name(type_ref);
228        if let Some(resolved_path) = self.short_name_to_path.get(short_name) {
229            return (*resolved_path).to_string();
230        }
231
232        // Fall back to the original path
233        type_ref.to_string()
234    }
235}
236
237/// Collect all TypeRef paths from a TypeKind.
238fn collect_type_refs<'a>(kind: &'a TypeKind, collector: &mut impl FnMut(&'a str)) {
239    match kind {
240        TypeKind::TypeRef(path) => collector(path),
241        TypeKind::Option(inner) | TypeKind::List(inner) => collect_type_refs(inner, collector),
242        TypeKind::Map { key, value } => {
243            collect_type_refs(key, collector);
244            collect_type_refs(value, collector);
245        }
246        TypeKind::Tuple(types) => {
247            for t in types {
248                collect_type_refs(t, collector);
249            }
250        }
251        TypeKind::Struct { fields } => {
252            for f in fields {
253                collect_type_refs(&f.ty, collector);
254            }
255        }
256        TypeKind::Enum { variants } => {
257            for v in variants {
258                match &v.kind {
259                    ron2::schema::VariantKind::Tuple(types) => {
260                        for t in types {
261                            collect_type_refs(t, collector);
262                        }
263                    }
264                    ron2::schema::VariantKind::Struct(fields) => {
265                        for f in fields {
266                            collect_type_refs(&f.ty, collector);
267                        }
268                    }
269                    ron2::schema::VariantKind::Unit => {}
270                }
271            }
272        }
273        _ => {}
274    }
275}
276
277/// Convert a type path to a markdown file path.
278///
279/// Example: `my_crate::config::AppConfig` -> `my_crate/config/AppConfig.md`
280pub fn type_path_to_md_path(type_path: &str) -> String {
281    type_path.replace("::", "/") + ".md"
282}
283
284/// Get the short name from a type path.
285///
286/// Example: `my_crate::config::AppConfig` -> `AppConfig`
287pub fn type_path_short_name(type_path: &str) -> &str {
288    type_path.split("::").last().unwrap_or(type_path)
289}
290
291#[cfg(test)]
292mod tests {
293    use std::path::PathBuf;
294
295    use ron2::schema::Schema;
296
297    use super::*;
298
299    fn make_schema(type_path: &str) -> DiscoveredSchema {
300        DiscoveredSchema {
301            path: PathBuf::from("test.schema.ron"),
302            type_path: type_path.to_string(),
303            schema: Schema::new(TypeKind::Unit),
304        }
305    }
306
307    #[test]
308    fn test_link_resolver_primitives() {
309        let schemas = vec![make_schema("Config")];
310        let resolver = LinkResolver::new(&schemas, None);
311
312        assert_eq!(resolver.type_to_markdown(&TypeKind::Bool), "`bool`");
313        assert_eq!(resolver.type_to_markdown(&TypeKind::String), "`String`");
314        assert_eq!(resolver.type_to_markdown(&TypeKind::I32), "`i32`");
315    }
316
317    #[test]
318    fn test_link_resolver_compound() {
319        let schemas = vec![make_schema("Config")];
320        let resolver = LinkResolver::new(&schemas, None);
321
322        let option_string = TypeKind::Option(Box::new(TypeKind::String));
323        assert_eq!(
324            resolver.type_to_markdown(&option_string),
325            "Option<`String`>"
326        );
327
328        let list_i32 = TypeKind::List(Box::new(TypeKind::I32));
329        assert_eq!(resolver.type_to_markdown(&list_i32), "List<`i32`>");
330    }
331
332    #[test]
333    fn test_link_resolver_typeref_resolves_to_discovered() {
334        // Schema is discovered as "Config" (short path)
335        // but TypeRef uses "my_crate::Config" (full path)
336        let schemas = vec![make_schema("Config")];
337        let resolver = LinkResolver::new(&schemas, None);
338
339        let type_ref = TypeKind::TypeRef("my_crate::Config".to_string());
340        // Should resolve to Config.md, not my_crate/Config.md
341        assert_eq!(
342            resolver.type_to_markdown(&type_ref),
343            "[`Config`](./Config.md)"
344        );
345    }
346
347    #[test]
348    fn test_link_resolver_with_base_url() {
349        let schemas = vec![make_schema("Config")];
350        let resolver = LinkResolver::new(&schemas, Some("/docs/types"));
351
352        let type_ref = TypeKind::TypeRef("my_crate::Config".to_string());
353        assert_eq!(
354            resolver.type_to_markdown(&type_ref),
355            "[`Config`](/docs/types/Config.md)"
356        );
357    }
358
359    #[test]
360    fn test_type_path_to_md_path() {
361        assert_eq!(
362            type_path_to_md_path("my_crate::config::AppConfig"),
363            "my_crate/config/AppConfig.md"
364        );
365    }
366
367    #[test]
368    fn test_type_path_short_name() {
369        assert_eq!(
370            type_path_short_name("my_crate::config::AppConfig"),
371            "AppConfig"
372        );
373        assert_eq!(type_path_short_name("Config"), "Config");
374    }
375
376    #[test]
377    fn test_collision_detection_no_collision() {
378        let schemas = vec![
379            make_schema("crate_a::Config"),
380            make_schema("crate_b::Server"),
381        ];
382        let resolver = LinkResolver::new(&schemas, None);
383
384        assert!(!resolver.has_collision("Config"));
385        assert!(!resolver.has_collision("Server"));
386        assert_eq!(resolver.display_name("crate_a::Config"), "Config");
387        assert_eq!(resolver.display_name("crate_b::Server"), "Server");
388    }
389
390    #[test]
391    fn test_collision_detection_with_collision() {
392        let schemas = vec![
393            make_schema("crate_a::Config"),
394            make_schema("crate_b::Config"),
395        ];
396        let resolver = LinkResolver::new(&schemas, None);
397
398        assert!(resolver.has_collision("Config"));
399        // When collision exists, display_name returns full path
400        assert_eq!(resolver.display_name("crate_a::Config"), "crate_a::Config");
401        assert_eq!(resolver.display_name("crate_b::Config"), "crate_b::Config");
402    }
403
404    #[test]
405    fn test_type_path_to_anchor() {
406        assert_eq!(
407            LinkResolver::type_path_to_anchor("my_crate::config::AppConfig"),
408            "my_crate-config-appconfig"
409        );
410        assert_eq!(LinkResolver::type_path_to_anchor("Config"), "config");
411    }
412
413    #[test]
414    fn test_type_to_markdown_anchor() {
415        let schemas = vec![make_schema("Config")];
416        let resolver = LinkResolver::new(&schemas, None);
417
418        let type_ref = TypeKind::TypeRef("Config".to_string());
419        assert_eq!(
420            resolver.type_to_markdown_anchor(&type_ref),
421            "[`Config`](#config)"
422        );
423    }
424
425    #[test]
426    fn test_type_to_markdown_anchor_with_collision() {
427        let schemas = vec![
428            make_schema("crate_a::Config"),
429            make_schema("crate_b::Config"),
430        ];
431        let resolver = LinkResolver::new(&schemas, None);
432
433        let type_ref = TypeKind::TypeRef("crate_a::Config".to_string());
434        assert_eq!(
435            resolver.type_to_markdown_anchor(&type_ref),
436            "[`crate_a::Config`](#crate_a-config)"
437        );
438    }
439}