Skip to main content

forge_doc/
hir_ext.rs

1//! HIR-aware enrichments.
2//!
3//! Pure functions over `solar`'s HIR:
4//! * `build_name_to_page`: maps contract names to their MDX page paths.
5//! * `inheritance_links`: `**Inherits:**` line for a contract page.
6//! * `resolve_inheritdoc`: pulls natspec from a base contract member.
7//! * `replace_inline_links`: rewrites `{Ident}` to markdown links.
8
9use 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// ── name-to-page map ──────────────────────────────────────────────────────────
28
29/// Maps Solidity identifiers and HIR ids to their output MDX page paths
30/// relative to `pages/`.
31#[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    /// Candidate pages defined for a top-level identifier, if any.
43    pub fn get(&self, name: &str) -> Option<&Vec<PathBuf>> {
44        self.by_name.get(name)
45    }
46
47    /// Exact page for a contract id, if it lives in an allowed source.
48    pub fn get_contract(&self, id: ContractId) -> Option<&PathBuf> {
49        self.by_contract.get(&id)
50    }
51}
52
53/// Build the [`NameToPage`] index from HIR by re-deriving each item's output path.
54///
55/// This mirrors the path computation in `render::source` so links can be resolved
56/// before rendering begins.
57///
58/// Only items whose source file is contained in `allowed_sources` (absolute paths)
59/// are included, so cross-references cannot resolve to pages that won't be emitted.
60pub 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    // Collect and sort by (source_path, name) so that last-insert-wins is deterministic
68    // across platforms even when the HIR iteration order is unspecified.
69    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        // For non-contract items, skip those defined inside a contract (they appear on the
142        // contract page, not their own page).
143        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            // Record exact contract -> page so inheritance / id-keyed lookups
164            // don't go through the ambiguous name index.
165            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            // Outside-root files (e.g. absolute lib paths) get a synthetic
181            // `lib/<tail>` path that matches what builder.rs emits.
182            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
193/// Pick the best candidate page for a given cross-reference lookup.
194///
195/// When only one candidate exists the choice is trivial. When multiple files define
196/// the same top-level name the page whose *directory* shares the longest common
197/// path prefix with `current_page` wins; ties fall back to the first entry (which
198/// is deterministic because `build_name_to_page` sorts before inserting).
199fn 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
213// ── inheritance links ─────────────────────────────────────────────────────────
214
215/// Returns the `**Inherits:**` markdown string for a contract, or `None` if it has no bases.
216///
217/// Each base is either a bare name (when no page is known) or a markdown link.
218pub 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            // Prefer the exact base id; only fall back to the ambiguous name
236            // index when the base has no rendered page of its own.
237            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
253// ── inheritdoc resolution ─────────────────────────────────────────────────────
254
255/// Collected natspec tags from an inherited base member.
256pub 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
263/// Resolve `@inheritdoc BaseContract` for a function named `fn_name` inside
264/// `contract_id` (the current contract). Walks the linearized bases to find a
265/// matching function and returns its natspec if found.
266///
267/// When `param_types` is `Some`, the resolver prefers a function whose parameter
268/// type signature matches exactly; this disambiguates overloads. Ambiguous
269/// overloads are never matched by name alone.
270pub 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    // Find the named base contract in the linearized hierarchy.
280    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    // Search the named base and then its own linearized chain so that `@inheritdoc Base`
287    // resolves even when `Base` itself inherits the member without redeclaring NatSpec.
288    // We prefer the first level that has both a name match AND non-empty documentation.
289    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        // Prefer an exact signature match when overloads exist.
317        if let Some(want) = param_types
318            && name_matches.len() > 1
319        {
320            // Try to find a signature-exact match with docs; fall through only for
321            // non-overloaded name matches below.
322            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        // Return the first candidate that has actual documentation; if none have docs
342        // at this inheritance level continue walking up the chain.
343        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
358/// Resolve `@inheritdoc BaseContract` for a **state variable** named `var_name`
359/// inside `contract_id`. Walks the linearised bases to find a matching public
360/// variable and returns its natspec if found.
361pub 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                // A public state variable can implement an interface getter declared as a
393                // zero-arg function (e.g. `function totalSupply() external view returns
394                // (uint256)`). Fall back to matching a same-name zero-parameter function so
395                // `@inheritdoc IERC20` on `uint256 public totalSupply` picks up the
396                // interface's notice/return docs.
397                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
439/// Extract the canonical ABI parameter type strings (in source order) for a function.
440///
441/// Non-ABI-printable internal parameter types, such as mappings, fall back to their
442/// source spelling so inherited docs can still resolve without panicking.
443pub(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(&param)).collect::<Vec<_>>()
447    });
448
449    f.parameters
450        .iter()
451        .enumerate()
452        .map(|(idx, &param)| {
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
467/// Extract function parameter types exactly as spelled in source.
468fn 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
491/// Canonicalize Solidity type aliases for generated anchors and source fallbacks.
492///
493/// Replaces every occurrence of the bare alias tokens `uint` / `int` (not
494/// followed by a digit) with their canonical ABI equivalents `uint256` /
495/// `int256`.
496fn normalize_sol_type(t: &str) -> String {
497    // Walk char-by-char and replace `uint` / `int` that are not followed by a
498    // digit (i.e. are bare aliases, not `uint8`, `uint256`, etc.).
499    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        // Try to match the longer alias first (`uint` before `int`) to avoid
505        // a prefix match of `int` inside `uint`.
506        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    // HIR functions store a span; we need the AST doc comments.
528    // The AST source has the doc comments on the Item.
529    // We find the source file for this function and look up the AST item by span.
530    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    // Walk the AST to find the Item whose span matches.
536    let docs = ast.items.iter().find_map(|item| {
537        if item.span == fn_span {
538            return Some(&item.docs);
539        }
540        // Also search inside contracts.
541        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            // For /** */ block comments Solar preserves raw ` * ` line decorations; strip them.
579            let raw: &str =
580                if doc.kind == CommentKind::Block { &clean_block_doc_content(raw) } else { raw };
581            // Solar emits lines without a `@` tag as synthetic @notice with leading whitespace.
582            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
631/// Strip the ` * ` block-comment line decoration from each line of a `/** */` NatSpec item's
632/// content. Solar preserves raw source bytes, so continuation lines look like ` * text` and blank
633/// separator lines look like ` *`. This normalises them to plain text / empty lines.
634pub(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// ── inline link replacement ───────────────────────────────────────────────────
649
650/// Members of the contract page currently being rendered.
651///
652/// Used to resolve `{member}` and `{Contract-member}` references lexically:
653/// a name that belongs to the current contract links to its heading anchor on
654/// the same page instead of going through the global name index (which only
655/// contains top-level items and could otherwise resolve to an unrelated page).
656#[derive(Debug)]
657pub struct LocalMembers {
658    /// The current contract's name.
659    name: String,
660    /// Member names with a heading (and thus an anchor) on the current page.
661    members: HashSet<String>,
662    /// Heading and exact signature anchors rendered on the current page.
663    anchors: HashSet<String>,
664    /// Effective inherited member names and their optional documentation pages.
665    inherited: HashMap<String, Option<PathBuf>>,
666    /// Inherited contracts and the members rendered on their exact pages.
667    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
676/// Record the heading and exact signature anchor for a rendered Solidity function.
677fn 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, &params));
695    Some(name)
696}
697
698impl LocalMembers {
699    /// Create an empty member set for the contract `name`.
700    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    /// Create a member set populated with members declared by base contracts.
711    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        // Solidity's linearization lists the current contract first, followed by bases in
716        // resolution order. Reserve the first inherited declaration even if its page is not
717        // rendered so a farther declaration cannot produce a confidently incorrect link.
718        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    /// Record a member that is rendered as a `### member` heading on the page.
774    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    /// Record an exact signature anchor rendered on the current page.
780    pub fn insert_anchor(&mut self, anchor: String) {
781        self.anchors.insert(anchor);
782    }
783
784    /// Anchor for a bare `{member}` reference, if `member` is documented on this page.
785    ///
786    /// Overloads share the base heading slug; the first heading owns it.
787    fn member_anchor(&self, member: &str) -> Option<String> {
788        self.members.contains(member).then(|| slug_anchor_segment(member))
789    }
790
791    /// Anchor for a qualified `{Contract-member[-params...]}` reference, if `member` is
792    /// documented on this page.
793    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    /// Page and anchor for a bare inherited-member reference.
799    ///
800    /// The outer option indicates whether the name is inherited; the inner option is absent when
801    /// the effective declaration has no rendered page.
802    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    /// Exact page and anchor for a qualified inherited-contract member reference.
810    ///
811    /// The outer option indicates whether the contract is an inherited base; the inner option is
812    /// absent when that base has no rendered page or the named member has no rendered heading.
813    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
830/// Escape a string for use as a markdown link label.
831///
832/// Prevents MDX from treating user-controlled NatSpec label text as JSX or
833/// breaking the surrounding markdown link syntax.
834fn escape_link_label(s: &str) -> String {
835    s.replace('{', "&#123;").replace('<', "&lt;").replace('[', "\\[").replace(']', "\\]")
836}
837
838/// Replace `{Ident}` and `{xref-Ident}` with markdown links using `name_to_page`.
839///
840/// Matches the legacy pattern: `{[xref-]Ident[-part]}[label]` where `label` defaults
841/// to `Ident`.
842///
843/// Resolution prefers lexical proximity: a reference naming a member of the current
844/// contract (`{member}`, or `{Contract-member}` where `Contract` is the current
845/// contract) becomes an anchor-only link within the page; everything else goes
846/// through the global `name_to_page` index.
847pub 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            // Try to parse {[xref-]Ident[-part]}[optional label].
860            if let Some((end, ident, part, label)) = parse_inline_link(&text[i..]) {
861                // Strip the leading `xref-` prefix if present.
862                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                // Same-contract references resolve to anchor-only links: a bare
870                // `{member}` documented on this page, or `{Contract-member}` where
871                // `Contract` is the contract being rendered.
872                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                    // Append the member anchor when the pattern is `{Type-member}`.
923                    // Sanitize to ASCII alphanumerics and `_` only, Solidity identifiers
924                    // never contain other characters, so this drops any injection attempt.
925                    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                        // default display: "Type.member"
934                        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                // Unresolved {Ident}, emit as inline code to avoid MDX treating it as a
945                // JS expression. Strip backticks to avoid breaking the fence.
946                let safe_name = lookup_name.replace('`', "'");
947                out.push_str(&format!("`{safe_name}`"));
948                i += end;
949                continue;
950            }
951            // Bare `{` with no matching `}`, escape it.
952            out.push_str("&#123;");
953            i += 1;
954            continue;
955        }
956
957        if bytes[i] == b'<' {
958            // Escape `<` that would be parsed as a JSX/HTML tag by MDX.
959            // A `<` is safe only when it's already part of a markdown link `<url>` or
960            // a standard HTML entity. We unconditionally escape to `&lt;` here
961            // since Solidity natspec does not produce markdown autolinks.
962            out.push_str("&lt;");
963            i += 1;
964            continue;
965        }
966
967        // Advance by the full UTF-8 character to avoid corrupting multi-byte sequences.
968        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(&param);
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, &params)
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
1018/// Parse `{[xref-]Ident[-part]}[label]` starting at offset 0 in `s`.
1019///
1020/// Returns `(consumed_bytes, ident, part, label)` on success.
1021fn 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    // inner = "[xref-]Ident[-part]"
1027    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; // '{' + inner + '}'
1048
1049    // Optional label: `[label]`
1050    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
1066// ── path helpers ──────────────────────────────────────────────────────────────
1067
1068/// Produce a vocs-style link from `page` relative to `current_page`.
1069///
1070/// vocs uses root-relative links (starting with `/`). Forward slashes are
1071/// always used so the URL stays correct on Windows.
1072fn page_link(page: &Path, _current_page: &Path) -> String {
1073    // Strip .mdx extension and produce an absolute path from the pages root.
1074    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        // Bare member reference -> anchor-only link.
1110        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        // `{Contract-member}` self-reference -> anchor-only link.
1119        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        // Unknown member still falls back to inline code.
1128        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        // Unknown qualified self-reference should not create a broken same-page anchor.
1137        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        // A top-level item elsewhere shares the member's name; lexical
1149        // proximity resolves to the same-page anchor, not the other page.
1150        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        // Without local context the global index still resolves.
1166        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}