1use path_slash::PathBufExt;
10use solar::{
11 ast::{
12 ContractKind, DataLocation, FunctionKind, ItemKind, NatSpecItem, NatSpecKind, Visibility,
13 },
14 interface::{Span, source_map::FileName},
15 sema::{
16 Gcx,
17 hir::{ContractId, FunctionId, ItemId, SourceId, VariableId},
18 ty::{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 GetterField {
258 pub name: Option<String>,
259 pub ty: String,
260 pub description: String,
261}
262
263pub struct NatSpecDoc {
266 pub notices: Vec<String>,
267 pub devs: Vec<String>,
268 pub params: Vec<String>,
269 pub returns: Vec<String>,
270 pub getter_params: Vec<GetterField>,
271 pub getter_returns: Vec<GetterField>,
272}
273
274pub fn natspec_doc(gcx: Gcx<'_>, item: ItemId, implicit: bool) -> Option<NatSpecDoc> {
276 let mut doc = effective_natspec_doc(gcx, item, implicit, &mut HashSet::new())?;
277 let callable = callable_function(gcx, item);
278 if let Some(fid) = callable
279 && matches!(item, ItemId::Variable(_))
280 {
281 let function = gcx.hir.function(fid);
282 doc.getter_params = function
283 .parameters
284 .iter()
285 .enumerate()
286 .map(|(index, ¶meter)| getter_field(gcx, parameter, &doc.params[index]))
287 .collect();
288 doc.getter_returns = function
289 .returns
290 .iter()
291 .enumerate()
292 .map(|(index, &return_)| getter_field(gcx, return_, &doc.returns[index]))
293 .collect();
294 }
295 Some(doc)
296}
297
298fn effective_natspec_doc(
299 gcx: Gcx<'_>,
300 item: ItemId,
301 implicit: bool,
302 visited: &mut HashSet<ItemId>,
303) -> Option<NatSpecDoc> {
304 if !visited.insert(item) {
305 return None;
306 }
307
308 let hir_item = gcx.hir.item(item);
309 let raw = gcx.hir.doc(hir_item.doc()).ast_comments();
310 let has_local = raw.iter().any(|comment| !comment.natspec.is_empty());
311 if !has_local {
312 if !implicit {
313 return Some(empty_natspec_doc(gcx, item));
314 }
315 let bases = direct_base_items(gcx, item);
316 let [base] = bases.as_slice() else { return None };
317 if !implicit_edge_compatible(gcx, item, *base)
318 || (!matches!(item, ItemId::Variable(_)) && !parameter_names_equal(gcx, item, *base))
319 {
320 return None;
321 }
322 return effective_natspec_doc(gcx, *base, true, visited);
323 }
324
325 let mut local = doc_from_view(gcx, item);
326 let mut inheritdoc = None;
327 let mut local_notice = false;
328 let mut local_dev = false;
329 let mut local_param = false;
330 let mut local_return = false;
331 let raw_items =
332 raw.iter().flat_map(|comment| comment.natspec.iter().copied()).collect::<Vec<_>>();
333 for entry in &raw_items {
334 match entry.kind {
335 NatSpecKind::Inheritdoc { contract } if inheritdoc.is_none() => {
336 inheritdoc = Some(contract)
337 }
338 NatSpecKind::Notice if continuation_parent(gcx, &raw_items, entry).is_none() => {
339 local_notice = true
340 }
341 NatSpecKind::Dev => local_dev = true,
342 NatSpecKind::Param { .. } => local_param = true,
343 NatSpecKind::Return { .. } => local_return = true,
344 _ => {}
345 }
346 }
347 let Some(alias) = inheritdoc else { return Some(local) };
348 let contract = gcx.natspec_contract(alias.name, hir_item.source())?;
349 let source = exact_override_item(gcx, item, contract, &mut HashSet::new())?;
350 let inherited = effective_natspec_doc(gcx, source, true, visited)?;
351
352 if !local_notice {
356 local.notices = inherited.notices;
357 }
358 if !local_dev {
359 local.devs = inherited.devs;
360 }
361 if !local_param {
362 for (description, inherited) in local.params.iter_mut().zip(inherited.params) {
363 *description = inherited;
364 }
365 }
366 if !local_return {
367 for (description, inherited) in local.returns.iter_mut().zip(inherited.returns) {
368 *description = inherited;
369 }
370 }
371 Some(local)
372}
373
374fn empty_natspec_doc(gcx: Gcx<'_>, item: ItemId) -> NatSpecDoc {
375 let (params, returns) = callable_function(gcx, item)
376 .map(|id| {
377 let function = gcx.hir.function(id);
378 (
379 vec![String::new(); function.parameters.len()],
380 vec![String::new(); function.returns.len()],
381 )
382 })
383 .unwrap_or_default();
384 NatSpecDoc {
385 notices: Vec::new(),
386 devs: Vec::new(),
387 params,
388 returns,
389 getter_params: Vec::new(),
390 getter_returns: Vec::new(),
391 }
392}
393
394fn doc_from_view(gcx: Gcx<'_>, item: ItemId) -> NatSpecDoc {
395 let view = gcx.natspec_view(item);
396 let mut doc = empty_natspec_doc(gcx, item);
397 for natspec in view.items() {
398 if continuation_parent(gcx, view.items(), natspec).is_some() {
399 continue;
400 }
401 let content = positional_description(gcx, view.items(), std::slice::from_ref(natspec));
402 match natspec.kind {
403 NatSpecKind::Notice => doc.notices.push(content),
404 NatSpecKind::Dev => doc.devs.push(content),
405 _ => {}
406 }
407 }
408 if let Some(fid) = callable_function(gcx, item) {
409 let function = gcx.hir.function(fid);
410 for (index, param) in doc.params[..function.parameters.len()].iter_mut().enumerate() {
411 *param = positional_description(gcx, view.items(), view.parameter(index));
412 }
413 for (index, return_) in doc.returns[..function.returns.len()].iter_mut().enumerate() {
414 *return_ = positional_description(gcx, view.items(), view.return_(index));
415 }
416 }
417 doc
418}
419
420fn positional_description(
421 gcx: Gcx<'_>,
422 all_items: &[NatSpecItem],
423 items: &[NatSpecItem],
424) -> String {
425 items
426 .iter()
427 .map(|item| {
428 let mut content = normalized_natspec_content(gcx, item);
429 for continuation in all_items.iter().filter(|candidate| {
430 continuation_parent(gcx, all_items, candidate) == Some(Some(item.span))
431 }) {
432 content.push('\n');
433 content.push_str(&normalized_natspec_content(gcx, continuation));
434 }
435 content
436 })
437 .collect::<Vec<_>>()
438 .join("\n")
439}
440
441fn normalized_natspec_content(gcx: Gcx<'_>, item: &NatSpecItem) -> String {
442 let source_map = gcx.sess.source_map();
443 let snippet = source_map.span_to_snippet(item.span).ok();
444 if snippet.as_deref().is_some_and(|snippet| snippet.starts_with("///")) {
445 return item.content().trim().to_string();
446 }
447 if snippet.as_deref().is_some_and(|snippet| snippet.starts_with("/**")) {
448 return clean_block_doc_content(item.content()).trim().to_string();
449 }
450 item.content()
451 .lines()
452 .enumerate()
453 .map(
454 |(index, line)| {
455 if index == 0 { line.to_string() } else { clean_block_doc_content(line) }
456 },
457 )
458 .collect::<Vec<_>>()
459 .join("\n")
460 .trim()
461 .to_string()
462}
463
464fn continuation_parent(
468 gcx: Gcx<'_>,
469 all_items: &[NatSpecItem],
470 item: &NatSpecItem,
471) -> Option<Option<Span>> {
472 if !matches!(item.kind, NatSpecKind::Notice) {
473 return None;
474 }
475
476 let source_map = gcx.sess.source_map();
477 let snippet = source_map.span_to_snippet(item.span).ok()?;
478 let location = source_map.lookup_char_pos(item.span.lo());
479 if snippet.starts_with("/**") {
480 let index = all_items.iter().position(|candidate| candidate.span == item.span)?;
481 let previous = all_items.get(index.checked_sub(1)?)?;
482 if !matches!(
483 previous.kind,
484 NatSpecKind::Notice
485 | NatSpecKind::Dev
486 | NatSpecKind::Param { .. }
487 | NatSpecKind::Return { .. }
488 ) {
489 return None;
490 }
491 let previous_location = source_map.lookup_char_pos(previous.span.lo());
492 if location.line != previous_location.line + 1
493 || !std::sync::Arc::ptr_eq(&location.file, &previous_location.file)
494 {
495 return None;
496 }
497 return Some(continuation_parent(gcx, all_items, previous).unwrap_or(Some(previous.span)));
498 }
499 if !snippet.starts_with("///") {
500 return None;
501 }
502
503 let mut previous_line = location.line.checked_sub(2)?;
504 loop {
505 let line = location.file.get_line(previous_line)?.trim_start();
506 let content = line.strip_prefix("///")?;
507 if content.trim().is_empty() {
508 return None;
509 }
510 let Some(tag) = content.trim_start().strip_prefix('@') else {
511 previous_line = previous_line.checked_sub(1)?;
512 continue;
513 };
514 if !matches!(tag.split_whitespace().next(), Some("notice" | "dev" | "param" | "return")) {
515 return None;
516 }
517 return Some(
518 all_items
519 .iter()
520 .find(|candidate| {
521 let candidate_location = source_map.lookup_char_pos(candidate.span.lo());
522 candidate_location.line == previous_line + 1
523 && std::sync::Arc::ptr_eq(&candidate_location.file, &location.file)
524 })
525 .map(|parent| parent.span),
526 );
527 }
528}
529
530fn getter_field(gcx: Gcx<'_>, variable: VariableId, description: &str) -> GetterField {
531 GetterField {
532 name: gcx.hir.variable(variable).name.map(|name| name.as_str().to_string()),
533 ty: render_ty(gcx, gcx.type_of_item(variable.into())),
534 description: description.to_string(),
535 }
536}
537
538fn render_ty<'a>(gcx: Gcx<'a>, ty: Ty<'a>) -> String {
540 let mut out = String::new();
541 let _ = TyAbiPrinter::new(gcx, &mut out, TyAbiPrinterMode::Signature).print(ty);
542 out
543}
544
545fn function_source_param_types(gcx: Gcx<'_>, fid: FunctionId) -> Option<Vec<String>> {
547 let f = gcx.hir.function(fid);
548 let ast = gcx.sources.get(f.source)?.ast.as_ref()?;
549 let params = ast.items.iter().find_map(|item| match &item.kind {
550 ItemKind::Function(func) if item.span == f.span => Some(&func.header.parameters),
551 ItemKind::Contract(c) => c.body.iter().find_map(|member| match &member.kind {
552 ItemKind::Function(func) if member.span == f.span => Some(&func.header.parameters),
553 _ => None,
554 }),
555 _ => None,
556 })?;
557 let sm = gcx.sess.source_map();
558 Some(
559 params
560 .vars
561 .iter()
562 .map(|variable| {
563 sm.span_to_snippet(variable.ty.span).unwrap_or_default().trim().to_string()
564 })
565 .collect(),
566 )
567}
568
569fn normalize_sol_type(t: &str) -> String {
571 let bytes = t.as_bytes();
572 let len = bytes.len();
573 let mut out = String::with_capacity(len + 8);
574 let mut i = 0;
575 while i < len {
576 if bytes[i..].starts_with(b"uint")
577 && !bytes.get(i + 4).copied().map(|b| b.is_ascii_digit()).unwrap_or(false)
578 {
579 out.push_str("uint256");
580 i += 4;
581 } else if bytes[i..].starts_with(b"int")
582 && !bytes.get(i + 3).copied().map(|b| b.is_ascii_digit()).unwrap_or(false)
583 {
584 out.push_str("int256");
585 i += 3;
586 } else if let Some(ch) = t[i..].chars().next() {
587 out.push(ch);
588 i += ch.len_utf8();
589 } else {
590 break;
591 }
592 }
593 out
594}
595
596fn callable_function(gcx: Gcx<'_>, item: ItemId) -> Option<FunctionId> {
597 match item {
598 ItemId::Function(id) => Some(id),
599 ItemId::Variable(id) => gcx.hir.variable(id).getter,
600 _ => None,
601 }
602}
603
604fn exact_override_item(
605 gcx: Gcx<'_>,
606 item: ItemId,
607 owner: ContractId,
608 visited: &mut HashSet<ItemId>,
609) -> Option<ItemId> {
610 if !visited.insert(item) {
611 return None;
612 }
613 if gcx.hir.item(item).contract() == Some(owner) {
614 return Some(item);
615 }
616 for &base in gcx.base_override_items(item) {
617 if let Some(found) = exact_override_item(gcx, base, owner, visited) {
618 return Some(found);
619 }
620 }
621 None
622}
623
624fn direct_base_items(gcx: Gcx<'_>, item: ItemId) -> Vec<ItemId> {
625 let mut bases = Vec::new();
626 let mut visited = HashSet::new();
627 for &base in gcx.base_override_items(item) {
628 push_non_yul_base_items(gcx, base, &mut bases, &mut visited);
629 }
630 bases
631}
632
633fn push_non_yul_base_items(
634 gcx: Gcx<'_>,
635 item: ItemId,
636 bases: &mut Vec<ItemId>,
637 visited: &mut HashSet<ItemId>,
638) {
639 if !visited.insert(item) {
640 return;
641 }
642 if matches!(item, ItemId::Function(id) if gcx.hir.function(id).is_yul) {
643 for &base in gcx.base_override_items(item) {
644 push_non_yul_base_items(gcx, base, bases, visited);
645 }
646 } else {
647 bases.push(item);
648 }
649}
650
651fn parameter_names_equal(gcx: Gcx<'_>, target: ItemId, base: ItemId) -> bool {
652 let names = |item| {
653 callable_function(gcx, item).map(|id| {
654 gcx.hir
655 .function(id)
656 .parameters
657 .iter()
658 .map(|&id| gcx.hir.variable(id).name.map(|name| name.name))
659 .collect::<Vec<_>>()
660 })
661 };
662 names(target) == names(base)
663}
664
665fn implicit_edge_compatible(gcx: Gcx<'_>, target: ItemId, base: ItemId) -> bool {
666 let (Some(target_id), Some(base_id)) =
667 (callable_function(gcx, target), callable_function(gcx, base))
668 else {
669 return false;
670 };
671 let target_fn = gcx.hir.function(target_id);
672 let base_fn = gcx.hir.function(base_id);
673 if !base_fn.virtual_
674 || target_fn.visibility == Visibility::Private
675 || base_fn.visibility == Visibility::Private
676 || (target_fn.body.is_none() && base_fn.body.is_some())
677 {
678 return false;
679 }
680 let visibility_ok = if matches!(target, ItemId::Variable(_)) {
681 base_fn.visibility == Visibility::External
682 } else {
683 target_fn.visibility == base_fn.visibility
684 || (base_fn.visibility == Visibility::External
685 && target_fn.visibility == Visibility::Public)
686 };
687 let target_mutability = match target {
688 ItemId::Variable(id) if gcx.hir.variable(id).is_constant() => {
689 solar::ast::StateMutability::Pure
690 }
691 _ => target_fn.state_mutability,
692 };
693 let mutability_ok = target_mutability == base_fn.state_mutability
694 || matches!(
695 (target_mutability, base_fn.state_mutability),
696 (
697 solar::ast::StateMutability::Pure,
698 solar::ast::StateMutability::View | solar::ast::StateMutability::NonPayable
699 ) | (solar::ast::StateMutability::View, solar::ast::StateMutability::NonPayable)
700 );
701 if !visibility_ok || !mutability_ok {
702 return false;
703 }
704 let types_equal = |left: &[VariableId], right: &[VariableId], locations: bool| {
705 left.len() == right.len()
706 && left.iter().zip(right).all(|(&left, &right)| {
707 let left = gcx.type_of_item(left.into());
708 let right = gcx.type_of_item(right.into());
709 left.peel_refs() == right.peel_refs() && (!locations || left.loc() == right.loc())
710 })
711 };
712 let external_types_equal = |left: &[VariableId], right: &[VariableId]| {
713 let normalize = |location| match location {
714 Some(DataLocation::Calldata) => Some(DataLocation::Memory),
715 location => location,
716 };
717 left.len() == right.len()
718 && left.iter().zip(right).all(|(&left, &right)| {
719 let left_variable = gcx.hir.variable(left);
720 let right_variable = gcx.hir.variable(right);
721 let left = gcx.type_of_item(left.into());
722 let right = gcx.type_of_item(right.into());
723 let left_location = left_variable.data_location.or_else(|| left.loc());
724 let right_location = right_variable.data_location.or_else(|| right.loc());
725 left.peel_refs() == right.peel_refs()
726 && normalize(left_location) == normalize(right_location)
727 })
728 };
729 let target_returns =
730 gcx.type_of_item(target_id.into()).as_externally_callable_function(false, gcx).returns();
731 let base_returns =
732 gcx.type_of_item(base_id.into()).as_externally_callable_function(false, gcx).returns();
733 if target_returns != base_returns || !external_types_equal(target_fn.returns, base_fn.returns) {
734 return false;
735 }
736 if target_fn.kind == FunctionKind::Modifier {
737 return types_equal(target_fn.parameters, base_fn.parameters, false);
738 }
739 if base_fn.visibility == Visibility::External {
740 external_types_equal(target_fn.parameters, base_fn.parameters)
741 } else {
742 types_equal(target_fn.parameters, base_fn.parameters, true)
743 && types_equal(target_fn.returns, base_fn.returns, true)
744 }
745}
746
747pub(crate) fn clean_block_doc_content(raw: &str) -> String {
751 raw.lines()
752 .map(|line| {
753 let t = line.trim_start();
754 if let Some(rest) = t.strip_prefix('*') {
755 rest.strip_prefix(' ').unwrap_or(rest)
756 } else {
757 line
758 }
759 })
760 .collect::<Vec<_>>()
761 .join("\n")
762}
763
764#[derive(Debug)]
773pub struct LocalMembers {
774 name: String,
776 members: HashSet<String>,
778 anchors: HashSet<String>,
780 inherited: HashMap<String, Option<PathBuf>>,
782 inherited_contracts: HashMap<String, InheritedContract>,
784}
785
786#[derive(Debug)]
787enum InheritedContract {
788 Unique { id: ContractId, page: Option<PathBuf>, anchors: HashSet<String> },
789 Ambiguous,
790}
791
792fn insert_function_anchors(
794 gcx: Gcx<'_>,
795 id: FunctionId,
796 anchors: &mut HashSet<String>,
797) -> Option<String> {
798 let function = gcx.hir.function(id);
799 if function.is_yul || function.is_getter() {
800 return None;
801 }
802 let params = function_source_param_types(gcx, id)?;
803 let name = match function.kind {
804 FunctionKind::Constructor => "constructor".to_string(),
805 FunctionKind::Fallback => "fallback".to_string(),
806 FunctionKind::Receive => "receive".to_string(),
807 FunctionKind::Function | FunctionKind::Modifier => function.name?.as_str().to_string(),
808 };
809 anchors.insert(slug_anchor_segment(&name));
810 anchors.insert(function_signature_anchor(&name, ¶ms));
811 Some(name)
812}
813
814impl LocalMembers {
815 pub fn new(name: &str) -> Self {
817 Self {
818 name: name.to_string(),
819 members: HashSet::new(),
820 anchors: HashSet::new(),
821 inherited: HashMap::new(),
822 inherited_contracts: HashMap::new(),
823 }
824 }
825
826 pub fn for_contract(gcx: Gcx<'_>, contract_id: ContractId, name_to_page: &NameToPage) -> Self {
828 let contract = gcx.hir.contract(contract_id);
829 let mut this = Self::new(contract.name.as_str());
830
831 for &base_id in contract.linearized_bases.iter().filter(|&&id| id != contract_id) {
835 let base = gcx.hir.contract(base_id);
836 let page = name_to_page.get_contract(base_id).cloned();
837 let mut anchors = HashSet::new();
838
839 for &item_id in base.items {
840 let (name, is_inherited) = match item_id {
841 ItemId::Function(id) => {
842 let function = gcx.hir.function(id);
843 (
844 insert_function_anchors(gcx, id, &mut anchors),
845 function.visibility != Visibility::Private
846 && function.kind != FunctionKind::Constructor,
847 )
848 }
849 ItemId::Variable(id) => {
850 let variable = gcx.hir.variable(id);
851 (
852 variable.name.map(|name| name.as_str().to_string()),
853 variable.visibility != Some(Visibility::Private),
854 )
855 }
856 ItemId::Struct(id) => {
857 (Some(gcx.hir.strukt(id).name.as_str().to_string()), true)
858 }
859 ItemId::Enum(id) => (Some(gcx.hir.enumm(id).name.as_str().to_string()), true),
860 ItemId::Error(id) => (Some(gcx.hir.error(id).name.as_str().to_string()), true),
861 ItemId::Event(id) => (Some(gcx.hir.event(id).name.as_str().to_string()), true),
862 ItemId::Udvt(id) => (Some(gcx.hir.udvt(id).name.as_str().to_string()), true),
863 ItemId::Contract(_) => (None, false),
864 };
865 if let Some(name) = name {
866 anchors.insert(slug_anchor_segment(&name));
867 if is_inherited {
868 this.inherited.entry(name).or_insert_with(|| page.clone());
869 }
870 }
871 }
872
873 match this.inherited_contracts.entry(base.name.as_str().to_string()) {
874 Entry::Vacant(entry) => {
875 entry.insert(InheritedContract::Unique { id: base_id, page, anchors });
876 }
877 Entry::Occupied(mut entry) => {
878 if matches!(entry.get(), InheritedContract::Unique { id, .. } if *id != base_id)
879 {
880 entry.insert(InheritedContract::Ambiguous);
881 }
882 }
883 }
884 }
885
886 this
887 }
888
889 pub fn insert(&mut self, member: &str) {
891 self.members.insert(member.to_string());
892 self.anchors.insert(slug_anchor_segment(member));
893 }
894
895 pub fn insert_anchor(&mut self, anchor: String) {
897 self.anchors.insert(anchor);
898 }
899
900 fn member_anchor(&self, member: &str) -> Option<String> {
904 self.members.contains(member).then(|| slug_anchor_segment(member))
905 }
906
907 fn xref_member_anchor(&self, part: &str) -> Option<String> {
910 let anchor = xref_part_anchor(part);
911 self.anchors.contains(&anchor).then_some(anchor)
912 }
913
914 fn inherited_member_link(&self, member: &str, current_page: &Path) -> Option<Option<String>> {
919 let page = self.inherited.get(member)?;
920 Some(page.as_ref().map(|page| {
921 format!("{}#{}", page_link(page, current_page), slug_anchor_segment(member))
922 }))
923 }
924
925 fn inherited_contract_member_link(
930 &self,
931 contract: &str,
932 part: &str,
933 current_page: &Path,
934 ) -> Option<Option<String>> {
935 let base = self.inherited_contracts.get(contract)?;
936 let InheritedContract::Unique { page, anchors, .. } = base else {
937 return Some(None);
938 };
939 let anchor = xref_part_anchor(part);
940 Some(page.as_ref().and_then(|page| {
941 anchors.contains(&anchor).then(|| format!("{}#{anchor}", page_link(page, current_page)))
942 }))
943 }
944}
945
946fn escape_link_label(s: &str) -> String {
951 s.replace('{', "{").replace('<', "<").replace('[', "\\[").replace(']', "\\]")
952}
953
954pub fn replace_inline_links(
964 text: &str,
965 name_to_page: &NameToPage,
966 current_page: &Path,
967 local: Option<&LocalMembers>,
968) -> String {
969 let mut out = String::with_capacity(text.len());
970 let bytes = text.as_bytes();
971 let mut i = 0;
972
973 while i < bytes.len() {
974 if bytes[i] == b'{' {
975 if let Some((end, ident, part, label)) = parse_inline_link(&text[i..]) {
977 let lookup_name = ident.strip_prefix("xref-").unwrap_or(ident);
979 let lookup_name = if let Some(pos) = lookup_name.find('-') {
980 &lookup_name[..pos]
981 } else {
982 lookup_name
983 };
984
985 if let Some(local) = local {
989 let local_anchor = match part {
990 None => local.member_anchor(lookup_name).map(Some),
991 Some(member) if lookup_name == local.name => {
992 Some(local.xref_member_anchor(member))
993 }
994 Some(_) => None,
995 };
996 if let Some(anchor) = local_anchor {
997 if let Some(anchor) = anchor {
998 let default_display = match part {
999 Some(member) => format!("{lookup_name}.{member}"),
1000 None => lookup_name.to_string(),
1001 };
1002 let display = escape_link_label(label.unwrap_or(&default_display));
1003 out.push_str(&format!("[{display}](#{anchor})"));
1004 } else {
1005 let safe_name = lookup_name.replace('`', "'");
1006 out.push_str(&format!("`{safe_name}`"));
1007 }
1008 i += end;
1009 continue;
1010 }
1011
1012 let inherited_link = match part {
1013 None => local.inherited_member_link(lookup_name, current_page),
1014 Some(member) => {
1015 local.inherited_contract_member_link(lookup_name, member, current_page)
1016 }
1017 };
1018 if let Some(link) = inherited_link {
1019 if let Some(link) = link {
1020 let default_display = match part {
1021 Some(member) => format!("{lookup_name}.{member}"),
1022 None => lookup_name.to_string(),
1023 };
1024 let display = escape_link_label(label.unwrap_or(&default_display));
1025 out.push_str(&format!("[{display}]({link})"));
1026 } else {
1027 let safe_name = lookup_name.replace('`', "'");
1028 out.push_str(&format!("`{safe_name}`"));
1029 }
1030 i += end;
1031 continue;
1032 }
1033 }
1034
1035 if let Some(candidates) = name_to_page.get(lookup_name) {
1036 let page = resolve_page(candidates, current_page);
1037 let mut link = page_link(page, current_page);
1038 if let Some(member) = part {
1042 let safe_member = xref_part_anchor(member);
1043 if !safe_member.is_empty() {
1044 link.push('#');
1045 link.push_str(&safe_member);
1046 }
1047 }
1048 let default_display = if let Some(member) = part {
1049 format!("{lookup_name}.{member}")
1051 } else {
1052 lookup_name.to_string()
1053 };
1054 let display = escape_link_label(label.unwrap_or(&default_display));
1055 out.push_str(&format!("[{display}]({link})"));
1056 i += end;
1057 continue;
1058 }
1059
1060 let safe_name = lookup_name.replace('`', "'");
1063 out.push_str(&format!("`{safe_name}`"));
1064 i += end;
1065 continue;
1066 }
1067 out.push_str("{");
1069 i += 1;
1070 continue;
1071 }
1072
1073 if bytes[i] == b'<' {
1074 out.push_str("<");
1079 i += 1;
1080 continue;
1081 }
1082
1083 let ch = text[i..].chars().next().unwrap();
1085 out.push(ch);
1086 i += ch.len_utf8();
1087 }
1088
1089 out
1090}
1091
1092pub(crate) fn function_signature_anchor(name: &str, params: &[String]) -> String {
1093 let mut anchor = slug_anchor_segment(name);
1094 for param in params {
1095 let param = slug_anchor_segment(&normalize_sol_type(param));
1096 if !param.is_empty() {
1097 anchor.push('-');
1098 anchor.push_str(¶m);
1099 }
1100 }
1101 anchor
1102}
1103
1104fn xref_part_anchor(part: &str) -> String {
1105 let mut pieces = part.split('-').filter(|piece| !piece.is_empty());
1106 let Some(member) = pieces.next() else {
1107 return String::new();
1108 };
1109 let params = pieces.map(|piece| piece.to_string()).collect::<Vec<_>>();
1110 function_signature_anchor(member, ¶ms)
1111}
1112
1113fn slug_anchor_segment(s: &str) -> String {
1114 let mut out = String::with_capacity(s.len());
1115 let mut last_was_dash = false;
1116
1117 for ch in s.chars().flat_map(char::to_lowercase) {
1118 if ch.is_ascii_alphanumeric() || ch == '_' {
1119 out.push(ch);
1120 last_was_dash = false;
1121 } else if ch != '$' && !last_was_dash && !out.is_empty() {
1122 out.push('-');
1123 last_was_dash = true;
1124 }
1125 }
1126
1127 if last_was_dash {
1128 out.pop();
1129 }
1130
1131 out
1132}
1133
1134fn parse_inline_link(s: &str) -> Option<(usize, &str, Option<&str>, Option<&str>)> {
1138 let s = s.strip_prefix('{')?;
1139 let close = s.find('}')?;
1140 let inner = &s[..close];
1141
1142 let (raw_ident, raw_part) = if let Some(rest) = inner.strip_prefix("xref-") {
1144 if let Some(dash) = rest.find('-') {
1145 (&inner[..("xref-".len() + dash)], Some(&rest[dash + 1..]))
1146 } else {
1147 (inner, None)
1148 }
1149 } else if let Some(dash) = inner.find('-') {
1150 let candidate_ident = &inner[..dash];
1151 let candidate_part = &inner[dash + 1..];
1152 if candidate_ident.chars().all(|c| c.is_alphanumeric() || c == '_')
1153 && !candidate_part.is_empty()
1154 {
1155 (candidate_ident, Some(candidate_part))
1156 } else {
1157 (inner, None)
1158 }
1159 } else {
1160 (inner, None)
1161 };
1162
1163 let mut consumed = 1 + close + 1; let rest = &s[close + 1..];
1167 let label = if rest.starts_with('[') {
1168 if let Some(end) = rest.find(']') {
1169 let lbl = &rest[1..end];
1170 consumed += end + 1;
1171 Some(lbl)
1172 } else {
1173 None
1174 }
1175 } else {
1176 None
1177 };
1178
1179 Some((consumed, raw_ident, raw_part, label))
1180}
1181
1182fn page_link(page: &Path, _current_page: &Path) -> String {
1189 let without_ext = page.with_extension("");
1191 format!("/{}", without_ext.to_slash_lossy())
1192}
1193
1194#[cfg(test)]
1195mod tests {
1196 use super::*;
1197
1198 #[test]
1199 fn full_signature_xref_links_member_anchor() {
1200 let mut name_to_page = NameToPage::new();
1201 name_to_page
1202 .by_name
1203 .insert("ERC721".to_string(), vec![PathBuf::from("src/contract.ERC721.mdx")]);
1204
1205 let out = replace_inline_links(
1206 "See {xref-ERC721-_safeMint-address-uint256-}.",
1207 &name_to_page,
1208 Path::new("src/contract.Child.mdx"),
1209 None,
1210 );
1211
1212 assert_eq!(
1213 out,
1214 "See [ERC721._safeMint-address-uint256-](/src/contract.ERC721#_safemint-address-uint256)."
1215 );
1216 }
1217
1218 #[test]
1219 fn same_contract_member_links_anchor_only() {
1220 let name_to_page = NameToPage::new();
1221 let mut local = LocalMembers::new("ECDSA");
1222 local.insert("toEthSignedMessageHash");
1223 local.insert("tryRecover");
1224
1225 let out = replace_inline_links(
1227 "then calling {toEthSignedMessageHash} on it.",
1228 &name_to_page,
1229 Path::new("src/library.ECDSA.mdx"),
1230 Some(&local),
1231 );
1232 assert_eq!(out, "then calling [toEthSignedMessageHash](#toethsignedmessagehash) on it.");
1233
1234 let out = replace_inline_links(
1236 "Overload of {ECDSA-tryRecover} that ...",
1237 &name_to_page,
1238 Path::new("src/library.ECDSA.mdx"),
1239 Some(&local),
1240 );
1241 assert_eq!(out, "Overload of [ECDSA.tryRecover](#tryrecover) that ...");
1242
1243 let out = replace_inline_links(
1245 "See {unknownMember}.",
1246 &name_to_page,
1247 Path::new("src/library.ECDSA.mdx"),
1248 Some(&local),
1249 );
1250 assert_eq!(out, "See `unknownMember`.");
1251
1252 let out = replace_inline_links(
1254 "See {ECDSA-doesNotExist}.",
1255 &name_to_page,
1256 Path::new("src/library.ECDSA.mdx"),
1257 Some(&local),
1258 );
1259 assert_eq!(out, "See `ECDSA`.");
1260 }
1261
1262 #[test]
1263 fn local_member_wins_over_global_name() {
1264 let mut name_to_page = NameToPage::new();
1267 name_to_page
1268 .by_name
1269 .insert("transfer".to_string(), vec![PathBuf::from("src/other/contract.transfer.mdx")]);
1270 let mut local = LocalMembers::new("Token");
1271 local.insert("transfer");
1272
1273 let out = replace_inline_links(
1274 "Calls {transfer}.",
1275 &name_to_page,
1276 Path::new("src/contract.Token.mdx"),
1277 Some(&local),
1278 );
1279 assert_eq!(out, "Calls [transfer](#transfer).");
1280
1281 let out = replace_inline_links(
1283 "Calls {transfer}.",
1284 &name_to_page,
1285 Path::new("src/contract.Token.mdx"),
1286 None,
1287 );
1288 assert_eq!(out, "Calls [transfer](/src/other/contract.transfer).");
1289 }
1290}