1use path_slash::PathBufExt;
10use solar::{
11 ast::{
12 CommentKind, ContractKind, DocComments, FunctionKind, ItemKind, NatSpecKind, ParameterList,
13 },
14 interface::source_map::FileName,
15 sema::{
16 Gcx,
17 hir::{ContractId, FunctionId, ItemId, SourceId, VariableId},
18 },
19};
20use std::{
21 collections::{HashMap, HashSet},
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 item_ids: Vec<_> = gcx.hir.item_ids().collect();
69 item_ids.sort_by_key(|id| {
70 let (name, source) = match id {
71 ItemId::Contract(id) => {
72 let c = gcx.hir.contract(*id);
73 (c.name.as_str().to_string(), c.source)
74 }
75 ItemId::Struct(id) => {
76 let s = gcx.hir.strukt(*id);
77 (s.name.as_str().to_string(), s.source)
78 }
79 ItemId::Enum(id) => {
80 let e = gcx.hir.enumm(*id);
81 (e.name.as_str().to_string(), e.source)
82 }
83 ItemId::Error(id) => {
84 let e = gcx.hir.error(*id);
85 (e.name.as_str().to_string(), e.source)
86 }
87 ItemId::Event(id) => {
88 let e = gcx.hir.event(*id);
89 (e.name.as_str().to_string(), e.source)
90 }
91 ItemId::Udvt(id) => {
92 let u = gcx.hir.udvt(*id);
93 (u.name.as_str().to_string(), u.source)
94 }
95 ItemId::Function(_) | ItemId::Variable(_) => {
96 return (String::new(), String::new());
97 }
98 };
99 let path = source_paths(gcx, source, root)
100 .map(|(_, rel)| rel.to_string_lossy().into_owned())
101 .unwrap_or_default();
102 (path, name)
103 });
104
105 for item_id in item_ids {
106 let (name, source, contract, prefix) = match item_id {
107 ItemId::Contract(id) => {
108 let c = gcx.hir.contract(id);
109 let kind = match c.kind {
110 ContractKind::Contract => "contract",
111 ContractKind::AbstractContract => "abstract",
112 ContractKind::Interface => "interface",
113 ContractKind::Library => "library",
114 };
115 (c.name, c.source, None, kind)
116 }
117 ItemId::Struct(id) => {
118 let s = gcx.hir.strukt(id);
119 (s.name, s.source, s.contract, "struct")
120 }
121 ItemId::Enum(id) => {
122 let e = gcx.hir.enumm(id);
123 (e.name, e.source, e.contract, "enum")
124 }
125 ItemId::Error(id) => {
126 let e = gcx.hir.error(id);
127 (e.name, e.source, e.contract, "error")
128 }
129 ItemId::Event(id) => {
130 let e = gcx.hir.event(id);
131 (e.name, e.source, e.contract, "event")
132 }
133 ItemId::Udvt(id) => {
134 let u = gcx.hir.udvt(id);
135 (u.name, u.source, u.contract, "type")
136 }
137 ItemId::Function(_) | ItemId::Variable(_) => continue,
138 };
139
140 if contract.is_some() && !matches!(item_id, ItemId::Contract(_)) {
143 continue;
144 }
145
146 if let Some((abs, rel)) = source_paths(gcx, source, root) {
147 if !allowed_sources.contains(&abs) {
148 continue;
149 }
150 let out_dir = rel.parent().unwrap_or(Path::new("")).to_owned();
151 let page = out_dir.join(format!("{prefix}.{}.mdx", name.as_str()));
152 let name_str = name.as_str().to_string();
153 let entry = map.by_name.entry(name_str.clone()).or_default();
154 if !entry.is_empty() {
155 warn!(
156 "forge doc: duplicate top-level name `{name_str}`; \
157 cross-reference `{{{name_str}}}` will resolve by proximity to the referencing page"
158 );
159 }
160 entry.push(page.clone());
161
162 if let ItemId::Contract(cid) = item_id {
165 map.by_contract.insert(cid, page);
166 }
167 }
168 }
169
170 map
171}
172
173fn source_paths(gcx: Gcx<'_>, source_id: SourceId, root: &Path) -> Option<(PathBuf, PathBuf)> {
174 let file = &gcx.hir.source(source_id).file;
175 if let FileName::Real(p) = &file.name {
176 let rel = if let Ok(r) = p.strip_prefix(root) {
177 r.to_path_buf()
178 } else {
179 let comps: Vec<_> = p.components().collect();
182 let start = comps.len().saturating_sub(3);
183 let tail: PathBuf = comps[start..].iter().collect();
184 PathBuf::from("lib").join(tail)
185 };
186 Some((p.clone(), rel))
187 } else {
188 None
189 }
190}
191
192fn resolve_page<'a>(candidates: &'a [PathBuf], current_page: &Path) -> &'a PathBuf {
199 if candidates.len() == 1 {
200 return &candidates[0];
201 }
202 let current_dir = current_page.parent().unwrap_or(Path::new(""));
203 candidates
204 .iter()
205 .max_by_key(|page| {
206 let page_dir = page.parent().unwrap_or(Path::new(""));
207 current_dir.components().zip(page_dir.components()).take_while(|(a, b)| a == b).count()
208 })
209 .unwrap_or(&candidates[0])
210}
211
212pub fn inheritance_links(
218 gcx: Gcx<'_>,
219 contract_id: ContractId,
220 name_to_page: &NameToPage,
221 current_page: &Path,
222) -> Option<String> {
223 let contract = gcx.hir.contract(contract_id);
224 if contract.bases.is_empty() {
225 return None;
226 }
227
228 let parts: Vec<String> = contract
229 .bases
230 .iter()
231 .map(|&base_id| {
232 let base = gcx.hir.contract(base_id);
233 let name = base.name.as_str();
234 if let Some(page) = name_to_page.get_contract(base_id) {
237 let link = page_link(page, current_page);
238 format!("[{name}]({link})")
239 } else if let Some(candidates) = name_to_page.get(name) {
240 let page = resolve_page(candidates, current_page);
241 let link = page_link(page, current_page);
242 format!("[{name}]({link})")
243 } else {
244 name.to_string()
245 }
246 })
247 .collect();
248
249 Some(format!("**Inherits:** {}", parts.join(", ")))
250}
251
252pub struct InheritedDoc {
256 pub notices: Vec<String>,
257 pub devs: Vec<String>,
258 pub params: Vec<(String, String)>,
259 pub returns: Vec<(String, String)>,
260}
261
262pub fn resolve_inheritdoc(
270 gcx: Gcx<'_>,
271 contract_id: ContractId,
272 fn_name: &str,
273 base_name: &str,
274 param_types: Option<&[String]>,
275) -> Option<InheritedDoc> {
276 let contract = gcx.hir.contract(contract_id);
277
278 let base_id = contract
280 .linearized_bases
281 .iter()
282 .copied()
283 .find(|&bid| gcx.hir.contract(bid).name.as_str() == base_name)?;
284
285 let base_contract = gcx.hir.contract(base_id);
289
290 let search_contracts: Vec<ContractId> = std::iter::once(base_id)
291 .chain(base_contract.linearized_bases.iter().copied().filter(|&id| id != base_id))
292 .collect();
293
294 for search_id in &search_contracts {
295 let search_contract = gcx.hir.contract(*search_id);
296 let mut name_matches: Vec<FunctionId> = Vec::new();
297 for &item_id in search_contract.items {
298 if let ItemId::Function(fid) = item_id {
299 let f = gcx.hir.function(fid);
300 let matches = match f.kind {
301 FunctionKind::Constructor => fn_name == "constructor",
302 FunctionKind::Fallback => fn_name == "fallback",
303 FunctionKind::Receive => fn_name == "receive",
304 _ => f.name.map(|n| n.as_str() == fn_name).unwrap_or(false),
305 };
306 if matches {
307 name_matches.push(fid);
308 }
309 }
310 }
311 if name_matches.is_empty() {
312 continue;
313 }
314
315 if let Some(want) = param_types
317 && name_matches.len() > 1
318 {
319 for &fid in &name_matches {
321 if let Some(got) = function_param_types(gcx, fid)
322 && got.len() == want.len()
323 && got.iter().zip(want).all(|(a, b)| a == b)
324 && let Some(doc) = extract_inherited_doc(gcx, fid)
325 && (!doc.notices.is_empty()
326 || !doc.devs.is_empty()
327 || !doc.params.is_empty()
328 || !doc.returns.is_empty())
329 {
330 return Some(doc);
331 }
332 }
333 }
334
335 if name_matches.len() > 1 && param_types.is_some() {
336 continue;
337 }
338
339 for &fid in &name_matches {
342 if let Some(doc) = extract_inherited_doc(gcx, fid)
343 && (!doc.notices.is_empty()
344 || !doc.devs.is_empty()
345 || !doc.params.is_empty()
346 || !doc.returns.is_empty())
347 {
348 return Some(doc);
349 }
350 }
351 }
352
353 None
354}
355
356pub fn resolve_inheritdoc_var(
360 gcx: Gcx<'_>,
361 contract_id: ContractId,
362 var_name: &str,
363 base_name: &str,
364) -> Option<InheritedDoc> {
365 let contract = gcx.hir.contract(contract_id);
366 let base_id = contract
367 .linearized_bases
368 .iter()
369 .copied()
370 .find(|&bid| gcx.hir.contract(bid).name.as_str() == base_name)?;
371
372 let base_contract = gcx.hir.contract(base_id);
373 let search_contracts: Vec<ContractId> = std::iter::once(base_id)
374 .chain(base_contract.linearized_bases.iter().copied().filter(|&id| id != base_id))
375 .collect();
376
377 for search_id in &search_contracts {
378 let search_contract = gcx.hir.contract(*search_id);
379 for &item_id in search_contract.items {
380 match item_id {
381 ItemId::Variable(vid) => {
382 let v = gcx.hir.variable(vid);
383 if v.name.map(|n| n.as_str() == var_name).unwrap_or(false)
384 && let Some(doc) = extract_inherited_doc_var(gcx, vid)
385 && (!doc.notices.is_empty() || !doc.devs.is_empty())
386 {
387 return Some(doc);
388 }
389 }
390 ItemId::Function(fid) => {
396 let f = gcx.hir.function(fid);
397 if f.name.map(|n| n.as_str() == var_name).unwrap_or(false)
398 && function_param_types(gcx, fid).map(|p| p.is_empty()).unwrap_or(false)
399 && let Some(doc) = extract_inherited_doc(gcx, fid)
400 && (!doc.notices.is_empty()
401 || !doc.devs.is_empty()
402 || !doc.returns.is_empty())
403 {
404 return Some(doc);
405 }
406 }
407 _ => {}
408 }
409 }
410 }
411 None
412}
413
414fn extract_inherited_doc_var(gcx: Gcx<'_>, vid: VariableId) -> Option<InheritedDoc> {
415 let v = gcx.hir.variable(vid);
416 let ast_source = gcx.sources.get(v.source)?;
417 let ast = ast_source.ast.as_ref()?;
418 let var_span = v.span;
419
420 let docs = ast.items.iter().find_map(|item| {
421 if item.span == var_span {
422 return Some(&item.docs);
423 }
424 if let ItemKind::Contract(c) = &item.kind {
425 for member in c.body.iter() {
426 if member.span == var_span {
427 return Some(&member.docs);
428 }
429 }
430 }
431 None
432 })?;
433
434 Some(collect_inherited_doc(docs))
435}
436
437fn function_param_types(gcx: Gcx<'_>, fid: FunctionId) -> Option<Vec<String>> {
441 let f = gcx.hir.function(fid);
442 let ast_source = gcx.sources.get(f.source)?;
443 let ast = ast_source.ast.as_ref()?;
444 let fn_span = f.span;
445
446 let params = ast.items.iter().find_map(|item| match &item.kind {
447 solar::ast::ItemKind::Function(func) if item.span == fn_span => {
448 Some(&func.header.parameters)
449 }
450 solar::ast::ItemKind::Contract(c) => c.body.iter().find_map(|m| match &m.kind {
451 solar::ast::ItemKind::Function(func) if m.span == fn_span => {
452 Some(&func.header.parameters)
453 }
454 _ => None,
455 }),
456 _ => None,
457 })?;
458
459 Some(parameter_type_strings(gcx, params))
460}
461
462pub fn parameter_type_strings(gcx: Gcx<'_>, params: &ParameterList<'_>) -> Vec<String> {
467 let sm = gcx.sess.source_map();
468 params
469 .vars
470 .iter()
471 .map(|v| {
472 let raw = sm.span_to_snippet(v.ty.span).unwrap_or_default();
473 let t = raw.split_whitespace().collect::<Vec<_>>().join(" ");
474 normalize_sol_type(&t)
475 })
476 .collect()
477}
478
479fn normalize_sol_type(t: &str) -> String {
486 let bytes = t.as_bytes();
489 let len = bytes.len();
490 let mut out = String::with_capacity(len + 8);
491 let mut i = 0;
492 while i < len {
493 if bytes[i..].starts_with(b"uint")
496 && !bytes.get(i + 4).copied().map(|b| b.is_ascii_digit()).unwrap_or(false)
497 {
498 out.push_str("uint256");
499 i += 4;
500 } else if bytes[i..].starts_with(b"int")
501 && !bytes.get(i + 3).copied().map(|b| b.is_ascii_digit()).unwrap_or(false)
502 {
503 out.push_str("int256");
504 i += 3;
505 } else if let Some(ch) = t[i..].chars().next() {
506 out.push(ch);
507 i += ch.len_utf8();
508 } else {
509 break;
510 }
511 }
512 out
513}
514
515fn extract_inherited_doc(gcx: Gcx<'_>, fid: FunctionId) -> Option<InheritedDoc> {
516 let f = gcx.hir.function(fid);
520 let ast_source = gcx.sources.get(f.source)?;
521 let ast = ast_source.ast.as_ref()?;
522
523 let fn_span = f.span;
524 let docs = ast.items.iter().find_map(|item| {
526 if item.span == fn_span {
527 return Some(&item.docs);
528 }
529 if let solar::ast::ItemKind::Contract(c) = &item.kind {
531 for member in c.body.iter() {
532 if member.span == fn_span {
533 return Some(&member.docs);
534 }
535 }
536 }
537 None
538 })?;
539
540 Some(collect_inherited_doc(docs))
541}
542
543fn collect_inherited_doc(docs: &DocComments<'_>) -> InheritedDoc {
544 let mut result = InheritedDoc {
545 notices: Vec::new(),
546 devs: Vec::new(),
547 params: Vec::new(),
548 returns: Vec::new(),
549 };
550 let mut prev_doc_was_blank = false;
551 #[derive(Clone, Copy)]
552 enum LastSection {
553 Notice,
554 Dev,
555 Param,
556 Return,
557 }
558 let mut last_section: Option<LastSection> = None;
559
560 for doc in docs.iter() {
561 if doc.natspec.is_empty() {
562 prev_doc_was_blank = true;
563 continue;
564 }
565 for item in doc.natspec.iter() {
566 let raw = doc.natspec_content(item);
567 let raw: &str =
569 if doc.kind == CommentKind::Block { &clean_block_doc_content(raw) } else { raw };
570 let is_continuation = matches!(item.kind, NatSpecKind::Notice)
572 && raw.starts_with(|c: char| c.is_whitespace());
573 let content = raw.trim().to_string();
574 if content.is_empty() {
575 prev_doc_was_blank = true;
576 continue;
577 }
578 if is_continuation && !prev_doc_was_blank {
579 let last: Option<&mut String> = match last_section {
580 Some(LastSection::Notice) => result.notices.last_mut(),
581 Some(LastSection::Dev) => result.devs.last_mut(),
582 Some(LastSection::Param) => result.params.last_mut().map(|(_, d)| d),
583 Some(LastSection::Return) => result.returns.last_mut().map(|(_, d)| d),
584 None => None,
585 };
586 if let Some(last) = last {
587 last.push('\n');
588 last.push_str(&content);
589 continue;
590 }
591 }
592 prev_doc_was_blank = false;
593 match item.kind {
594 NatSpecKind::Notice => {
595 result.notices.push(content);
596 last_section = Some(LastSection::Notice);
597 }
598 NatSpecKind::Dev => {
599 result.devs.push(content);
600 last_section = Some(LastSection::Dev);
601 }
602 NatSpecKind::Param { name } => {
603 result.params.push((name.as_str().to_string(), content));
604 last_section = Some(LastSection::Param);
605 }
606 NatSpecKind::Return { name } => {
607 result.returns.push((
608 name.map(|name| name.as_str().to_string()).unwrap_or_default(),
609 content,
610 ));
611 last_section = Some(LastSection::Return);
612 }
613 _ => {}
614 }
615 }
616 }
617 result
618}
619
620pub(crate) fn clean_block_doc_content(raw: &str) -> String {
624 raw.lines()
625 .map(|line| {
626 let t = line.trim_start();
627 if let Some(rest) = t.strip_prefix('*') {
628 rest.strip_prefix(' ').unwrap_or(rest)
629 } else {
630 line
631 }
632 })
633 .collect::<Vec<_>>()
634 .join("\n")
635}
636
637fn escape_link_label(s: &str) -> String {
644 s.replace('{', "{").replace('<', "<").replace('[', "\\[").replace(']', "\\]")
645}
646
647pub fn replace_inline_links(text: &str, name_to_page: &NameToPage, current_page: &Path) -> String {
652 let mut out = String::with_capacity(text.len());
653 let bytes = text.as_bytes();
654 let mut i = 0;
655
656 while i < bytes.len() {
657 if bytes[i] == b'{' {
658 if let Some((end, ident, part, label)) = parse_inline_link(&text[i..]) {
660 let lookup_name = ident.strip_prefix("xref-").unwrap_or(ident);
662 let lookup_name = if let Some(pos) = lookup_name.find('-') {
663 &lookup_name[..pos]
664 } else {
665 lookup_name
666 };
667
668 if let Some(candidates) = name_to_page.get(lookup_name) {
669 let page = resolve_page(candidates, current_page);
670 let mut link = page_link(page, current_page);
671 if let Some(member) = part {
675 let safe_member = xref_part_anchor(member);
676 if !safe_member.is_empty() {
677 link.push('#');
678 link.push_str(&safe_member);
679 }
680 }
681 let default_display = if let Some(member) = part {
682 format!("{lookup_name}.{member}")
684 } else {
685 lookup_name.to_string()
686 };
687 let display = escape_link_label(label.unwrap_or(&default_display));
688 out.push_str(&format!("[{display}]({link})"));
689 i += end;
690 continue;
691 }
692
693 let safe_name = lookup_name.replace('`', "'");
696 out.push_str(&format!("`{safe_name}`"));
697 i += end;
698 continue;
699 }
700 out.push_str("{");
702 i += 1;
703 continue;
704 }
705
706 if bytes[i] == b'<' {
707 out.push_str("<");
712 i += 1;
713 continue;
714 }
715
716 let ch = text[i..].chars().next().unwrap();
718 out.push(ch);
719 i += ch.len_utf8();
720 }
721
722 out
723}
724
725pub(crate) fn function_signature_anchor(name: &str, params: &[String]) -> String {
726 let mut anchor = slug_anchor_segment(name);
727 for param in params {
728 let param = slug_anchor_segment(&normalize_sol_type(param));
729 if !param.is_empty() {
730 anchor.push('-');
731 anchor.push_str(¶m);
732 }
733 }
734 anchor
735}
736
737fn xref_part_anchor(part: &str) -> String {
738 let mut pieces = part.split('-').filter(|piece| !piece.is_empty());
739 let Some(member) = pieces.next() else {
740 return String::new();
741 };
742 let params = pieces.map(|piece| piece.to_string()).collect::<Vec<_>>();
743 function_signature_anchor(member, ¶ms)
744}
745
746fn slug_anchor_segment(s: &str) -> String {
747 let mut out = String::with_capacity(s.len());
748 let mut last_was_dash = false;
749
750 for ch in s.chars().flat_map(char::to_lowercase) {
751 if ch.is_ascii_alphanumeric() || ch == '_' {
752 out.push(ch);
753 last_was_dash = false;
754 } else if !last_was_dash && !out.is_empty() {
755 out.push('-');
756 last_was_dash = true;
757 }
758 }
759
760 if last_was_dash {
761 out.pop();
762 }
763
764 out
765}
766
767fn parse_inline_link(s: &str) -> Option<(usize, &str, Option<&str>, Option<&str>)> {
771 let s = s.strip_prefix('{')?;
772 let close = s.find('}')?;
773 let inner = &s[..close];
774
775 let (raw_ident, raw_part) = if let Some(rest) = inner.strip_prefix("xref-") {
777 if let Some(dash) = rest.find('-') {
778 (&inner[..("xref-".len() + dash)], Some(&rest[dash + 1..]))
779 } else {
780 (inner, None)
781 }
782 } else if let Some(dash) = inner.find('-') {
783 let candidate_ident = &inner[..dash];
784 let candidate_part = &inner[dash + 1..];
785 if candidate_ident.chars().all(|c| c.is_alphanumeric() || c == '_')
786 && !candidate_part.is_empty()
787 {
788 (candidate_ident, Some(candidate_part))
789 } else {
790 (inner, None)
791 }
792 } else {
793 (inner, None)
794 };
795
796 let mut consumed = 1 + close + 1; let rest = &s[close + 1..];
800 let label = if rest.starts_with('[') {
801 if let Some(end) = rest.find(']') {
802 let lbl = &rest[1..end];
803 consumed += end + 1;
804 Some(lbl)
805 } else {
806 None
807 }
808 } else {
809 None
810 };
811
812 Some((consumed, raw_ident, raw_part, label))
813}
814
815fn page_link(page: &Path, _current_page: &Path) -> String {
822 let without_ext = page.with_extension("");
824 format!("/{}", without_ext.to_slash_lossy())
825}
826
827#[cfg(test)]
828mod tests {
829 use super::*;
830
831 #[test]
832 fn full_signature_xref_links_member_anchor() {
833 let mut name_to_page = NameToPage::new();
834 name_to_page
835 .by_name
836 .insert("ERC721".to_string(), vec![PathBuf::from("src/contract.ERC721.mdx")]);
837
838 let out = replace_inline_links(
839 "See {xref-ERC721-_safeMint-address-uint256-}.",
840 &name_to_page,
841 Path::new("src/contract.Child.mdx"),
842 );
843
844 assert_eq!(
845 out,
846 "See [ERC721._safeMint-address-uint256-](/src/contract.ERC721#_safemint-address-uint256)."
847 );
848 }
849}