1use crate::utils::{contract_kind_str, page_path, relative_source_path};
10use path_slash::PathBufExt;
11use solar::{
12 ast::{DataLocation, FunctionKind, ItemKind, NatSpecItem, NatSpecKind, Visibility},
13 interface::{Span, source_map::FileName},
14 sema::{
15 Gcx,
16 hir::{ContractId, FunctionId, Item, ItemId, VariableId},
17 ty::{Ty, TyAbiPrinter, TyAbiPrinterMode},
18 },
19};
20use std::{
21 collections::{HashMap, HashSet, hash_map::Entry},
22 path::{Path, PathBuf},
23};
24use tracing::warn;
25
26#[derive(Debug, Default)]
31pub struct NameToPage {
32 by_name: HashMap<String, Vec<PathBuf>>,
33 by_contract: HashMap<ContractId, PathBuf>,
34}
35
36impl NameToPage {
37 pub fn new() -> Self {
38 Self::default()
39 }
40
41 pub fn get(&self, name: &str) -> Option<&Vec<PathBuf>> {
43 self.by_name.get(name)
44 }
45
46 pub fn get_contract(&self, id: ContractId) -> Option<&PathBuf> {
48 self.by_contract.get(&id)
49 }
50}
51
52pub fn build_name_to_page(
60 gcx: Gcx<'_>,
61 root: &Path,
62 allowed_sources: &HashSet<PathBuf>,
63) -> NameToPage {
64 let mut map = NameToPage::new();
65
66 let mut items = gcx
68 .hir
69 .item_ids()
70 .filter_map(|item_id| {
71 let item = gcx.hir.item(item_id);
72 let prefix = match item {
73 Item::Contract(c) => contract_kind_str(c.kind),
74 Item::Struct(_) => "struct",
75 Item::Enum(_) => "enum",
76 Item::Error(_) => "error",
77 Item::Event(_) => "event",
78 Item::Udvt(_) => "type",
79 Item::Function(_) | Item::Variable(_) => return None,
80 };
81 if item.contract().is_some() {
82 return None;
83 }
84 let FileName::Real(abs) = &gcx.hir.source(item.source()).file.name else {
85 return None;
86 };
87 let name = item.name()?;
88 allowed_sources
89 .contains(abs)
90 .then(|| (relative_source_path(root, abs), name, prefix, item_id))
91 })
92 .collect::<Vec<_>>();
93 items.sort_by(|(a, an, _, _), (b, bn, _, _)| {
94 a.to_string_lossy().cmp(&b.to_string_lossy()).then_with(|| an.as_str().cmp(bn.as_str()))
95 });
96
97 for (rel, name, prefix, item_id) in items {
98 let page = page_path(&rel, prefix, name.as_str());
99 let name_str = name.as_str().to_string();
100 let entry = map.by_name.entry(name_str.clone()).or_default();
101 if !entry.is_empty() {
102 warn!(
103 "forge doc: duplicate top-level name `{name_str}`; \
104 cross-reference `{{{name_str}}}` will resolve by proximity to the referencing page"
105 );
106 }
107 entry.push(page.clone());
108
109 if let ItemId::Contract(cid) = item_id {
112 map.by_contract.insert(cid, page);
113 }
114 }
115
116 map
117}
118
119fn resolve_page<'a>(candidates: &'a [PathBuf], current_page: &Path) -> &'a PathBuf {
126 if candidates.len() == 1 {
127 return &candidates[0];
128 }
129 let current_dir = current_page.parent().unwrap_or(Path::new(""));
130 candidates
131 .iter()
132 .max_by_key(|page| {
133 let page_dir = page.parent().unwrap_or(Path::new(""));
134 current_dir.components().zip(page_dir.components()).take_while(|(a, b)| a == b).count()
135 })
136 .unwrap_or(&candidates[0])
137}
138
139pub fn inheritance_links(
145 gcx: Gcx<'_>,
146 contract_id: ContractId,
147 name_to_page: &NameToPage,
148 current_page: &Path,
149) -> Option<String> {
150 let contract = gcx.hir.contract(contract_id);
151 if contract.bases.is_empty() {
152 return None;
153 }
154
155 let parts: Vec<String> = contract
156 .bases
157 .iter()
158 .map(|&base_id| {
159 let base = gcx.hir.contract(base_id);
160 let name = base.name.as_str();
161 if let Some(page) = name_to_page.get_contract(base_id) {
164 let link = page_link(page, current_page);
165 format!("[{name}]({link})")
166 } else if let Some(candidates) = name_to_page.get(name) {
167 let page = resolve_page(candidates, current_page);
168 let link = page_link(page, current_page);
169 format!("[{name}]({link})")
170 } else {
171 name.to_string()
172 }
173 })
174 .collect();
175
176 Some(format!("**Inherits:** {}", parts.join(", ")))
177}
178
179pub struct GetterField {
184 pub name: Option<String>,
185 pub ty: String,
186 pub description: String,
187}
188
189#[derive(Default)]
192pub struct NatSpecDoc {
193 pub notices: Vec<String>,
194 pub devs: Vec<String>,
195 pub params: Vec<String>,
196 pub returns: Vec<String>,
197 pub getter_params: Vec<GetterField>,
198 pub getter_returns: Vec<GetterField>,
199}
200
201pub fn natspec_doc(gcx: Gcx<'_>, item: ItemId, implicit: bool) -> Option<NatSpecDoc> {
203 let mut doc = effective_natspec_doc(gcx, item, implicit, &mut HashSet::new())?;
204 let callable = callable_function(gcx, item);
205 if let Some(fid) = callable
206 && matches!(item, ItemId::Variable(_))
207 {
208 let function = gcx.hir.function(fid);
209 doc.getter_params = function
210 .parameters
211 .iter()
212 .enumerate()
213 .map(|(index, ¶meter)| getter_field(gcx, parameter, &doc.params[index]))
214 .collect();
215 doc.getter_returns = function
216 .returns
217 .iter()
218 .enumerate()
219 .map(|(index, &return_)| getter_field(gcx, return_, &doc.returns[index]))
220 .collect();
221 }
222 Some(doc)
223}
224
225fn effective_natspec_doc(
226 gcx: Gcx<'_>,
227 item: ItemId,
228 implicit: bool,
229 visited: &mut HashSet<ItemId>,
230) -> Option<NatSpecDoc> {
231 if !visited.insert(item) {
232 return None;
233 }
234
235 let hir_item = gcx.hir.item(item);
236 let raw = gcx.hir.doc(hir_item.doc()).ast_comments();
237 let has_local = raw.iter().any(|comment| !comment.natspec.is_empty());
238 if !has_local {
239 if !implicit {
240 return Some(empty_natspec_doc(gcx, item));
241 }
242 let bases = direct_base_items(gcx, item);
243 let [base] = bases.as_slice() else { return None };
244 if !implicit_edge_compatible(gcx, item, *base)
245 || (!matches!(item, ItemId::Variable(_)) && !parameter_names_equal(gcx, item, *base))
246 {
247 return None;
248 }
249 return effective_natspec_doc(gcx, *base, true, visited);
250 }
251
252 let mut local = doc_from_view(gcx, item);
253 let mut inheritdoc = None;
254 let mut local_notice = false;
255 let mut local_dev = false;
256 let mut local_param = false;
257 let mut local_return = false;
258 let raw_items =
259 raw.iter().flat_map(|comment| comment.natspec.iter().copied()).collect::<Vec<_>>();
260 for entry in &raw_items {
261 match entry.kind {
262 NatSpecKind::Inheritdoc { contract } if inheritdoc.is_none() => {
263 inheritdoc = Some(contract)
264 }
265 NatSpecKind::Notice if continuation_parent(gcx, &raw_items, entry).is_none() => {
266 local_notice = true
267 }
268 NatSpecKind::Dev => local_dev = true,
269 NatSpecKind::Param { .. } => local_param = true,
270 NatSpecKind::Return { .. } => local_return = true,
271 _ => {}
272 }
273 }
274 let Some(alias) = inheritdoc else { return Some(local) };
275 let contract = gcx.natspec_contract(alias.name, hir_item.source())?;
276 let source = exact_override_item(gcx, item, contract, &mut HashSet::new())?;
277 let inherited = effective_natspec_doc(gcx, source, true, visited)?;
278
279 if !local_notice {
283 local.notices = inherited.notices;
284 }
285 if !local_dev {
286 local.devs = inherited.devs;
287 }
288 if !local_param {
289 for (description, inherited) in local.params.iter_mut().zip(inherited.params) {
290 *description = inherited;
291 }
292 }
293 if !local_return {
294 for (description, inherited) in local.returns.iter_mut().zip(inherited.returns) {
295 *description = inherited;
296 }
297 }
298 Some(local)
299}
300
301fn empty_natspec_doc(gcx: Gcx<'_>, item: ItemId) -> NatSpecDoc {
302 let (params, returns) = callable_function(gcx, item)
303 .map(|id| {
304 let function = gcx.hir.function(id);
305 (
306 vec![String::new(); function.parameters.len()],
307 vec![String::new(); function.returns.len()],
308 )
309 })
310 .unwrap_or_default();
311 NatSpecDoc { params, returns, ..Default::default() }
312}
313
314fn doc_from_view(gcx: Gcx<'_>, item: ItemId) -> NatSpecDoc {
315 let view = gcx.natspec_view(item);
316 let mut doc = empty_natspec_doc(gcx, item);
317 for natspec in view.items() {
318 if continuation_parent(gcx, view.items(), natspec).is_some() {
319 continue;
320 }
321 let content = positional_description(gcx, view.items(), std::slice::from_ref(natspec));
322 match natspec.kind {
323 NatSpecKind::Notice => doc.notices.push(content),
324 NatSpecKind::Dev => doc.devs.push(content),
325 _ => {}
326 }
327 }
328 if let Some(fid) = callable_function(gcx, item) {
329 let function = gcx.hir.function(fid);
330 for (index, param) in doc.params[..function.parameters.len()].iter_mut().enumerate() {
331 *param = positional_description(gcx, view.items(), view.parameter(index));
332 }
333 for (index, return_) in doc.returns[..function.returns.len()].iter_mut().enumerate() {
334 *return_ = positional_description(gcx, view.items(), view.return_(index));
335 }
336 }
337 doc
338}
339
340fn positional_description(
341 gcx: Gcx<'_>,
342 all_items: &[NatSpecItem],
343 items: &[NatSpecItem],
344) -> String {
345 items
346 .iter()
347 .map(|item| {
348 let mut content = normalized_natspec_content(gcx, item);
349 for continuation in all_items.iter().filter(|candidate| {
350 continuation_parent(gcx, all_items, candidate) == Some(Some(item.span))
351 }) {
352 content.push('\n');
353 content.push_str(&normalized_natspec_content(gcx, continuation));
354 }
355 content
356 })
357 .collect::<Vec<_>>()
358 .join("\n")
359}
360
361fn normalized_natspec_content(gcx: Gcx<'_>, item: &NatSpecItem) -> String {
362 let source_map = gcx.sess.source_map();
363 let snippet = source_map.span_to_snippet(item.span).ok();
364 if snippet.as_deref().is_some_and(|snippet| snippet.starts_with("///")) {
365 return item.content().trim().to_string();
366 }
367 if snippet.as_deref().is_some_and(|snippet| snippet.starts_with("/**")) {
368 return clean_block_doc_content(item.content()).trim().to_string();
369 }
370 item.content()
371 .lines()
372 .enumerate()
373 .map(
374 |(index, line)| {
375 if index == 0 { line.to_string() } else { clean_block_doc_content(line) }
376 },
377 )
378 .collect::<Vec<_>>()
379 .join("\n")
380 .trim()
381 .to_string()
382}
383
384fn continuation_parent(
388 gcx: Gcx<'_>,
389 all_items: &[NatSpecItem],
390 item: &NatSpecItem,
391) -> Option<Option<Span>> {
392 if !matches!(item.kind, NatSpecKind::Notice) {
393 return None;
394 }
395
396 let source_map = gcx.sess.source_map();
397 let snippet = source_map.span_to_snippet(item.span).ok()?;
398 let location = source_map.lookup_char_pos(item.span.lo());
399 if snippet.starts_with("/**") {
400 let index = all_items.iter().position(|candidate| candidate.span == item.span)?;
401 let previous = all_items.get(index.checked_sub(1)?)?;
402 if !matches!(
403 previous.kind,
404 NatSpecKind::Notice
405 | NatSpecKind::Dev
406 | NatSpecKind::Param { .. }
407 | NatSpecKind::Return { .. }
408 ) {
409 return None;
410 }
411 let previous_location = source_map.lookup_char_pos(previous.span.lo());
412 if location.line != previous_location.line + 1
413 || !std::sync::Arc::ptr_eq(&location.file, &previous_location.file)
414 {
415 return None;
416 }
417 return Some(continuation_parent(gcx, all_items, previous).unwrap_or(Some(previous.span)));
418 }
419 if !snippet.starts_with("///") {
420 return None;
421 }
422
423 let mut previous_line = location.line.checked_sub(2)?;
424 loop {
425 let line = location.file.get_line(previous_line)?.trim_start();
426 let content = line.strip_prefix("///")?;
427 if content.trim().is_empty() {
428 return None;
429 }
430 let Some(tag) = content.trim_start().strip_prefix('@') else {
431 previous_line = previous_line.checked_sub(1)?;
432 continue;
433 };
434 if !matches!(tag.split_whitespace().next(), Some("notice" | "dev" | "param" | "return")) {
435 return None;
436 }
437 return Some(
438 all_items
439 .iter()
440 .find(|candidate| {
441 let candidate_location = source_map.lookup_char_pos(candidate.span.lo());
442 candidate_location.line == previous_line + 1
443 && std::sync::Arc::ptr_eq(&candidate_location.file, &location.file)
444 })
445 .map(|parent| parent.span),
446 );
447 }
448}
449
450fn getter_field(gcx: Gcx<'_>, variable: VariableId, description: &str) -> GetterField {
451 GetterField {
452 name: gcx.hir.variable(variable).name.map(|name| name.as_str().to_string()),
453 ty: render_ty(gcx, gcx.type_of_item(variable.into())),
454 description: description.to_string(),
455 }
456}
457
458fn render_ty<'a>(gcx: Gcx<'a>, ty: Ty<'a>) -> String {
460 let mut out = String::new();
461 let _ = TyAbiPrinter::new(gcx, &mut out, TyAbiPrinterMode::Signature).print(ty);
462 out
463}
464
465fn function_source_param_types(gcx: Gcx<'_>, fid: FunctionId) -> Option<Vec<String>> {
467 let f = gcx.hir.function(fid);
468 let ast = gcx.sources.get(f.source)?.ast.as_ref()?;
469 let params = ast.items.iter().find_map(|item| match &item.kind {
470 ItemKind::Function(func) if item.span == f.span => Some(&func.header.parameters),
471 ItemKind::Contract(c) => c.body.iter().find_map(|member| match &member.kind {
472 ItemKind::Function(func) if member.span == f.span => Some(&func.header.parameters),
473 _ => None,
474 }),
475 _ => None,
476 })?;
477 let sm = gcx.sess.source_map();
478 Some(
479 params
480 .vars
481 .iter()
482 .map(|variable| {
483 sm.span_to_snippet(variable.ty.span).unwrap_or_default().trim().to_string()
484 })
485 .collect(),
486 )
487}
488
489fn normalize_sol_type(t: &str) -> String {
491 let bytes = t.as_bytes();
492 let len = bytes.len();
493 let mut out = String::with_capacity(len + 8);
494 let mut i = 0;
495 while i < len {
496 if bytes[i..].starts_with(b"uint") && !bytes.get(i + 4).is_some_and(u8::is_ascii_digit) {
497 out.push_str("uint256");
498 i += 4;
499 } else if bytes[i..].starts_with(b"int")
500 && !bytes.get(i + 3).is_some_and(u8::is_ascii_digit)
501 {
502 out.push_str("int256");
503 i += 3;
504 } else if let Some(ch) = t[i..].chars().next() {
505 out.push(ch);
506 i += ch.len_utf8();
507 } else {
508 break;
509 }
510 }
511 out
512}
513
514fn callable_function(gcx: Gcx<'_>, item: ItemId) -> Option<FunctionId> {
515 match item {
516 ItemId::Function(id) => Some(id),
517 ItemId::Variable(id) => gcx.hir.variable(id).getter,
518 _ => None,
519 }
520}
521
522fn exact_override_item(
523 gcx: Gcx<'_>,
524 item: ItemId,
525 owner: ContractId,
526 visited: &mut HashSet<ItemId>,
527) -> Option<ItemId> {
528 if !visited.insert(item) {
529 return None;
530 }
531 if gcx.hir.item(item).contract() == Some(owner) {
532 return Some(item);
533 }
534 for &base in gcx.base_override_items(item) {
535 if let Some(found) = exact_override_item(gcx, base, owner, visited) {
536 return Some(found);
537 }
538 }
539 None
540}
541
542fn direct_base_items(gcx: Gcx<'_>, item: ItemId) -> Vec<ItemId> {
543 let mut bases = Vec::new();
544 let mut visited = HashSet::new();
545 for &base in gcx.base_override_items(item) {
546 push_non_yul_base_items(gcx, base, &mut bases, &mut visited);
547 }
548 bases
549}
550
551fn push_non_yul_base_items(
552 gcx: Gcx<'_>,
553 item: ItemId,
554 bases: &mut Vec<ItemId>,
555 visited: &mut HashSet<ItemId>,
556) {
557 if !visited.insert(item) {
558 return;
559 }
560 if matches!(item, ItemId::Function(id) if gcx.hir.function(id).is_yul) {
561 for &base in gcx.base_override_items(item) {
562 push_non_yul_base_items(gcx, base, bases, visited);
563 }
564 } else {
565 bases.push(item);
566 }
567}
568
569fn parameter_names_equal(gcx: Gcx<'_>, target: ItemId, base: ItemId) -> bool {
570 let names = |item| {
571 callable_function(gcx, item).map(|id| {
572 gcx.hir
573 .function(id)
574 .parameters
575 .iter()
576 .map(|&id| gcx.hir.variable(id).name.map(|name| name.name))
577 .collect::<Vec<_>>()
578 })
579 };
580 names(target) == names(base)
581}
582
583fn implicit_edge_compatible(gcx: Gcx<'_>, target: ItemId, base: ItemId) -> bool {
584 let (Some(target_id), Some(base_id)) =
585 (callable_function(gcx, target), callable_function(gcx, base))
586 else {
587 return false;
588 };
589 let target_fn = gcx.hir.function(target_id);
590 let base_fn = gcx.hir.function(base_id);
591 if !base_fn.virtual_
592 || target_fn.visibility == Visibility::Private
593 || base_fn.visibility == Visibility::Private
594 || (target_fn.body.is_none() && base_fn.body.is_some())
595 {
596 return false;
597 }
598 let visibility_ok = if matches!(target, ItemId::Variable(_)) {
599 base_fn.visibility == Visibility::External
600 } else {
601 target_fn.visibility == base_fn.visibility
602 || (base_fn.visibility == Visibility::External
603 && target_fn.visibility == Visibility::Public)
604 };
605 let target_mutability = match target {
606 ItemId::Variable(id) if gcx.hir.variable(id).is_constant() => {
607 solar::ast::StateMutability::Pure
608 }
609 _ => target_fn.state_mutability,
610 };
611 let mutability_ok = target_mutability == base_fn.state_mutability
612 || matches!(
613 (target_mutability, base_fn.state_mutability),
614 (
615 solar::ast::StateMutability::Pure,
616 solar::ast::StateMutability::View | solar::ast::StateMutability::NonPayable
617 ) | (solar::ast::StateMutability::View, solar::ast::StateMutability::NonPayable)
618 );
619 if !visibility_ok || !mutability_ok {
620 return false;
621 }
622 let types_equal = |left: &[VariableId], right: &[VariableId], locations: bool| {
623 left.len() == right.len()
624 && left.iter().zip(right).all(|(&left, &right)| {
625 let left = gcx.type_of_item(left.into());
626 let right = gcx.type_of_item(right.into());
627 left.peel_refs() == right.peel_refs() && (!locations || left.loc() == right.loc())
628 })
629 };
630 let external_types_equal = |left: &[VariableId], right: &[VariableId]| {
631 let normalize = |location| match location {
632 Some(DataLocation::Calldata) => Some(DataLocation::Memory),
633 location => location,
634 };
635 left.len() == right.len()
636 && left.iter().zip(right).all(|(&left, &right)| {
637 let left_variable = gcx.hir.variable(left);
638 let right_variable = gcx.hir.variable(right);
639 let left = gcx.type_of_item(left.into());
640 let right = gcx.type_of_item(right.into());
641 let left_location = left_variable.data_location.or_else(|| left.loc());
642 let right_location = right_variable.data_location.or_else(|| right.loc());
643 left.peel_refs() == right.peel_refs()
644 && normalize(left_location) == normalize(right_location)
645 })
646 };
647 let target_returns =
648 gcx.type_of_item(target_id.into()).as_externally_callable_function(false, gcx).returns();
649 let base_returns =
650 gcx.type_of_item(base_id.into()).as_externally_callable_function(false, gcx).returns();
651 if target_returns != base_returns || !external_types_equal(target_fn.returns, base_fn.returns) {
652 return false;
653 }
654 if target_fn.kind == FunctionKind::Modifier {
655 return types_equal(target_fn.parameters, base_fn.parameters, false);
656 }
657 if base_fn.visibility == Visibility::External {
658 external_types_equal(target_fn.parameters, base_fn.parameters)
659 } else {
660 types_equal(target_fn.parameters, base_fn.parameters, true)
661 && types_equal(target_fn.returns, base_fn.returns, true)
662 }
663}
664
665pub(crate) fn clean_block_doc_content(raw: &str) -> String {
669 raw.lines()
670 .map(|line| {
671 let t = line.trim_start();
672 if let Some(rest) = t.strip_prefix('*') {
673 rest.strip_prefix(' ').unwrap_or(rest)
674 } else {
675 line
676 }
677 })
678 .collect::<Vec<_>>()
679 .join("\n")
680}
681
682#[derive(Debug)]
691pub struct LocalMembers {
692 name: String,
694 members: HashSet<String>,
696 anchors: HashSet<String>,
698 inherited: HashMap<String, Option<PathBuf>>,
700 inherited_contracts: HashMap<String, InheritedContract>,
702}
703
704#[derive(Debug)]
705enum InheritedContract {
706 Unique { id: ContractId, page: Option<PathBuf>, anchors: HashSet<String> },
707 Ambiguous,
708}
709
710fn insert_function_anchors(
712 gcx: Gcx<'_>,
713 id: FunctionId,
714 anchors: &mut HashSet<String>,
715) -> Option<String> {
716 let function = gcx.hir.function(id);
717 if function.is_yul || function.is_getter() {
718 return None;
719 }
720 let params = function_source_param_types(gcx, id)?;
721 let name = match function.kind {
722 FunctionKind::Constructor => "constructor".to_string(),
723 FunctionKind::Fallback => "fallback".to_string(),
724 FunctionKind::Receive => "receive".to_string(),
725 FunctionKind::Function | FunctionKind::Modifier => function.name?.as_str().to_string(),
726 };
727 anchors.insert(slug_anchor_segment(&name));
728 anchors.insert(function_signature_anchor(&name, ¶ms));
729 Some(name)
730}
731
732impl LocalMembers {
733 pub fn new(name: &str) -> Self {
735 Self {
736 name: name.to_string(),
737 members: HashSet::new(),
738 anchors: HashSet::new(),
739 inherited: HashMap::new(),
740 inherited_contracts: HashMap::new(),
741 }
742 }
743
744 pub fn for_contract(gcx: Gcx<'_>, contract_id: ContractId, name_to_page: &NameToPage) -> Self {
746 let contract = gcx.hir.contract(contract_id);
747 let mut this = Self::new(contract.name.as_str());
748
749 for &base_id in contract.linearized_bases.iter().filter(|&&id| id != contract_id) {
753 let base = gcx.hir.contract(base_id);
754 let page = name_to_page.get_contract(base_id).cloned();
755 let mut anchors = HashSet::new();
756
757 for &item_id in base.items {
758 let (name, is_inherited) = match item_id {
759 ItemId::Function(id) => {
760 let function = gcx.hir.function(id);
761 (
762 insert_function_anchors(gcx, id, &mut anchors),
763 function.visibility != Visibility::Private
764 && function.kind != FunctionKind::Constructor,
765 )
766 }
767 ItemId::Variable(id) => {
768 let variable = gcx.hir.variable(id);
769 (
770 variable.name.map(|name| name.as_str().to_string()),
771 variable.visibility != Some(Visibility::Private),
772 )
773 }
774 ItemId::Struct(id) => {
775 (Some(gcx.hir.strukt(id).name.as_str().to_string()), true)
776 }
777 ItemId::Enum(id) => (Some(gcx.hir.enumm(id).name.as_str().to_string()), true),
778 ItemId::Error(id) => (Some(gcx.hir.error(id).name.as_str().to_string()), true),
779 ItemId::Event(id) => (Some(gcx.hir.event(id).name.as_str().to_string()), true),
780 ItemId::Udvt(id) => (Some(gcx.hir.udvt(id).name.as_str().to_string()), true),
781 ItemId::Contract(_) => (None, false),
782 };
783 if let Some(name) = name {
784 anchors.insert(slug_anchor_segment(&name));
785 if is_inherited {
786 this.inherited.entry(name).or_insert_with(|| page.clone());
787 }
788 }
789 }
790
791 match this.inherited_contracts.entry(base.name.as_str().to_string()) {
792 Entry::Vacant(entry) => {
793 entry.insert(InheritedContract::Unique { id: base_id, page, anchors });
794 }
795 Entry::Occupied(mut entry) => {
796 if matches!(entry.get(), InheritedContract::Unique { id, .. } if *id != base_id)
797 {
798 entry.insert(InheritedContract::Ambiguous);
799 }
800 }
801 }
802 }
803
804 this
805 }
806
807 pub fn insert(&mut self, member: &str) {
809 self.members.insert(member.to_string());
810 self.anchors.insert(slug_anchor_segment(member));
811 }
812
813 pub fn insert_anchor(&mut self, anchor: String) {
815 self.anchors.insert(anchor);
816 }
817
818 fn member_anchor(&self, member: &str) -> Option<String> {
822 self.members.contains(member).then(|| slug_anchor_segment(member))
823 }
824
825 fn xref_member_anchor(&self, part: &str) -> Option<String> {
828 let anchor = xref_part_anchor(part);
829 self.anchors.contains(&anchor).then_some(anchor)
830 }
831
832 fn inherited_member_link(&self, member: &str, current_page: &Path) -> Option<Option<String>> {
837 let page = self.inherited.get(member)?;
838 Some(page.as_ref().map(|page| {
839 format!("{}#{}", page_link(page, current_page), slug_anchor_segment(member))
840 }))
841 }
842
843 fn inherited_contract_member_link(
848 &self,
849 contract: &str,
850 part: &str,
851 current_page: &Path,
852 ) -> Option<Option<String>> {
853 let base = self.inherited_contracts.get(contract)?;
854 let InheritedContract::Unique { page, anchors, .. } = base else {
855 return Some(None);
856 };
857 let anchor = xref_part_anchor(part);
858 Some(page.as_ref().and_then(|page| {
859 anchors.contains(&anchor).then(|| format!("{}#{anchor}", page_link(page, current_page)))
860 }))
861 }
862}
863
864fn escape_link_label(s: &str) -> String {
869 s.replace('{', "{").replace('<', "<").replace('[', "\\[").replace(']', "\\]")
870}
871
872pub fn replace_inline_links(
882 text: &str,
883 name_to_page: &NameToPage,
884 current_page: &Path,
885 local: Option<&LocalMembers>,
886) -> String {
887 let mut out = String::with_capacity(text.len());
888 let bytes = text.as_bytes();
889 let mut i = 0;
890
891 while i < bytes.len() {
892 if bytes[i] == b'{' {
893 if let Some((end, ident, part, label)) = parse_inline_link(&text[i..]) {
895 let lookup_name = ident.strip_prefix("xref-").unwrap_or(ident);
897 let lookup_name = if let Some(pos) = lookup_name.find('-') {
898 &lookup_name[..pos]
899 } else {
900 lookup_name
901 };
902
903 if let Some(local) = local {
907 let local_anchor = match part {
908 None => local.member_anchor(lookup_name).map(Some),
909 Some(member) if lookup_name == local.name => {
910 Some(local.xref_member_anchor(member))
911 }
912 Some(_) => None,
913 };
914 if let Some(anchor) = local_anchor {
915 if let Some(anchor) = anchor {
916 let default_display = match part {
917 Some(member) => format!("{lookup_name}.{member}"),
918 None => lookup_name.to_string(),
919 };
920 let display = escape_link_label(label.unwrap_or(&default_display));
921 out.push_str(&format!("[{display}](#{anchor})"));
922 } else {
923 let safe_name = lookup_name.replace('`', "'");
924 out.push_str(&format!("`{safe_name}`"));
925 }
926 i += end;
927 continue;
928 }
929
930 let inherited_link = match part {
931 None => local.inherited_member_link(lookup_name, current_page),
932 Some(member) => {
933 local.inherited_contract_member_link(lookup_name, member, current_page)
934 }
935 };
936 if let Some(link) = inherited_link {
937 if let Some(link) = link {
938 let default_display = match part {
939 Some(member) => format!("{lookup_name}.{member}"),
940 None => lookup_name.to_string(),
941 };
942 let display = escape_link_label(label.unwrap_or(&default_display));
943 out.push_str(&format!("[{display}]({link})"));
944 } else {
945 let safe_name = lookup_name.replace('`', "'");
946 out.push_str(&format!("`{safe_name}`"));
947 }
948 i += end;
949 continue;
950 }
951 }
952
953 if let Some(candidates) = name_to_page.get(lookup_name) {
954 let page = resolve_page(candidates, current_page);
955 let mut link = page_link(page, current_page);
956 if let Some(member) = part {
960 let safe_member = xref_part_anchor(member);
961 if !safe_member.is_empty() {
962 link.push('#');
963 link.push_str(&safe_member);
964 }
965 }
966 let default_display = if let Some(member) = part {
967 format!("{lookup_name}.{member}")
969 } else {
970 lookup_name.to_string()
971 };
972 let display = escape_link_label(label.unwrap_or(&default_display));
973 out.push_str(&format!("[{display}]({link})"));
974 i += end;
975 continue;
976 }
977
978 let safe_name = lookup_name.replace('`', "'");
981 out.push_str(&format!("`{safe_name}`"));
982 i += end;
983 continue;
984 }
985 out.push_str("{");
987 i += 1;
988 continue;
989 }
990
991 if bytes[i] == b'<' {
992 out.push_str("<");
997 i += 1;
998 continue;
999 }
1000
1001 let ch = text[i..].chars().next().unwrap();
1003 out.push(ch);
1004 i += ch.len_utf8();
1005 }
1006
1007 out
1008}
1009
1010pub(crate) fn function_signature_anchor(name: &str, params: &[String]) -> String {
1011 let mut anchor = slug_anchor_segment(name);
1012 for param in params {
1013 let param = slug_anchor_segment(&normalize_sol_type(param));
1014 if !param.is_empty() {
1015 anchor.push('-');
1016 anchor.push_str(¶m);
1017 }
1018 }
1019 anchor
1020}
1021
1022fn xref_part_anchor(part: &str) -> String {
1023 let mut pieces = part.split('-').filter(|piece| !piece.is_empty());
1024 let Some(member) = pieces.next() else {
1025 return String::new();
1026 };
1027 let params = pieces.map(|piece| piece.to_string()).collect::<Vec<_>>();
1028 function_signature_anchor(member, ¶ms)
1029}
1030
1031fn slug_anchor_segment(s: &str) -> String {
1032 let mut out = String::with_capacity(s.len());
1033 let mut last_was_dash = false;
1034
1035 for ch in s.chars().flat_map(char::to_lowercase) {
1036 if ch.is_ascii_alphanumeric() || ch == '_' {
1037 out.push(ch);
1038 last_was_dash = false;
1039 } else if ch != '$' && !last_was_dash && !out.is_empty() {
1040 out.push('-');
1041 last_was_dash = true;
1042 }
1043 }
1044
1045 if last_was_dash {
1046 out.pop();
1047 }
1048
1049 out
1050}
1051
1052fn parse_inline_link(s: &str) -> Option<(usize, &str, Option<&str>, Option<&str>)> {
1056 let s = s.strip_prefix('{')?;
1057 let close = s.find('}')?;
1058 let inner = &s[..close];
1059
1060 let (raw_ident, raw_part) = if let Some(rest) = inner.strip_prefix("xref-") {
1062 if let Some(dash) = rest.find('-') {
1063 (&inner[..("xref-".len() + dash)], Some(&rest[dash + 1..]))
1064 } else {
1065 (inner, None)
1066 }
1067 } else if let Some(dash) = inner.find('-') {
1068 let candidate_ident = &inner[..dash];
1069 let candidate_part = &inner[dash + 1..];
1070 if candidate_ident.chars().all(|c| c.is_alphanumeric() || c == '_')
1071 && !candidate_part.is_empty()
1072 {
1073 (candidate_ident, Some(candidate_part))
1074 } else {
1075 (inner, None)
1076 }
1077 } else {
1078 (inner, None)
1079 };
1080
1081 let mut consumed = 1 + close + 1; let rest = &s[close + 1..];
1085 let label = if rest.starts_with('[') {
1086 if let Some(end) = rest.find(']') {
1087 let lbl = &rest[1..end];
1088 consumed += end + 1;
1089 Some(lbl)
1090 } else {
1091 None
1092 }
1093 } else {
1094 None
1095 };
1096
1097 Some((consumed, raw_ident, raw_part, label))
1098}
1099
1100fn page_link(page: &Path, _current_page: &Path) -> String {
1107 let without_ext = page.with_extension("");
1109 format!("/{}", without_ext.to_slash_lossy())
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114 use super::*;
1115
1116 #[test]
1117 fn full_signature_xref_links_member_anchor() {
1118 let mut name_to_page = NameToPage::new();
1119 name_to_page
1120 .by_name
1121 .insert("ERC721".to_string(), vec![PathBuf::from("src/contract.ERC721.mdx")]);
1122
1123 let out = replace_inline_links(
1124 "See {xref-ERC721-_safeMint-address-uint256-}.",
1125 &name_to_page,
1126 Path::new("src/contract.Child.mdx"),
1127 None,
1128 );
1129
1130 assert_eq!(
1131 out,
1132 "See [ERC721._safeMint-address-uint256-](/src/contract.ERC721#_safemint-address-uint256)."
1133 );
1134 }
1135
1136 #[test]
1137 fn same_contract_member_links_anchor_only() {
1138 let name_to_page = NameToPage::new();
1139 let mut local = LocalMembers::new("ECDSA");
1140 local.insert("toEthSignedMessageHash");
1141 local.insert("tryRecover");
1142
1143 let out = replace_inline_links(
1145 "then calling {toEthSignedMessageHash} on it.",
1146 &name_to_page,
1147 Path::new("src/library.ECDSA.mdx"),
1148 Some(&local),
1149 );
1150 assert_eq!(out, "then calling [toEthSignedMessageHash](#toethsignedmessagehash) on it.");
1151
1152 let out = replace_inline_links(
1154 "Overload of {ECDSA-tryRecover} that ...",
1155 &name_to_page,
1156 Path::new("src/library.ECDSA.mdx"),
1157 Some(&local),
1158 );
1159 assert_eq!(out, "Overload of [ECDSA.tryRecover](#tryrecover) that ...");
1160
1161 let out = replace_inline_links(
1163 "See {unknownMember}.",
1164 &name_to_page,
1165 Path::new("src/library.ECDSA.mdx"),
1166 Some(&local),
1167 );
1168 assert_eq!(out, "See `unknownMember`.");
1169
1170 let out = replace_inline_links(
1172 "See {ECDSA-doesNotExist}.",
1173 &name_to_page,
1174 Path::new("src/library.ECDSA.mdx"),
1175 Some(&local),
1176 );
1177 assert_eq!(out, "See `ECDSA`.");
1178 }
1179
1180 #[test]
1181 fn local_member_wins_over_global_name() {
1182 let mut name_to_page = NameToPage::new();
1185 name_to_page
1186 .by_name
1187 .insert("transfer".to_string(), vec![PathBuf::from("src/other/contract.transfer.mdx")]);
1188 let mut local = LocalMembers::new("Token");
1189 local.insert("transfer");
1190
1191 let out = replace_inline_links(
1192 "Calls {transfer}.",
1193 &name_to_page,
1194 Path::new("src/contract.Token.mdx"),
1195 Some(&local),
1196 );
1197 assert_eq!(out, "Calls [transfer](#transfer).");
1198
1199 let out = replace_inline_links(
1201 "Calls {transfer}.",
1202 &name_to_page,
1203 Path::new("src/contract.Token.mdx"),
1204 None,
1205 );
1206 assert_eq!(out, "Calls [transfer](/src/other/contract.transfer).");
1207 }
1208}