1use path_slash::PathBufExt;
10use solar::{
11 ast::{
12 CommentKind, ContractKind, DocComments, FunctionKind, ItemKind, NatSpecKind, Visibility,
13 },
14 interface::source_map::FileName,
15 sema::{
16 Gcx,
17 hir::{ContractId, FunctionId, ItemId, SourceId, VariableId},
18 ty::{TyAbiPrinter, TyAbiPrinterMode},
19 },
20};
21use std::{
22 collections::{HashMap, HashSet, hash_map::Entry},
23 path::{Path, PathBuf},
24};
25use tracing::warn;
26
27#[derive(Debug, Default)]
32pub struct NameToPage {
33 by_name: HashMap<String, Vec<PathBuf>>,
34 by_contract: HashMap<ContractId, PathBuf>,
35}
36
37impl NameToPage {
38 pub fn new() -> Self {
39 Self::default()
40 }
41
42 pub fn get(&self, name: &str) -> Option<&Vec<PathBuf>> {
44 self.by_name.get(name)
45 }
46
47 pub fn get_contract(&self, id: ContractId) -> Option<&PathBuf> {
49 self.by_contract.get(&id)
50 }
51}
52
53pub fn build_name_to_page(
61 gcx: Gcx<'_>,
62 root: &Path,
63 allowed_sources: &HashSet<PathBuf>,
64) -> NameToPage {
65 let mut map = NameToPage::new();
66
67 let mut item_ids: Vec<_> = gcx.hir.item_ids().collect();
70 item_ids.sort_by_key(|id| {
71 let (name, source) = match id {
72 ItemId::Contract(id) => {
73 let c = gcx.hir.contract(*id);
74 (c.name.as_str().to_string(), c.source)
75 }
76 ItemId::Struct(id) => {
77 let s = gcx.hir.strukt(*id);
78 (s.name.as_str().to_string(), s.source)
79 }
80 ItemId::Enum(id) => {
81 let e = gcx.hir.enumm(*id);
82 (e.name.as_str().to_string(), e.source)
83 }
84 ItemId::Error(id) => {
85 let e = gcx.hir.error(*id);
86 (e.name.as_str().to_string(), e.source)
87 }
88 ItemId::Event(id) => {
89 let e = gcx.hir.event(*id);
90 (e.name.as_str().to_string(), e.source)
91 }
92 ItemId::Udvt(id) => {
93 let u = gcx.hir.udvt(*id);
94 (u.name.as_str().to_string(), u.source)
95 }
96 ItemId::Function(_) | ItemId::Variable(_) => {
97 return (String::new(), String::new());
98 }
99 };
100 let path = source_paths(gcx, source, root)
101 .map(|(_, rel)| rel.to_string_lossy().into_owned())
102 .unwrap_or_default();
103 (path, name)
104 });
105
106 for item_id in item_ids {
107 let (name, source, contract, prefix) = match item_id {
108 ItemId::Contract(id) => {
109 let c = gcx.hir.contract(id);
110 let kind = match c.kind {
111 ContractKind::Contract => "contract",
112 ContractKind::AbstractContract => "abstract",
113 ContractKind::Interface => "interface",
114 ContractKind::Library => "library",
115 };
116 (c.name, c.source, None, kind)
117 }
118 ItemId::Struct(id) => {
119 let s = gcx.hir.strukt(id);
120 (s.name, s.source, s.contract, "struct")
121 }
122 ItemId::Enum(id) => {
123 let e = gcx.hir.enumm(id);
124 (e.name, e.source, e.contract, "enum")
125 }
126 ItemId::Error(id) => {
127 let e = gcx.hir.error(id);
128 (e.name, e.source, e.contract, "error")
129 }
130 ItemId::Event(id) => {
131 let e = gcx.hir.event(id);
132 (e.name, e.source, e.contract, "event")
133 }
134 ItemId::Udvt(id) => {
135 let u = gcx.hir.udvt(id);
136 (u.name, u.source, u.contract, "type")
137 }
138 ItemId::Function(_) | ItemId::Variable(_) => continue,
139 };
140
141 if contract.is_some() && !matches!(item_id, ItemId::Contract(_)) {
144 continue;
145 }
146
147 if let Some((abs, rel)) = source_paths(gcx, source, root) {
148 if !allowed_sources.contains(&abs) {
149 continue;
150 }
151 let out_dir = rel.parent().unwrap_or(Path::new("")).to_owned();
152 let page = out_dir.join(format!("{prefix}.{}.mdx", name.as_str()));
153 let name_str = name.as_str().to_string();
154 let entry = map.by_name.entry(name_str.clone()).or_default();
155 if !entry.is_empty() {
156 warn!(
157 "forge doc: duplicate top-level name `{name_str}`; \
158 cross-reference `{{{name_str}}}` will resolve by proximity to the referencing page"
159 );
160 }
161 entry.push(page.clone());
162
163 if let ItemId::Contract(cid) = item_id {
166 map.by_contract.insert(cid, page);
167 }
168 }
169 }
170
171 map
172}
173
174fn source_paths(gcx: Gcx<'_>, source_id: SourceId, root: &Path) -> Option<(PathBuf, PathBuf)> {
175 let file = &gcx.hir.source(source_id).file;
176 if let FileName::Real(p) = &file.name {
177 let rel = if let Ok(r) = p.strip_prefix(root) {
178 r.to_path_buf()
179 } else {
180 let comps: Vec<_> = p.components().collect();
183 let start = comps.len().saturating_sub(3);
184 let tail: PathBuf = comps[start..].iter().collect();
185 PathBuf::from("lib").join(tail)
186 };
187 Some((p.clone(), rel))
188 } else {
189 None
190 }
191}
192
193fn resolve_page<'a>(candidates: &'a [PathBuf], current_page: &Path) -> &'a PathBuf {
200 if candidates.len() == 1 {
201 return &candidates[0];
202 }
203 let current_dir = current_page.parent().unwrap_or(Path::new(""));
204 candidates
205 .iter()
206 .max_by_key(|page| {
207 let page_dir = page.parent().unwrap_or(Path::new(""));
208 current_dir.components().zip(page_dir.components()).take_while(|(a, b)| a == b).count()
209 })
210 .unwrap_or(&candidates[0])
211}
212
213pub fn inheritance_links(
219 gcx: Gcx<'_>,
220 contract_id: ContractId,
221 name_to_page: &NameToPage,
222 current_page: &Path,
223) -> Option<String> {
224 let contract = gcx.hir.contract(contract_id);
225 if contract.bases.is_empty() {
226 return None;
227 }
228
229 let parts: Vec<String> = contract
230 .bases
231 .iter()
232 .map(|&base_id| {
233 let base = gcx.hir.contract(base_id);
234 let name = base.name.as_str();
235 if let Some(page) = name_to_page.get_contract(base_id) {
238 let link = page_link(page, current_page);
239 format!("[{name}]({link})")
240 } else if let Some(candidates) = name_to_page.get(name) {
241 let page = resolve_page(candidates, current_page);
242 let link = page_link(page, current_page);
243 format!("[{name}]({link})")
244 } else {
245 name.to_string()
246 }
247 })
248 .collect();
249
250 Some(format!("**Inherits:** {}", parts.join(", ")))
251}
252
253pub struct InheritedDoc {
257 pub notices: Vec<String>,
258 pub devs: Vec<String>,
259 pub params: Vec<(String, String)>,
260 pub returns: Vec<(String, String)>,
261}
262
263pub fn resolve_inheritdoc(
271 gcx: Gcx<'_>,
272 contract_id: ContractId,
273 fn_name: &str,
274 base_name: &str,
275 param_types: Option<&[String]>,
276) -> Option<InheritedDoc> {
277 let contract = gcx.hir.contract(contract_id);
278
279 let base_id = contract
281 .linearized_bases
282 .iter()
283 .copied()
284 .find(|&bid| gcx.hir.contract(bid).name.as_str() == base_name)?;
285
286 let base_contract = gcx.hir.contract(base_id);
290
291 let search_contracts: Vec<ContractId> = std::iter::once(base_id)
292 .chain(base_contract.linearized_bases.iter().copied().filter(|&id| id != base_id))
293 .collect();
294
295 for search_id in &search_contracts {
296 let search_contract = gcx.hir.contract(*search_id);
297 let mut name_matches: Vec<FunctionId> = Vec::new();
298 for &item_id in search_contract.items {
299 if let ItemId::Function(fid) = item_id {
300 let f = gcx.hir.function(fid);
301 let matches = match f.kind {
302 FunctionKind::Constructor => fn_name == "constructor",
303 FunctionKind::Fallback => fn_name == "fallback",
304 FunctionKind::Receive => fn_name == "receive",
305 _ => f.name.map(|n| n.as_str() == fn_name).unwrap_or(false),
306 };
307 if matches {
308 name_matches.push(fid);
309 }
310 }
311 }
312 if name_matches.is_empty() {
313 continue;
314 }
315
316 if let Some(want) = param_types
318 && name_matches.len() > 1
319 {
320 for &fid in &name_matches {
323 if let Some(got) = function_param_types(gcx, fid)
324 && got.len() == want.len()
325 && got.iter().zip(want).all(|(a, b)| a == b)
326 && let Some(doc) = extract_inherited_doc(gcx, fid)
327 && (!doc.notices.is_empty()
328 || !doc.devs.is_empty()
329 || !doc.params.is_empty()
330 || !doc.returns.is_empty())
331 {
332 return Some(doc);
333 }
334 }
335 }
336
337 if name_matches.len() > 1 {
338 continue;
339 }
340
341 for &fid in &name_matches {
344 if let Some(doc) = extract_inherited_doc(gcx, fid)
345 && (!doc.notices.is_empty()
346 || !doc.devs.is_empty()
347 || !doc.params.is_empty()
348 || !doc.returns.is_empty())
349 {
350 return Some(doc);
351 }
352 }
353 }
354
355 None
356}
357
358pub fn resolve_inheritdoc_var(
362 gcx: Gcx<'_>,
363 contract_id: ContractId,
364 var_name: &str,
365 base_name: &str,
366) -> Option<InheritedDoc> {
367 let contract = gcx.hir.contract(contract_id);
368 let base_id = contract
369 .linearized_bases
370 .iter()
371 .copied()
372 .find(|&bid| gcx.hir.contract(bid).name.as_str() == base_name)?;
373
374 let base_contract = gcx.hir.contract(base_id);
375 let search_contracts: Vec<ContractId> = std::iter::once(base_id)
376 .chain(base_contract.linearized_bases.iter().copied().filter(|&id| id != base_id))
377 .collect();
378
379 for search_id in &search_contracts {
380 let search_contract = gcx.hir.contract(*search_id);
381 for &item_id in search_contract.items {
382 match item_id {
383 ItemId::Variable(vid) => {
384 let v = gcx.hir.variable(vid);
385 if v.name.map(|n| n.as_str() == var_name).unwrap_or(false)
386 && let Some(doc) = extract_inherited_doc_var(gcx, vid)
387 && (!doc.notices.is_empty() || !doc.devs.is_empty())
388 {
389 return Some(doc);
390 }
391 }
392 ItemId::Function(fid) => {
398 let f = gcx.hir.function(fid);
399 if f.name.map(|n| n.as_str() == var_name).unwrap_or(false)
400 && function_param_types(gcx, fid).map(|p| p.is_empty()).unwrap_or(false)
401 && let Some(doc) = extract_inherited_doc(gcx, fid)
402 && (!doc.notices.is_empty()
403 || !doc.devs.is_empty()
404 || !doc.returns.is_empty())
405 {
406 return Some(doc);
407 }
408 }
409 _ => {}
410 }
411 }
412 }
413 None
414}
415
416fn extract_inherited_doc_var(gcx: Gcx<'_>, vid: VariableId) -> Option<InheritedDoc> {
417 let v = gcx.hir.variable(vid);
418 let ast_source = gcx.sources.get(v.source)?;
419 let ast = ast_source.ast.as_ref()?;
420 let var_span = v.span;
421
422 let docs = ast.items.iter().find_map(|item| {
423 if item.span == var_span {
424 return Some(&item.docs);
425 }
426 if let ItemKind::Contract(c) = &item.kind {
427 for member in c.body.iter() {
428 if member.span == var_span {
429 return Some(&member.docs);
430 }
431 }
432 }
433 None
434 })?;
435
436 Some(collect_inherited_doc(docs))
437}
438
439pub(crate) fn function_param_types(gcx: Gcx<'_>, fid: FunctionId) -> Option<Vec<String>> {
444 let f = gcx.hir.function(fid);
445 let source_types = function_source_param_types(gcx, fid).map(|params| {
446 params.into_iter().map(|param| normalize_sol_type(¶m)).collect::<Vec<_>>()
447 });
448
449 f.parameters
450 .iter()
451 .enumerate()
452 .map(|(idx, ¶m)| {
453 let ty = gcx.type_of_item(param.into());
454 if ty.can_be_exported(gcx) {
455 let mut out = String::new();
456 TyAbiPrinter::new(gcx, &mut out, TyAbiPrinterMode::Signature)
457 .print(ty)
458 .expect("writing ABI signature type to a String cannot fail");
459 Some(out)
460 } else {
461 source_types.as_ref().and_then(|types| types.get(idx).cloned())
462 }
463 })
464 .collect()
465}
466
467fn function_source_param_types(gcx: Gcx<'_>, fid: FunctionId) -> Option<Vec<String>> {
469 let f = gcx.hir.function(fid);
470 let ast = gcx.sources.get(f.source)?.ast.as_ref()?;
471 let params = ast.items.iter().find_map(|item| match &item.kind {
472 ItemKind::Function(func) if item.span == f.span => Some(&func.header.parameters),
473 ItemKind::Contract(c) => c.body.iter().find_map(|member| match &member.kind {
474 ItemKind::Function(func) if member.span == f.span => Some(&func.header.parameters),
475 _ => None,
476 }),
477 _ => None,
478 })?;
479 let sm = gcx.sess.source_map();
480 Some(
481 params
482 .vars
483 .iter()
484 .map(|variable| {
485 sm.span_to_snippet(variable.ty.span).unwrap_or_default().trim().to_string()
486 })
487 .collect(),
488 )
489}
490
491fn normalize_sol_type(t: &str) -> String {
497 let bytes = t.as_bytes();
500 let len = bytes.len();
501 let mut out = String::with_capacity(len + 8);
502 let mut i = 0;
503 while i < len {
504 if bytes[i..].starts_with(b"uint")
507 && !bytes.get(i + 4).copied().map(|b| b.is_ascii_digit()).unwrap_or(false)
508 {
509 out.push_str("uint256");
510 i += 4;
511 } else if bytes[i..].starts_with(b"int")
512 && !bytes.get(i + 3).copied().map(|b| b.is_ascii_digit()).unwrap_or(false)
513 {
514 out.push_str("int256");
515 i += 3;
516 } else if let Some(ch) = t[i..].chars().next() {
517 out.push(ch);
518 i += ch.len_utf8();
519 } else {
520 break;
521 }
522 }
523 out
524}
525
526fn extract_inherited_doc(gcx: Gcx<'_>, fid: FunctionId) -> Option<InheritedDoc> {
527 let f = gcx.hir.function(fid);
531 let ast_source = gcx.sources.get(f.source)?;
532 let ast = ast_source.ast.as_ref()?;
533
534 let fn_span = f.span;
535 let docs = ast.items.iter().find_map(|item| {
537 if item.span == fn_span {
538 return Some(&item.docs);
539 }
540 if let solar::ast::ItemKind::Contract(c) = &item.kind {
542 for member in c.body.iter() {
543 if member.span == fn_span {
544 return Some(&member.docs);
545 }
546 }
547 }
548 None
549 })?;
550
551 Some(collect_inherited_doc(docs))
552}
553
554fn collect_inherited_doc(docs: &DocComments<'_>) -> InheritedDoc {
555 let mut result = InheritedDoc {
556 notices: Vec::new(),
557 devs: Vec::new(),
558 params: Vec::new(),
559 returns: Vec::new(),
560 };
561 let mut prev_doc_was_blank = false;
562 #[derive(Clone, Copy)]
563 enum LastSection {
564 Notice,
565 Dev,
566 Param,
567 Return,
568 }
569 let mut last_section: Option<LastSection> = None;
570
571 for doc in docs.iter() {
572 if doc.natspec.is_empty() {
573 prev_doc_was_blank = true;
574 continue;
575 }
576 for item in doc.natspec.iter() {
577 let raw = doc.natspec_content(item);
578 let raw: &str =
580 if doc.kind == CommentKind::Block { &clean_block_doc_content(raw) } else { raw };
581 let is_continuation = matches!(item.kind, NatSpecKind::Notice)
583 && raw.starts_with(|c: char| c.is_whitespace());
584 let content = raw.trim().to_string();
585 if content.is_empty() {
586 prev_doc_was_blank = true;
587 continue;
588 }
589 if is_continuation && !prev_doc_was_blank {
590 let last: Option<&mut String> = match last_section {
591 Some(LastSection::Notice) => result.notices.last_mut(),
592 Some(LastSection::Dev) => result.devs.last_mut(),
593 Some(LastSection::Param) => result.params.last_mut().map(|(_, d)| d),
594 Some(LastSection::Return) => result.returns.last_mut().map(|(_, d)| d),
595 None => None,
596 };
597 if let Some(last) = last {
598 last.push('\n');
599 last.push_str(&content);
600 continue;
601 }
602 }
603 prev_doc_was_blank = false;
604 match item.kind {
605 NatSpecKind::Notice => {
606 result.notices.push(content);
607 last_section = Some(LastSection::Notice);
608 }
609 NatSpecKind::Dev => {
610 result.devs.push(content);
611 last_section = Some(LastSection::Dev);
612 }
613 NatSpecKind::Param { name } => {
614 result.params.push((name.as_str().to_string(), content));
615 last_section = Some(LastSection::Param);
616 }
617 NatSpecKind::Return { name } => {
618 result.returns.push((
619 name.map(|name| name.as_str().to_string()).unwrap_or_default(),
620 content,
621 ));
622 last_section = Some(LastSection::Return);
623 }
624 _ => {}
625 }
626 }
627 }
628 result
629}
630
631pub(crate) fn clean_block_doc_content(raw: &str) -> String {
635 raw.lines()
636 .map(|line| {
637 let t = line.trim_start();
638 if let Some(rest) = t.strip_prefix('*') {
639 rest.strip_prefix(' ').unwrap_or(rest)
640 } else {
641 line
642 }
643 })
644 .collect::<Vec<_>>()
645 .join("\n")
646}
647
648#[derive(Debug)]
657pub struct LocalMembers {
658 name: String,
660 members: HashSet<String>,
662 anchors: HashSet<String>,
664 inherited: HashMap<String, Option<PathBuf>>,
666 inherited_contracts: HashMap<String, InheritedContract>,
668}
669
670#[derive(Debug)]
671enum InheritedContract {
672 Unique { id: ContractId, page: Option<PathBuf>, anchors: HashSet<String> },
673 Ambiguous,
674}
675
676fn insert_function_anchors(
678 gcx: Gcx<'_>,
679 id: FunctionId,
680 anchors: &mut HashSet<String>,
681) -> Option<String> {
682 let function = gcx.hir.function(id);
683 if function.is_yul || function.is_getter() {
684 return None;
685 }
686 let params = function_source_param_types(gcx, id)?;
687 let name = match function.kind {
688 FunctionKind::Constructor => "constructor".to_string(),
689 FunctionKind::Fallback => "fallback".to_string(),
690 FunctionKind::Receive => "receive".to_string(),
691 FunctionKind::Function | FunctionKind::Modifier => function.name?.as_str().to_string(),
692 };
693 anchors.insert(slug_anchor_segment(&name));
694 anchors.insert(function_signature_anchor(&name, ¶ms));
695 Some(name)
696}
697
698impl LocalMembers {
699 pub fn new(name: &str) -> Self {
701 Self {
702 name: name.to_string(),
703 members: HashSet::new(),
704 anchors: HashSet::new(),
705 inherited: HashMap::new(),
706 inherited_contracts: HashMap::new(),
707 }
708 }
709
710 pub fn for_contract(gcx: Gcx<'_>, contract_id: ContractId, name_to_page: &NameToPage) -> Self {
712 let contract = gcx.hir.contract(contract_id);
713 let mut this = Self::new(contract.name.as_str());
714
715 for &base_id in contract.linearized_bases.iter().filter(|&&id| id != contract_id) {
719 let base = gcx.hir.contract(base_id);
720 let page = name_to_page.get_contract(base_id).cloned();
721 let mut anchors = HashSet::new();
722
723 for &item_id in base.items {
724 let (name, is_inherited) = match item_id {
725 ItemId::Function(id) => {
726 let function = gcx.hir.function(id);
727 (
728 insert_function_anchors(gcx, id, &mut anchors),
729 function.visibility != Visibility::Private
730 && function.kind != FunctionKind::Constructor,
731 )
732 }
733 ItemId::Variable(id) => {
734 let variable = gcx.hir.variable(id);
735 (
736 variable.name.map(|name| name.as_str().to_string()),
737 variable.visibility != Some(Visibility::Private),
738 )
739 }
740 ItemId::Struct(id) => {
741 (Some(gcx.hir.strukt(id).name.as_str().to_string()), true)
742 }
743 ItemId::Enum(id) => (Some(gcx.hir.enumm(id).name.as_str().to_string()), true),
744 ItemId::Error(id) => (Some(gcx.hir.error(id).name.as_str().to_string()), true),
745 ItemId::Event(id) => (Some(gcx.hir.event(id).name.as_str().to_string()), true),
746 ItemId::Udvt(id) => (Some(gcx.hir.udvt(id).name.as_str().to_string()), true),
747 ItemId::Contract(_) => (None, false),
748 };
749 if let Some(name) = name {
750 anchors.insert(slug_anchor_segment(&name));
751 if is_inherited {
752 this.inherited.entry(name).or_insert_with(|| page.clone());
753 }
754 }
755 }
756
757 match this.inherited_contracts.entry(base.name.as_str().to_string()) {
758 Entry::Vacant(entry) => {
759 entry.insert(InheritedContract::Unique { id: base_id, page, anchors });
760 }
761 Entry::Occupied(mut entry) => {
762 if matches!(entry.get(), InheritedContract::Unique { id, .. } if *id != base_id)
763 {
764 entry.insert(InheritedContract::Ambiguous);
765 }
766 }
767 }
768 }
769
770 this
771 }
772
773 pub fn insert(&mut self, member: &str) {
775 self.members.insert(member.to_string());
776 self.anchors.insert(slug_anchor_segment(member));
777 }
778
779 pub fn insert_anchor(&mut self, anchor: String) {
781 self.anchors.insert(anchor);
782 }
783
784 fn member_anchor(&self, member: &str) -> Option<String> {
788 self.members.contains(member).then(|| slug_anchor_segment(member))
789 }
790
791 fn xref_member_anchor(&self, part: &str) -> Option<String> {
794 let anchor = xref_part_anchor(part);
795 self.anchors.contains(&anchor).then_some(anchor)
796 }
797
798 fn inherited_member_link(&self, member: &str, current_page: &Path) -> Option<Option<String>> {
803 let page = self.inherited.get(member)?;
804 Some(page.as_ref().map(|page| {
805 format!("{}#{}", page_link(page, current_page), slug_anchor_segment(member))
806 }))
807 }
808
809 fn inherited_contract_member_link(
814 &self,
815 contract: &str,
816 part: &str,
817 current_page: &Path,
818 ) -> Option<Option<String>> {
819 let base = self.inherited_contracts.get(contract)?;
820 let InheritedContract::Unique { page, anchors, .. } = base else {
821 return Some(None);
822 };
823 let anchor = xref_part_anchor(part);
824 Some(page.as_ref().and_then(|page| {
825 anchors.contains(&anchor).then(|| format!("{}#{anchor}", page_link(page, current_page)))
826 }))
827 }
828}
829
830fn escape_link_label(s: &str) -> String {
835 s.replace('{', "{").replace('<', "<").replace('[', "\\[").replace(']', "\\]")
836}
837
838pub fn replace_inline_links(
848 text: &str,
849 name_to_page: &NameToPage,
850 current_page: &Path,
851 local: Option<&LocalMembers>,
852) -> String {
853 let mut out = String::with_capacity(text.len());
854 let bytes = text.as_bytes();
855 let mut i = 0;
856
857 while i < bytes.len() {
858 if bytes[i] == b'{' {
859 if let Some((end, ident, part, label)) = parse_inline_link(&text[i..]) {
861 let lookup_name = ident.strip_prefix("xref-").unwrap_or(ident);
863 let lookup_name = if let Some(pos) = lookup_name.find('-') {
864 &lookup_name[..pos]
865 } else {
866 lookup_name
867 };
868
869 if let Some(local) = local {
873 let local_anchor = match part {
874 None => local.member_anchor(lookup_name).map(Some),
875 Some(member) if lookup_name == local.name => {
876 Some(local.xref_member_anchor(member))
877 }
878 Some(_) => None,
879 };
880 if let Some(anchor) = local_anchor {
881 if let Some(anchor) = anchor {
882 let default_display = match part {
883 Some(member) => format!("{lookup_name}.{member}"),
884 None => lookup_name.to_string(),
885 };
886 let display = escape_link_label(label.unwrap_or(&default_display));
887 out.push_str(&format!("[{display}](#{anchor})"));
888 } else {
889 let safe_name = lookup_name.replace('`', "'");
890 out.push_str(&format!("`{safe_name}`"));
891 }
892 i += end;
893 continue;
894 }
895
896 let inherited_link = match part {
897 None => local.inherited_member_link(lookup_name, current_page),
898 Some(member) => {
899 local.inherited_contract_member_link(lookup_name, member, current_page)
900 }
901 };
902 if let Some(link) = inherited_link {
903 if let Some(link) = link {
904 let default_display = match part {
905 Some(member) => format!("{lookup_name}.{member}"),
906 None => lookup_name.to_string(),
907 };
908 let display = escape_link_label(label.unwrap_or(&default_display));
909 out.push_str(&format!("[{display}]({link})"));
910 } else {
911 let safe_name = lookup_name.replace('`', "'");
912 out.push_str(&format!("`{safe_name}`"));
913 }
914 i += end;
915 continue;
916 }
917 }
918
919 if let Some(candidates) = name_to_page.get(lookup_name) {
920 let page = resolve_page(candidates, current_page);
921 let mut link = page_link(page, current_page);
922 if let Some(member) = part {
926 let safe_member = xref_part_anchor(member);
927 if !safe_member.is_empty() {
928 link.push('#');
929 link.push_str(&safe_member);
930 }
931 }
932 let default_display = if let Some(member) = part {
933 format!("{lookup_name}.{member}")
935 } else {
936 lookup_name.to_string()
937 };
938 let display = escape_link_label(label.unwrap_or(&default_display));
939 out.push_str(&format!("[{display}]({link})"));
940 i += end;
941 continue;
942 }
943
944 let safe_name = lookup_name.replace('`', "'");
947 out.push_str(&format!("`{safe_name}`"));
948 i += end;
949 continue;
950 }
951 out.push_str("{");
953 i += 1;
954 continue;
955 }
956
957 if bytes[i] == b'<' {
958 out.push_str("<");
963 i += 1;
964 continue;
965 }
966
967 let ch = text[i..].chars().next().unwrap();
969 out.push(ch);
970 i += ch.len_utf8();
971 }
972
973 out
974}
975
976pub(crate) fn function_signature_anchor(name: &str, params: &[String]) -> String {
977 let mut anchor = slug_anchor_segment(name);
978 for param in params {
979 let param = slug_anchor_segment(&normalize_sol_type(param));
980 if !param.is_empty() {
981 anchor.push('-');
982 anchor.push_str(¶m);
983 }
984 }
985 anchor
986}
987
988fn xref_part_anchor(part: &str) -> String {
989 let mut pieces = part.split('-').filter(|piece| !piece.is_empty());
990 let Some(member) = pieces.next() else {
991 return String::new();
992 };
993 let params = pieces.map(|piece| piece.to_string()).collect::<Vec<_>>();
994 function_signature_anchor(member, ¶ms)
995}
996
997fn slug_anchor_segment(s: &str) -> String {
998 let mut out = String::with_capacity(s.len());
999 let mut last_was_dash = false;
1000
1001 for ch in s.chars().flat_map(char::to_lowercase) {
1002 if ch.is_ascii_alphanumeric() || ch == '_' {
1003 out.push(ch);
1004 last_was_dash = false;
1005 } else if ch != '$' && !last_was_dash && !out.is_empty() {
1006 out.push('-');
1007 last_was_dash = true;
1008 }
1009 }
1010
1011 if last_was_dash {
1012 out.pop();
1013 }
1014
1015 out
1016}
1017
1018fn parse_inline_link(s: &str) -> Option<(usize, &str, Option<&str>, Option<&str>)> {
1022 let s = s.strip_prefix('{')?;
1023 let close = s.find('}')?;
1024 let inner = &s[..close];
1025
1026 let (raw_ident, raw_part) = if let Some(rest) = inner.strip_prefix("xref-") {
1028 if let Some(dash) = rest.find('-') {
1029 (&inner[..("xref-".len() + dash)], Some(&rest[dash + 1..]))
1030 } else {
1031 (inner, None)
1032 }
1033 } else if let Some(dash) = inner.find('-') {
1034 let candidate_ident = &inner[..dash];
1035 let candidate_part = &inner[dash + 1..];
1036 if candidate_ident.chars().all(|c| c.is_alphanumeric() || c == '_')
1037 && !candidate_part.is_empty()
1038 {
1039 (candidate_ident, Some(candidate_part))
1040 } else {
1041 (inner, None)
1042 }
1043 } else {
1044 (inner, None)
1045 };
1046
1047 let mut consumed = 1 + close + 1; let rest = &s[close + 1..];
1051 let label = if rest.starts_with('[') {
1052 if let Some(end) = rest.find(']') {
1053 let lbl = &rest[1..end];
1054 consumed += end + 1;
1055 Some(lbl)
1056 } else {
1057 None
1058 }
1059 } else {
1060 None
1061 };
1062
1063 Some((consumed, raw_ident, raw_part, label))
1064}
1065
1066fn page_link(page: &Path, _current_page: &Path) -> String {
1073 let without_ext = page.with_extension("");
1075 format!("/{}", without_ext.to_slash_lossy())
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080 use super::*;
1081
1082 #[test]
1083 fn full_signature_xref_links_member_anchor() {
1084 let mut name_to_page = NameToPage::new();
1085 name_to_page
1086 .by_name
1087 .insert("ERC721".to_string(), vec![PathBuf::from("src/contract.ERC721.mdx")]);
1088
1089 let out = replace_inline_links(
1090 "See {xref-ERC721-_safeMint-address-uint256-}.",
1091 &name_to_page,
1092 Path::new("src/contract.Child.mdx"),
1093 None,
1094 );
1095
1096 assert_eq!(
1097 out,
1098 "See [ERC721._safeMint-address-uint256-](/src/contract.ERC721#_safemint-address-uint256)."
1099 );
1100 }
1101
1102 #[test]
1103 fn same_contract_member_links_anchor_only() {
1104 let name_to_page = NameToPage::new();
1105 let mut local = LocalMembers::new("ECDSA");
1106 local.insert("toEthSignedMessageHash");
1107 local.insert("tryRecover");
1108
1109 let out = replace_inline_links(
1111 "then calling {toEthSignedMessageHash} on it.",
1112 &name_to_page,
1113 Path::new("src/library.ECDSA.mdx"),
1114 Some(&local),
1115 );
1116 assert_eq!(out, "then calling [toEthSignedMessageHash](#toethsignedmessagehash) on it.");
1117
1118 let out = replace_inline_links(
1120 "Overload of {ECDSA-tryRecover} that ...",
1121 &name_to_page,
1122 Path::new("src/library.ECDSA.mdx"),
1123 Some(&local),
1124 );
1125 assert_eq!(out, "Overload of [ECDSA.tryRecover](#tryrecover) that ...");
1126
1127 let out = replace_inline_links(
1129 "See {unknownMember}.",
1130 &name_to_page,
1131 Path::new("src/library.ECDSA.mdx"),
1132 Some(&local),
1133 );
1134 assert_eq!(out, "See `unknownMember`.");
1135
1136 let out = replace_inline_links(
1138 "See {ECDSA-doesNotExist}.",
1139 &name_to_page,
1140 Path::new("src/library.ECDSA.mdx"),
1141 Some(&local),
1142 );
1143 assert_eq!(out, "See `ECDSA`.");
1144 }
1145
1146 #[test]
1147 fn local_member_wins_over_global_name() {
1148 let mut name_to_page = NameToPage::new();
1151 name_to_page
1152 .by_name
1153 .insert("transfer".to_string(), vec![PathBuf::from("src/other/contract.transfer.mdx")]);
1154 let mut local = LocalMembers::new("Token");
1155 local.insert("transfer");
1156
1157 let out = replace_inline_links(
1158 "Calls {transfer}.",
1159 &name_to_page,
1160 Path::new("src/contract.Token.mdx"),
1161 Some(&local),
1162 );
1163 assert_eq!(out, "Calls [transfer](#transfer).");
1164
1165 let out = replace_inline_links(
1167 "Calls {transfer}.",
1168 &name_to_page,
1169 Path::new("src/contract.Token.mdx"),
1170 None,
1171 );
1172 assert_eq!(out, "Calls [transfer](/src/other/contract.transfer).");
1173 }
1174}