Skip to main content

switchback_traits/
link_context.rs

1//! Cross-reference index shared by renderers and link formatters.
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use crate::EntityRef;
7use crate::layout_paths::{
8    LayoutEntityKey, ProtobufEntityKind, heading_slug, layout_entity_rel_path, package_index_rel,
9    package_page_rel, relative_path_from_dir,
10};
11use crate::options::{Layout, Options};
12use crate::paths::{entity_category_dir, entity_rel_path};
13use crate::{EntityBody, ReferenceManual, StoredEntity};
14
15/// Entity output path index used by [`LinkFormatter`](crate::traits::LinkFormatter).
16#[derive(Clone, Debug, Default, PartialEq, Eq)]
17pub struct LinkContext {
18    /// Active page layout (controls relative path shape).
19    pub layout: Layout,
20    /// mdBook project root used when resolving relative links.
21    pub book_root: String,
22    /// Markdown output root relative to `book_root`.
23    pub markdown_root: String,
24    /// Resolved output path for each entity in the current render pass.
25    pub entity_paths: HashMap<EntityRef, PathBuf>,
26    /// Layout-aware protobuf entity paths (message/enum/service).
27    layout_entities: HashMap<LayoutEntityKey, PathBuf>,
28    /// Current page path used when formatting relative entity links.
29    pub render_from: Option<PathBuf>,
30}
31
32impl LinkContext {
33    /// Creates an empty context with layout and path roots but no entity entries.
34    pub fn empty(
35        layout: Layout,
36        book_root: impl Into<String>,
37        markdown_root: impl Into<String>,
38    ) -> Self {
39        Self {
40            layout,
41            book_root: book_root.into(),
42            markdown_root: markdown_root.into(),
43            entity_paths: HashMap::new(),
44            layout_entities: HashMap::new(),
45            render_from: None,
46        }
47    }
48
49    /// Builds a link context from a reference manual and render options.
50    pub fn from_manual(manual: &ReferenceManual, opts: &Options) -> Self {
51        let mut ctx = Self::empty(opts.layout, &opts.book_root, &opts.markdown_root);
52        for module in &manual.modules {
53            for contract in &module.contracts {
54                let is_protobuf = contract.family == "protobuf";
55                for group in &contract.groups {
56                    if group.id.as_str().is_empty() {
57                        continue;
58                    }
59                    for entity in &group.entities {
60                        ctx.register_stored_entity(
61                            module.id.as_str(),
62                            group.id.as_str(),
63                            &group.dir,
64                            entity,
65                            is_protobuf,
66                            opts.layout,
67                            &opts.markdown_root,
68                        );
69                    }
70                }
71            }
72        }
73        ctx
74    }
75
76    /// Registers one stored entity in the path index.
77    #[allow(clippy::too_many_arguments)]
78    pub fn register_stored_entity(
79        &mut self,
80        module: &str,
81        group: &str,
82        group_dir: &str,
83        entity: &StoredEntity,
84        is_protobuf: bool,
85        layout: Layout,
86        markdown_root: &str,
87    ) {
88        let entity_ref = EntityRef {
89            module: module.to_string(),
90            group: group.to_string(),
91            category: entity.category.clone(),
92            name: entity.name.clone(),
93        };
94
95        if is_protobuf {
96            if let Some(kind) = protobuf_entity_kind(entity) {
97                let key = LayoutEntityKey {
98                    package: group.to_string(),
99                    kind,
100                    name: entity.name.clone(),
101                };
102                let path = layout_entity_rel_path(layout, markdown_root, &key);
103                self.layout_entities.insert(key, path.clone());
104                self.entity_paths.insert(entity_ref, path);
105            }
106            return;
107        }
108
109        let rel = match layout {
110            Layout::Package => package_page_rel(markdown_root, group),
111            Layout::Entity | Layout::Split => {
112                let rel_path = entity_rel_path(
113                    group_dir,
114                    entity_category_dir(&entity.category),
115                    &entity.name,
116                );
117                PathBuf::from(format!("{markdown_root}/{rel_path}"))
118            }
119        };
120        self.entity_paths.insert(entity_ref, rel);
121    }
122
123    /// Iterate layout entity keys registered in this context.
124    pub fn layout_entity_keys(&self) -> impl Iterator<Item = &LayoutEntityKey> {
125        self.layout_entities.keys()
126    }
127
128    /// Relative path to a package rollup page.
129    pub fn package_page_rel(&self, package: &str) -> PathBuf {
130        package_page_rel(&self.markdown_root, package)
131    }
132
133    /// Relative path to a package index page.
134    pub fn package_index_rel(&self, package: &str) -> PathBuf {
135        package_index_rel(self.layout, &self.markdown_root, package)
136    }
137
138    /// Lookup layout entity path.
139    pub fn layout_entity_path(
140        &self,
141        package: &str,
142        kind: ProtobufEntityKind,
143        name: &str,
144    ) -> Option<&PathBuf> {
145        self.layout_entities.get(&LayoutEntityKey {
146            package: package.to_string(),
147            kind,
148            name: name.to_string(),
149        })
150    }
151
152    /// Lookup entity path by [`EntityRef`].
153    pub fn entity_path(&self, entity_ref: &EntityRef) -> Option<&PathBuf> {
154        self.entity_paths.get(entity_ref)
155    }
156
157    /// Format a markdown link to an entity from `from`.
158    pub fn link_from(
159        &self,
160        from: &Path,
161        package: &str,
162        kind: ProtobufEntityKind,
163        name: &str,
164    ) -> String {
165        let Some(target) = self.layout_entity_path(package, kind, name) else {
166            return format!("`.{package}.{name}`");
167        };
168        match self.layout {
169            Layout::Package => self.package_layout_link(from, target, name),
170            Layout::Entity | Layout::Split => self.file_link(from, target),
171        }
172    }
173
174    /// Format a markdown link to a stored entity from `from`.
175    pub fn link_entity(&self, from: &Path, entity_ref: &EntityRef) -> String {
176        let Some(target) = self.entity_paths.get(entity_ref) else {
177            return format!("`{}`", entity_ref.name);
178        };
179        match self.layout {
180            Layout::Package => self.package_layout_link(from, target, &entity_ref.name),
181            Layout::Entity | Layout::Split => self.file_link(from, target),
182        }
183    }
184
185    /// Format a markdown link for a protobuf FQN type reference.
186    pub fn link_type(&self, from: &Path, fqn: &str) -> String {
187        let Some((pkg, ident)) = split_proto_type_name(fqn) else {
188            return format!("`{fqn}`");
189        };
190        if self
191            .layout_entity_path(pkg, ProtobufEntityKind::Message, ident)
192            .is_some()
193        {
194            return self.link_from(from, pkg, ProtobufEntityKind::Message, ident);
195        }
196        if self
197            .layout_entity_path(pkg, ProtobufEntityKind::Enum, ident)
198            .is_some()
199        {
200            return self.link_from(from, pkg, ProtobufEntityKind::Enum, ident);
201        }
202        format!("`{fqn}`")
203    }
204
205    /// SUMMARY nav link from `from` to `target`.
206    pub fn summary_link(&self, from: &Path, target: &Path, title: &str) -> String {
207        let from_dir = from.parent().unwrap_or(Path::new(""));
208        let rel = relative_path_from_dir(from_dir, target);
209        format!("[{title}]({rel})")
210    }
211
212    fn file_link(&self, from: &Path, target: &Path) -> String {
213        let from_dir = from.parent().unwrap_or(Path::new(""));
214        let rel = relative_path_from_dir(from_dir, target);
215        let label = target.file_stem().unwrap_or_default().to_string_lossy();
216        format!("[{label}]({rel})")
217    }
218
219    fn package_layout_link(&self, from: &Path, target: &Path, name: &str) -> String {
220        if from == target {
221            format!("[{name}](#{})", heading_slug(name))
222        } else {
223            let from_dir = from.parent().unwrap_or(Path::new(""));
224            let rel = relative_path_from_dir(from_dir, target);
225            format!("[{name}]({rel}#{})", heading_slug(name))
226        }
227    }
228}
229
230fn protobuf_entity_kind(entity: &StoredEntity) -> Option<ProtobufEntityKind> {
231    match &entity.body {
232        EntityBody::Service(_) => Some(ProtobufEntityKind::Service),
233        EntityBody::Operation(_) => None,
234        EntityBody::Schema(body) => {
235            if body.fence_body.starts_with("enum ") {
236                Some(ProtobufEntityKind::Enum)
237            } else {
238                Some(ProtobufEntityKind::Message)
239            }
240        }
241        _ => None,
242    }
243}
244
245fn split_proto_type_name(fqn: &str) -> Option<(&str, &str)> {
246    let fqn = fqn.strip_prefix('.').unwrap_or(fqn);
247    let (pkg, name) = fqn.rsplit_once('.')?;
248    if pkg.is_empty() || name.is_empty() {
249        return None;
250    }
251    Some((pkg, name))
252}