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//! * `natspec_doc`: resolves effective NatSpec for a callable item.
7//! * `replace_inline_links`: rewrites `{Ident}` to markdown links.
8
9use 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// ── 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/// One rendered row of a public variable getter signature (its name, ABI type and the
256/// inherited description).
257pub struct GetterField {
258    pub name: Option<String>,
259    pub ty: String,
260    pub description: String,
261}
262
263/// Effective NatSpec for an item, resolved by Solar and aligned with the item's callable
264/// signature.
265pub 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
274/// Resolves explicit NatSpec inheritance, or Foundry's conservative implicit inheritance policy.
275pub 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, &parameter)| 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    // Solar handles ordinary explicit inheritance, including positional remapping. The
353    // recursive source fills only sections that Solar cannot see because they depend on
354    // Foundry's implicit policy at the exact source declaration.
355    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
464/// Returns the original tagged parent of a synthetic comment notice. The outer `Option`
465/// distinguishes a continuation from a standalone untagged notice; the inner value is `None` when
466/// Solar's resolved view omitted the parent because a local section replaced it.
467fn 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
538/// Renders a resolved type as its ABI signature string.
539fn 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
545/// Extracts function parameter types exactly as spelled in source for link anchors.
546fn 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
569/// Canonicalizes Solidity type aliases for generated link anchors.
570fn 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
747/// Strip the ` * ` block-comment line decoration from each line of a `/** */` NatSpec item's
748/// content. Solar preserves raw source bytes, so continuation lines look like ` * text` and blank
749/// separator lines look like ` *`. This normalises them to plain text / empty lines.
750pub(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// ── inline link replacement ───────────────────────────────────────────────────
765
766/// Members of the contract page currently being rendered.
767///
768/// Used to resolve `{member}` and `{Contract-member}` references lexically:
769/// a name that belongs to the current contract links to its heading anchor on
770/// the same page instead of going through the global name index (which only
771/// contains top-level items and could otherwise resolve to an unrelated page).
772#[derive(Debug)]
773pub struct LocalMembers {
774    /// The current contract's name.
775    name: String,
776    /// Member names with a heading (and thus an anchor) on the current page.
777    members: HashSet<String>,
778    /// Heading and exact signature anchors rendered on the current page.
779    anchors: HashSet<String>,
780    /// Effective inherited member names and their optional documentation pages.
781    inherited: HashMap<String, Option<PathBuf>>,
782    /// Inherited contracts and the members rendered on their exact pages.
783    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
792/// Record the heading and exact signature anchor for a rendered Solidity function.
793fn 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, &params));
811    Some(name)
812}
813
814impl LocalMembers {
815    /// Create an empty member set for the contract `name`.
816    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    /// Create a member set populated with members declared by base contracts.
827    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        // Solidity's linearization lists the current contract first, followed by bases in
832        // resolution order. Reserve the first inherited declaration even if its page is not
833        // rendered so a farther declaration cannot produce a confidently incorrect link.
834        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    /// Record a member that is rendered as a `### member` heading on the page.
890    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    /// Record an exact signature anchor rendered on the current page.
896    pub fn insert_anchor(&mut self, anchor: String) {
897        self.anchors.insert(anchor);
898    }
899
900    /// Anchor for a bare `{member}` reference, if `member` is documented on this page.
901    ///
902    /// Overloads share the base heading slug; the first heading owns it.
903    fn member_anchor(&self, member: &str) -> Option<String> {
904        self.members.contains(member).then(|| slug_anchor_segment(member))
905    }
906
907    /// Anchor for a qualified `{Contract-member[-params...]}` reference, if `member` is
908    /// documented on this page.
909    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    /// Page and anchor for a bare inherited-member reference.
915    ///
916    /// The outer option indicates whether the name is inherited; the inner option is absent when
917    /// the effective declaration has no rendered page.
918    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    /// Exact page and anchor for a qualified inherited-contract member reference.
926    ///
927    /// The outer option indicates whether the contract is an inherited base; the inner option is
928    /// absent when that base has no rendered page or the named member has no rendered heading.
929    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
946/// Escape a string for use as a markdown link label.
947///
948/// Prevents MDX from treating user-controlled NatSpec label text as JSX or
949/// breaking the surrounding markdown link syntax.
950fn escape_link_label(s: &str) -> String {
951    s.replace('{', "&#123;").replace('<', "&lt;").replace('[', "\\[").replace(']', "\\]")
952}
953
954/// Replace `{Ident}` and `{xref-Ident}` with markdown links using `name_to_page`.
955///
956/// Matches the legacy pattern: `{[xref-]Ident[-part]}[label]` where `label` defaults
957/// to `Ident`.
958///
959/// Resolution prefers lexical proximity: a reference naming a member of the current
960/// contract (`{member}`, or `{Contract-member}` where `Contract` is the current
961/// contract) becomes an anchor-only link within the page; everything else goes
962/// through the global `name_to_page` index.
963pub 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            // Try to parse {[xref-]Ident[-part]}[optional label].
976            if let Some((end, ident, part, label)) = parse_inline_link(&text[i..]) {
977                // Strip the leading `xref-` prefix if present.
978                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                // Same-contract references resolve to anchor-only links: a bare
986                // `{member}` documented on this page, or `{Contract-member}` where
987                // `Contract` is the contract being rendered.
988                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                    // Append the member anchor when the pattern is `{Type-member}`.
1039                    // Sanitize to ASCII alphanumerics and `_` only, Solidity identifiers
1040                    // never contain other characters, so this drops any injection attempt.
1041                    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                        // default display: "Type.member"
1050                        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                // Unresolved {Ident}, emit as inline code to avoid MDX treating it as a
1061                // JS expression. Strip backticks to avoid breaking the fence.
1062                let safe_name = lookup_name.replace('`', "'");
1063                out.push_str(&format!("`{safe_name}`"));
1064                i += end;
1065                continue;
1066            }
1067            // Bare `{` with no matching `}`, escape it.
1068            out.push_str("&#123;");
1069            i += 1;
1070            continue;
1071        }
1072
1073        if bytes[i] == b'<' {
1074            // Escape `<` that would be parsed as a JSX/HTML tag by MDX.
1075            // A `<` is safe only when it's already part of a markdown link `<url>` or
1076            // a standard HTML entity. We unconditionally escape to `&lt;` here
1077            // since Solidity natspec does not produce markdown autolinks.
1078            out.push_str("&lt;");
1079            i += 1;
1080            continue;
1081        }
1082
1083        // Advance by the full UTF-8 character to avoid corrupting multi-byte sequences.
1084        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(&param);
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, &params)
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
1134/// Parse `{[xref-]Ident[-part]}[label]` starting at offset 0 in `s`.
1135///
1136/// Returns `(consumed_bytes, ident, part, label)` on success.
1137fn 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    // inner = "[xref-]Ident[-part]"
1143    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; // '{' + inner + '}'
1164
1165    // Optional label: `[label]`
1166    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
1182// ── path helpers ──────────────────────────────────────────────────────────────
1183
1184/// Produce a vocs-style link from `page` relative to `current_page`.
1185///
1186/// vocs uses root-relative links (starting with `/`). Forward slashes are
1187/// always used so the URL stays correct on Windows.
1188fn page_link(page: &Path, _current_page: &Path) -> String {
1189    // Strip .mdx extension and produce an absolute path from the pages root.
1190    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        // Bare member reference -> anchor-only link.
1226        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        // `{Contract-member}` self-reference -> anchor-only link.
1235        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        // Unknown member still falls back to inline code.
1244        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        // Unknown qualified self-reference should not create a broken same-page anchor.
1253        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        // A top-level item elsewhere shares the member's name; lexical
1265        // proximity resolves to the same-page anchor, not the other page.
1266        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        // Without local context the global index still resolves.
1282        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}