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 crate::utils::{contract_kind_str, page_path, relative_source_path};
10use path_slash::PathBufExt;
11use solar::{
12    ast::{DataLocation, FunctionKind, ItemKind, NatSpecItem, NatSpecKind, Visibility},
13    interface::{Span, source_map::FileName},
14    sema::{
15        Gcx,
16        hir::{ContractId, FunctionId, Item, ItemId, VariableId},
17        ty::{Ty, TyAbiPrinter, TyAbiPrinterMode},
18    },
19};
20use std::{
21    collections::{HashMap, HashSet, hash_map::Entry},
22    path::{Path, PathBuf},
23};
24use tracing::warn;
25
26// ── name-to-page map ──────────────────────────────────────────────────────────
27
28/// Maps Solidity identifiers and HIR ids to their output MDX page paths
29/// relative to `pages/`.
30#[derive(Debug, Default)]
31pub struct NameToPage {
32    by_name: HashMap<String, Vec<PathBuf>>,
33    by_contract: HashMap<ContractId, PathBuf>,
34}
35
36impl NameToPage {
37    pub fn new() -> Self {
38        Self::default()
39    }
40
41    /// Candidate pages defined for a top-level identifier, if any.
42    pub fn get(&self, name: &str) -> Option<&Vec<PathBuf>> {
43        self.by_name.get(name)
44    }
45
46    /// Exact page for a contract id, if it lives in an allowed source.
47    pub fn get_contract(&self, id: ContractId) -> Option<&PathBuf> {
48        self.by_contract.get(&id)
49    }
50}
51
52/// Build the [`NameToPage`] index from HIR by re-deriving each item's output path.
53///
54/// This mirrors the path computation in `render::source` so links can be resolved
55/// before rendering begins.
56///
57/// Only items whose source file is contained in `allowed_sources` (absolute paths)
58/// are included, so cross-references cannot resolve to pages that won't be emitted.
59pub fn build_name_to_page(
60    gcx: Gcx<'_>,
61    root: &Path,
62    allowed_sources: &HashSet<PathBuf>,
63) -> NameToPage {
64    let mut map = NameToPage::new();
65
66    // Sort by source path and name to preserve deterministic candidate ordering.
67    let mut items = gcx
68        .hir
69        .item_ids()
70        .filter_map(|item_id| {
71            let item = gcx.hir.item(item_id);
72            let prefix = match item {
73                Item::Contract(c) => contract_kind_str(c.kind),
74                Item::Struct(_) => "struct",
75                Item::Enum(_) => "enum",
76                Item::Error(_) => "error",
77                Item::Event(_) => "event",
78                Item::Udvt(_) => "type",
79                Item::Function(_) | Item::Variable(_) => return None,
80            };
81            if item.contract().is_some() {
82                return None;
83            }
84            let FileName::Real(abs) = &gcx.hir.source(item.source()).file.name else {
85                return None;
86            };
87            let name = item.name()?;
88            allowed_sources
89                .contains(abs)
90                .then(|| (relative_source_path(root, abs), name, prefix, item_id))
91        })
92        .collect::<Vec<_>>();
93    items.sort_by(|(a, an, _, _), (b, bn, _, _)| {
94        a.to_string_lossy().cmp(&b.to_string_lossy()).then_with(|| an.as_str().cmp(bn.as_str()))
95    });
96
97    for (rel, name, prefix, item_id) in items {
98        let page = page_path(&rel, prefix, name.as_str());
99        let name_str = name.as_str().to_string();
100        let entry = map.by_name.entry(name_str.clone()).or_default();
101        if !entry.is_empty() {
102            warn!(
103                "forge doc: duplicate top-level name `{name_str}`; \
104                     cross-reference `{{{name_str}}}` will resolve by proximity to the referencing page"
105            );
106        }
107        entry.push(page.clone());
108
109        // Record exact contract -> page so inheritance / id-keyed lookups
110        // don't go through the ambiguous name index.
111        if let ItemId::Contract(cid) = item_id {
112            map.by_contract.insert(cid, page);
113        }
114    }
115
116    map
117}
118
119/// Pick the best candidate page for a given cross-reference lookup.
120///
121/// When only one candidate exists the choice is trivial. When multiple files define
122/// the same top-level name the page whose *directory* shares the longest common
123/// path prefix with `current_page` wins; ties fall back to the first entry (which
124/// is deterministic because `build_name_to_page` sorts before inserting).
125fn resolve_page<'a>(candidates: &'a [PathBuf], current_page: &Path) -> &'a PathBuf {
126    if candidates.len() == 1 {
127        return &candidates[0];
128    }
129    let current_dir = current_page.parent().unwrap_or(Path::new(""));
130    candidates
131        .iter()
132        .max_by_key(|page| {
133            let page_dir = page.parent().unwrap_or(Path::new(""));
134            current_dir.components().zip(page_dir.components()).take_while(|(a, b)| a == b).count()
135        })
136        .unwrap_or(&candidates[0])
137}
138
139// ── inheritance links ─────────────────────────────────────────────────────────
140
141/// Returns the `**Inherits:**` markdown string for a contract, or `None` if it has no bases.
142///
143/// Each base is either a bare name (when no page is known) or a markdown link.
144pub fn inheritance_links(
145    gcx: Gcx<'_>,
146    contract_id: ContractId,
147    name_to_page: &NameToPage,
148    current_page: &Path,
149) -> Option<String> {
150    let contract = gcx.hir.contract(contract_id);
151    if contract.bases.is_empty() {
152        return None;
153    }
154
155    let parts: Vec<String> = contract
156        .bases
157        .iter()
158        .map(|&base_id| {
159            let base = gcx.hir.contract(base_id);
160            let name = base.name.as_str();
161            // Prefer the exact base id; only fall back to the ambiguous name
162            // index when the base has no rendered page of its own.
163            if let Some(page) = name_to_page.get_contract(base_id) {
164                let link = page_link(page, current_page);
165                format!("[{name}]({link})")
166            } else if let Some(candidates) = name_to_page.get(name) {
167                let page = resolve_page(candidates, current_page);
168                let link = page_link(page, current_page);
169                format!("[{name}]({link})")
170            } else {
171                name.to_string()
172            }
173        })
174        .collect();
175
176    Some(format!("**Inherits:** {}", parts.join(", ")))
177}
178
179// ── inheritdoc resolution ─────────────────────────────────────────────────────
180
181/// One rendered row of a public variable getter signature (its name, ABI type and the
182/// inherited description).
183pub struct GetterField {
184    pub name: Option<String>,
185    pub ty: String,
186    pub description: String,
187}
188
189/// Effective NatSpec for an item, resolved by Solar and aligned with the item's callable
190/// signature.
191#[derive(Default)]
192pub struct NatSpecDoc {
193    pub notices: Vec<String>,
194    pub devs: Vec<String>,
195    pub params: Vec<String>,
196    pub returns: Vec<String>,
197    pub getter_params: Vec<GetterField>,
198    pub getter_returns: Vec<GetterField>,
199}
200
201/// Resolves explicit NatSpec inheritance, or Foundry's conservative implicit inheritance policy.
202pub fn natspec_doc(gcx: Gcx<'_>, item: ItemId, implicit: bool) -> Option<NatSpecDoc> {
203    let mut doc = effective_natspec_doc(gcx, item, implicit, &mut HashSet::new())?;
204    let callable = callable_function(gcx, item);
205    if let Some(fid) = callable
206        && matches!(item, ItemId::Variable(_))
207    {
208        let function = gcx.hir.function(fid);
209        doc.getter_params = function
210            .parameters
211            .iter()
212            .enumerate()
213            .map(|(index, &parameter)| getter_field(gcx, parameter, &doc.params[index]))
214            .collect();
215        doc.getter_returns = function
216            .returns
217            .iter()
218            .enumerate()
219            .map(|(index, &return_)| getter_field(gcx, return_, &doc.returns[index]))
220            .collect();
221    }
222    Some(doc)
223}
224
225fn effective_natspec_doc(
226    gcx: Gcx<'_>,
227    item: ItemId,
228    implicit: bool,
229    visited: &mut HashSet<ItemId>,
230) -> Option<NatSpecDoc> {
231    if !visited.insert(item) {
232        return None;
233    }
234
235    let hir_item = gcx.hir.item(item);
236    let raw = gcx.hir.doc(hir_item.doc()).ast_comments();
237    let has_local = raw.iter().any(|comment| !comment.natspec.is_empty());
238    if !has_local {
239        if !implicit {
240            return Some(empty_natspec_doc(gcx, item));
241        }
242        let bases = direct_base_items(gcx, item);
243        let [base] = bases.as_slice() else { return None };
244        if !implicit_edge_compatible(gcx, item, *base)
245            || (!matches!(item, ItemId::Variable(_)) && !parameter_names_equal(gcx, item, *base))
246        {
247            return None;
248        }
249        return effective_natspec_doc(gcx, *base, true, visited);
250    }
251
252    let mut local = doc_from_view(gcx, item);
253    let mut inheritdoc = None;
254    let mut local_notice = false;
255    let mut local_dev = false;
256    let mut local_param = false;
257    let mut local_return = false;
258    let raw_items =
259        raw.iter().flat_map(|comment| comment.natspec.iter().copied()).collect::<Vec<_>>();
260    for entry in &raw_items {
261        match entry.kind {
262            NatSpecKind::Inheritdoc { contract } if inheritdoc.is_none() => {
263                inheritdoc = Some(contract)
264            }
265            NatSpecKind::Notice if continuation_parent(gcx, &raw_items, entry).is_none() => {
266                local_notice = true
267            }
268            NatSpecKind::Dev => local_dev = true,
269            NatSpecKind::Param { .. } => local_param = true,
270            NatSpecKind::Return { .. } => local_return = true,
271            _ => {}
272        }
273    }
274    let Some(alias) = inheritdoc else { return Some(local) };
275    let contract = gcx.natspec_contract(alias.name, hir_item.source())?;
276    let source = exact_override_item(gcx, item, contract, &mut HashSet::new())?;
277    let inherited = effective_natspec_doc(gcx, source, true, visited)?;
278
279    // Solar handles ordinary explicit inheritance, including positional remapping. The
280    // recursive source fills only sections that Solar cannot see because they depend on
281    // Foundry's implicit policy at the exact source declaration.
282    if !local_notice {
283        local.notices = inherited.notices;
284    }
285    if !local_dev {
286        local.devs = inherited.devs;
287    }
288    if !local_param {
289        for (description, inherited) in local.params.iter_mut().zip(inherited.params) {
290            *description = inherited;
291        }
292    }
293    if !local_return {
294        for (description, inherited) in local.returns.iter_mut().zip(inherited.returns) {
295            *description = inherited;
296        }
297    }
298    Some(local)
299}
300
301fn empty_natspec_doc(gcx: Gcx<'_>, item: ItemId) -> NatSpecDoc {
302    let (params, returns) = callable_function(gcx, item)
303        .map(|id| {
304            let function = gcx.hir.function(id);
305            (
306                vec![String::new(); function.parameters.len()],
307                vec![String::new(); function.returns.len()],
308            )
309        })
310        .unwrap_or_default();
311    NatSpecDoc { params, returns, ..Default::default() }
312}
313
314fn doc_from_view(gcx: Gcx<'_>, item: ItemId) -> NatSpecDoc {
315    let view = gcx.natspec_view(item);
316    let mut doc = empty_natspec_doc(gcx, item);
317    for natspec in view.items() {
318        if continuation_parent(gcx, view.items(), natspec).is_some() {
319            continue;
320        }
321        let content = positional_description(gcx, view.items(), std::slice::from_ref(natspec));
322        match natspec.kind {
323            NatSpecKind::Notice => doc.notices.push(content),
324            NatSpecKind::Dev => doc.devs.push(content),
325            _ => {}
326        }
327    }
328    if let Some(fid) = callable_function(gcx, item) {
329        let function = gcx.hir.function(fid);
330        for (index, param) in doc.params[..function.parameters.len()].iter_mut().enumerate() {
331            *param = positional_description(gcx, view.items(), view.parameter(index));
332        }
333        for (index, return_) in doc.returns[..function.returns.len()].iter_mut().enumerate() {
334            *return_ = positional_description(gcx, view.items(), view.return_(index));
335        }
336    }
337    doc
338}
339
340fn positional_description(
341    gcx: Gcx<'_>,
342    all_items: &[NatSpecItem],
343    items: &[NatSpecItem],
344) -> String {
345    items
346        .iter()
347        .map(|item| {
348            let mut content = normalized_natspec_content(gcx, item);
349            for continuation in all_items.iter().filter(|candidate| {
350                continuation_parent(gcx, all_items, candidate) == Some(Some(item.span))
351            }) {
352                content.push('\n');
353                content.push_str(&normalized_natspec_content(gcx, continuation));
354            }
355            content
356        })
357        .collect::<Vec<_>>()
358        .join("\n")
359}
360
361fn normalized_natspec_content(gcx: Gcx<'_>, item: &NatSpecItem) -> String {
362    let source_map = gcx.sess.source_map();
363    let snippet = source_map.span_to_snippet(item.span).ok();
364    if snippet.as_deref().is_some_and(|snippet| snippet.starts_with("///")) {
365        return item.content().trim().to_string();
366    }
367    if snippet.as_deref().is_some_and(|snippet| snippet.starts_with("/**")) {
368        return clean_block_doc_content(item.content()).trim().to_string();
369    }
370    item.content()
371        .lines()
372        .enumerate()
373        .map(
374            |(index, line)| {
375                if index == 0 { line.to_string() } else { clean_block_doc_content(line) }
376            },
377        )
378        .collect::<Vec<_>>()
379        .join("\n")
380        .trim()
381        .to_string()
382}
383
384/// Returns the original tagged parent of a synthetic comment notice. The outer `Option`
385/// distinguishes a continuation from a standalone untagged notice; the inner value is `None` when
386/// Solar's resolved view omitted the parent because a local section replaced it.
387fn continuation_parent(
388    gcx: Gcx<'_>,
389    all_items: &[NatSpecItem],
390    item: &NatSpecItem,
391) -> Option<Option<Span>> {
392    if !matches!(item.kind, NatSpecKind::Notice) {
393        return None;
394    }
395
396    let source_map = gcx.sess.source_map();
397    let snippet = source_map.span_to_snippet(item.span).ok()?;
398    let location = source_map.lookup_char_pos(item.span.lo());
399    if snippet.starts_with("/**") {
400        let index = all_items.iter().position(|candidate| candidate.span == item.span)?;
401        let previous = all_items.get(index.checked_sub(1)?)?;
402        if !matches!(
403            previous.kind,
404            NatSpecKind::Notice
405                | NatSpecKind::Dev
406                | NatSpecKind::Param { .. }
407                | NatSpecKind::Return { .. }
408        ) {
409            return None;
410        }
411        let previous_location = source_map.lookup_char_pos(previous.span.lo());
412        if location.line != previous_location.line + 1
413            || !std::sync::Arc::ptr_eq(&location.file, &previous_location.file)
414        {
415            return None;
416        }
417        return Some(continuation_parent(gcx, all_items, previous).unwrap_or(Some(previous.span)));
418    }
419    if !snippet.starts_with("///") {
420        return None;
421    }
422
423    let mut previous_line = location.line.checked_sub(2)?;
424    loop {
425        let line = location.file.get_line(previous_line)?.trim_start();
426        let content = line.strip_prefix("///")?;
427        if content.trim().is_empty() {
428            return None;
429        }
430        let Some(tag) = content.trim_start().strip_prefix('@') else {
431            previous_line = previous_line.checked_sub(1)?;
432            continue;
433        };
434        if !matches!(tag.split_whitespace().next(), Some("notice" | "dev" | "param" | "return")) {
435            return None;
436        }
437        return Some(
438            all_items
439                .iter()
440                .find(|candidate| {
441                    let candidate_location = source_map.lookup_char_pos(candidate.span.lo());
442                    candidate_location.line == previous_line + 1
443                        && std::sync::Arc::ptr_eq(&candidate_location.file, &location.file)
444                })
445                .map(|parent| parent.span),
446        );
447    }
448}
449
450fn getter_field(gcx: Gcx<'_>, variable: VariableId, description: &str) -> GetterField {
451    GetterField {
452        name: gcx.hir.variable(variable).name.map(|name| name.as_str().to_string()),
453        ty: render_ty(gcx, gcx.type_of_item(variable.into())),
454        description: description.to_string(),
455    }
456}
457
458/// Renders a resolved type as its ABI signature string.
459fn render_ty<'a>(gcx: Gcx<'a>, ty: Ty<'a>) -> String {
460    let mut out = String::new();
461    let _ = TyAbiPrinter::new(gcx, &mut out, TyAbiPrinterMode::Signature).print(ty);
462    out
463}
464
465/// Extracts function parameter types exactly as spelled in source for link anchors.
466fn function_source_param_types(gcx: Gcx<'_>, fid: FunctionId) -> Option<Vec<String>> {
467    let f = gcx.hir.function(fid);
468    let ast = gcx.sources.get(f.source)?.ast.as_ref()?;
469    let params = ast.items.iter().find_map(|item| match &item.kind {
470        ItemKind::Function(func) if item.span == f.span => Some(&func.header.parameters),
471        ItemKind::Contract(c) => c.body.iter().find_map(|member| match &member.kind {
472            ItemKind::Function(func) if member.span == f.span => Some(&func.header.parameters),
473            _ => None,
474        }),
475        _ => None,
476    })?;
477    let sm = gcx.sess.source_map();
478    Some(
479        params
480            .vars
481            .iter()
482            .map(|variable| {
483                sm.span_to_snippet(variable.ty.span).unwrap_or_default().trim().to_string()
484            })
485            .collect(),
486    )
487}
488
489/// Canonicalizes Solidity type aliases for generated link anchors.
490fn normalize_sol_type(t: &str) -> String {
491    let bytes = t.as_bytes();
492    let len = bytes.len();
493    let mut out = String::with_capacity(len + 8);
494    let mut i = 0;
495    while i < len {
496        if bytes[i..].starts_with(b"uint") && !bytes.get(i + 4).is_some_and(u8::is_ascii_digit) {
497            out.push_str("uint256");
498            i += 4;
499        } else if bytes[i..].starts_with(b"int")
500            && !bytes.get(i + 3).is_some_and(u8::is_ascii_digit)
501        {
502            out.push_str("int256");
503            i += 3;
504        } else if let Some(ch) = t[i..].chars().next() {
505            out.push(ch);
506            i += ch.len_utf8();
507        } else {
508            break;
509        }
510    }
511    out
512}
513
514fn callable_function(gcx: Gcx<'_>, item: ItemId) -> Option<FunctionId> {
515    match item {
516        ItemId::Function(id) => Some(id),
517        ItemId::Variable(id) => gcx.hir.variable(id).getter,
518        _ => None,
519    }
520}
521
522fn exact_override_item(
523    gcx: Gcx<'_>,
524    item: ItemId,
525    owner: ContractId,
526    visited: &mut HashSet<ItemId>,
527) -> Option<ItemId> {
528    if !visited.insert(item) {
529        return None;
530    }
531    if gcx.hir.item(item).contract() == Some(owner) {
532        return Some(item);
533    }
534    for &base in gcx.base_override_items(item) {
535        if let Some(found) = exact_override_item(gcx, base, owner, visited) {
536            return Some(found);
537        }
538    }
539    None
540}
541
542fn direct_base_items(gcx: Gcx<'_>, item: ItemId) -> Vec<ItemId> {
543    let mut bases = Vec::new();
544    let mut visited = HashSet::new();
545    for &base in gcx.base_override_items(item) {
546        push_non_yul_base_items(gcx, base, &mut bases, &mut visited);
547    }
548    bases
549}
550
551fn push_non_yul_base_items(
552    gcx: Gcx<'_>,
553    item: ItemId,
554    bases: &mut Vec<ItemId>,
555    visited: &mut HashSet<ItemId>,
556) {
557    if !visited.insert(item) {
558        return;
559    }
560    if matches!(item, ItemId::Function(id) if gcx.hir.function(id).is_yul) {
561        for &base in gcx.base_override_items(item) {
562            push_non_yul_base_items(gcx, base, bases, visited);
563        }
564    } else {
565        bases.push(item);
566    }
567}
568
569fn parameter_names_equal(gcx: Gcx<'_>, target: ItemId, base: ItemId) -> bool {
570    let names = |item| {
571        callable_function(gcx, item).map(|id| {
572            gcx.hir
573                .function(id)
574                .parameters
575                .iter()
576                .map(|&id| gcx.hir.variable(id).name.map(|name| name.name))
577                .collect::<Vec<_>>()
578        })
579    };
580    names(target) == names(base)
581}
582
583fn implicit_edge_compatible(gcx: Gcx<'_>, target: ItemId, base: ItemId) -> bool {
584    let (Some(target_id), Some(base_id)) =
585        (callable_function(gcx, target), callable_function(gcx, base))
586    else {
587        return false;
588    };
589    let target_fn = gcx.hir.function(target_id);
590    let base_fn = gcx.hir.function(base_id);
591    if !base_fn.virtual_
592        || target_fn.visibility == Visibility::Private
593        || base_fn.visibility == Visibility::Private
594        || (target_fn.body.is_none() && base_fn.body.is_some())
595    {
596        return false;
597    }
598    let visibility_ok = if matches!(target, ItemId::Variable(_)) {
599        base_fn.visibility == Visibility::External
600    } else {
601        target_fn.visibility == base_fn.visibility
602            || (base_fn.visibility == Visibility::External
603                && target_fn.visibility == Visibility::Public)
604    };
605    let target_mutability = match target {
606        ItemId::Variable(id) if gcx.hir.variable(id).is_constant() => {
607            solar::ast::StateMutability::Pure
608        }
609        _ => target_fn.state_mutability,
610    };
611    let mutability_ok = target_mutability == base_fn.state_mutability
612        || matches!(
613            (target_mutability, base_fn.state_mutability),
614            (
615                solar::ast::StateMutability::Pure,
616                solar::ast::StateMutability::View | solar::ast::StateMutability::NonPayable
617            ) | (solar::ast::StateMutability::View, solar::ast::StateMutability::NonPayable)
618        );
619    if !visibility_ok || !mutability_ok {
620        return false;
621    }
622    let types_equal = |left: &[VariableId], right: &[VariableId], locations: bool| {
623        left.len() == right.len()
624            && left.iter().zip(right).all(|(&left, &right)| {
625                let left = gcx.type_of_item(left.into());
626                let right = gcx.type_of_item(right.into());
627                left.peel_refs() == right.peel_refs() && (!locations || left.loc() == right.loc())
628            })
629    };
630    let external_types_equal = |left: &[VariableId], right: &[VariableId]| {
631        let normalize = |location| match location {
632            Some(DataLocation::Calldata) => Some(DataLocation::Memory),
633            location => location,
634        };
635        left.len() == right.len()
636            && left.iter().zip(right).all(|(&left, &right)| {
637                let left_variable = gcx.hir.variable(left);
638                let right_variable = gcx.hir.variable(right);
639                let left = gcx.type_of_item(left.into());
640                let right = gcx.type_of_item(right.into());
641                let left_location = left_variable.data_location.or_else(|| left.loc());
642                let right_location = right_variable.data_location.or_else(|| right.loc());
643                left.peel_refs() == right.peel_refs()
644                    && normalize(left_location) == normalize(right_location)
645            })
646    };
647    let target_returns =
648        gcx.type_of_item(target_id.into()).as_externally_callable_function(false, gcx).returns();
649    let base_returns =
650        gcx.type_of_item(base_id.into()).as_externally_callable_function(false, gcx).returns();
651    if target_returns != base_returns || !external_types_equal(target_fn.returns, base_fn.returns) {
652        return false;
653    }
654    if target_fn.kind == FunctionKind::Modifier {
655        return types_equal(target_fn.parameters, base_fn.parameters, false);
656    }
657    if base_fn.visibility == Visibility::External {
658        external_types_equal(target_fn.parameters, base_fn.parameters)
659    } else {
660        types_equal(target_fn.parameters, base_fn.parameters, true)
661            && types_equal(target_fn.returns, base_fn.returns, true)
662    }
663}
664
665/// Strip the ` * ` block-comment line decoration from each line of a `/** */` NatSpec item's
666/// content. Solar preserves raw source bytes, so continuation lines look like ` * text` and blank
667/// separator lines look like ` *`. This normalises them to plain text / empty lines.
668pub(crate) fn clean_block_doc_content(raw: &str) -> String {
669    raw.lines()
670        .map(|line| {
671            let t = line.trim_start();
672            if let Some(rest) = t.strip_prefix('*') {
673                rest.strip_prefix(' ').unwrap_or(rest)
674            } else {
675                line
676            }
677        })
678        .collect::<Vec<_>>()
679        .join("\n")
680}
681
682// ── inline link replacement ───────────────────────────────────────────────────
683
684/// Members of the contract page currently being rendered.
685///
686/// Used to resolve `{member}` and `{Contract-member}` references lexically:
687/// a name that belongs to the current contract links to its heading anchor on
688/// the same page instead of going through the global name index (which only
689/// contains top-level items and could otherwise resolve to an unrelated page).
690#[derive(Debug)]
691pub struct LocalMembers {
692    /// The current contract's name.
693    name: String,
694    /// Member names with a heading (and thus an anchor) on the current page.
695    members: HashSet<String>,
696    /// Heading and exact signature anchors rendered on the current page.
697    anchors: HashSet<String>,
698    /// Effective inherited member names and their optional documentation pages.
699    inherited: HashMap<String, Option<PathBuf>>,
700    /// Inherited contracts and the members rendered on their exact pages.
701    inherited_contracts: HashMap<String, InheritedContract>,
702}
703
704#[derive(Debug)]
705enum InheritedContract {
706    Unique { id: ContractId, page: Option<PathBuf>, anchors: HashSet<String> },
707    Ambiguous,
708}
709
710/// Record the heading and exact signature anchor for a rendered Solidity function.
711fn insert_function_anchors(
712    gcx: Gcx<'_>,
713    id: FunctionId,
714    anchors: &mut HashSet<String>,
715) -> Option<String> {
716    let function = gcx.hir.function(id);
717    if function.is_yul || function.is_getter() {
718        return None;
719    }
720    let params = function_source_param_types(gcx, id)?;
721    let name = match function.kind {
722        FunctionKind::Constructor => "constructor".to_string(),
723        FunctionKind::Fallback => "fallback".to_string(),
724        FunctionKind::Receive => "receive".to_string(),
725        FunctionKind::Function | FunctionKind::Modifier => function.name?.as_str().to_string(),
726    };
727    anchors.insert(slug_anchor_segment(&name));
728    anchors.insert(function_signature_anchor(&name, &params));
729    Some(name)
730}
731
732impl LocalMembers {
733    /// Create an empty member set for the contract `name`.
734    pub fn new(name: &str) -> Self {
735        Self {
736            name: name.to_string(),
737            members: HashSet::new(),
738            anchors: HashSet::new(),
739            inherited: HashMap::new(),
740            inherited_contracts: HashMap::new(),
741        }
742    }
743
744    /// Create a member set populated with members declared by base contracts.
745    pub fn for_contract(gcx: Gcx<'_>, contract_id: ContractId, name_to_page: &NameToPage) -> Self {
746        let contract = gcx.hir.contract(contract_id);
747        let mut this = Self::new(contract.name.as_str());
748
749        // Solidity's linearization lists the current contract first, followed by bases in
750        // resolution order. Reserve the first inherited declaration even if its page is not
751        // rendered so a farther declaration cannot produce a confidently incorrect link.
752        for &base_id in contract.linearized_bases.iter().filter(|&&id| id != contract_id) {
753            let base = gcx.hir.contract(base_id);
754            let page = name_to_page.get_contract(base_id).cloned();
755            let mut anchors = HashSet::new();
756
757            for &item_id in base.items {
758                let (name, is_inherited) = match item_id {
759                    ItemId::Function(id) => {
760                        let function = gcx.hir.function(id);
761                        (
762                            insert_function_anchors(gcx, id, &mut anchors),
763                            function.visibility != Visibility::Private
764                                && function.kind != FunctionKind::Constructor,
765                        )
766                    }
767                    ItemId::Variable(id) => {
768                        let variable = gcx.hir.variable(id);
769                        (
770                            variable.name.map(|name| name.as_str().to_string()),
771                            variable.visibility != Some(Visibility::Private),
772                        )
773                    }
774                    ItemId::Struct(id) => {
775                        (Some(gcx.hir.strukt(id).name.as_str().to_string()), true)
776                    }
777                    ItemId::Enum(id) => (Some(gcx.hir.enumm(id).name.as_str().to_string()), true),
778                    ItemId::Error(id) => (Some(gcx.hir.error(id).name.as_str().to_string()), true),
779                    ItemId::Event(id) => (Some(gcx.hir.event(id).name.as_str().to_string()), true),
780                    ItemId::Udvt(id) => (Some(gcx.hir.udvt(id).name.as_str().to_string()), true),
781                    ItemId::Contract(_) => (None, false),
782                };
783                if let Some(name) = name {
784                    anchors.insert(slug_anchor_segment(&name));
785                    if is_inherited {
786                        this.inherited.entry(name).or_insert_with(|| page.clone());
787                    }
788                }
789            }
790
791            match this.inherited_contracts.entry(base.name.as_str().to_string()) {
792                Entry::Vacant(entry) => {
793                    entry.insert(InheritedContract::Unique { id: base_id, page, anchors });
794                }
795                Entry::Occupied(mut entry) => {
796                    if matches!(entry.get(), InheritedContract::Unique { id, .. } if *id != base_id)
797                    {
798                        entry.insert(InheritedContract::Ambiguous);
799                    }
800                }
801            }
802        }
803
804        this
805    }
806
807    /// Record a member that is rendered as a `### member` heading on the page.
808    pub fn insert(&mut self, member: &str) {
809        self.members.insert(member.to_string());
810        self.anchors.insert(slug_anchor_segment(member));
811    }
812
813    /// Record an exact signature anchor rendered on the current page.
814    pub fn insert_anchor(&mut self, anchor: String) {
815        self.anchors.insert(anchor);
816    }
817
818    /// Anchor for a bare `{member}` reference, if `member` is documented on this page.
819    ///
820    /// Overloads share the base heading slug; the first heading owns it.
821    fn member_anchor(&self, member: &str) -> Option<String> {
822        self.members.contains(member).then(|| slug_anchor_segment(member))
823    }
824
825    /// Anchor for a qualified `{Contract-member[-params...]}` reference, if `member` is
826    /// documented on this page.
827    fn xref_member_anchor(&self, part: &str) -> Option<String> {
828        let anchor = xref_part_anchor(part);
829        self.anchors.contains(&anchor).then_some(anchor)
830    }
831
832    /// Page and anchor for a bare inherited-member reference.
833    ///
834    /// The outer option indicates whether the name is inherited; the inner option is absent when
835    /// the effective declaration has no rendered page.
836    fn inherited_member_link(&self, member: &str, current_page: &Path) -> Option<Option<String>> {
837        let page = self.inherited.get(member)?;
838        Some(page.as_ref().map(|page| {
839            format!("{}#{}", page_link(page, current_page), slug_anchor_segment(member))
840        }))
841    }
842
843    /// Exact page and anchor for a qualified inherited-contract member reference.
844    ///
845    /// The outer option indicates whether the contract is an inherited base; the inner option is
846    /// absent when that base has no rendered page or the named member has no rendered heading.
847    fn inherited_contract_member_link(
848        &self,
849        contract: &str,
850        part: &str,
851        current_page: &Path,
852    ) -> Option<Option<String>> {
853        let base = self.inherited_contracts.get(contract)?;
854        let InheritedContract::Unique { page, anchors, .. } = base else {
855            return Some(None);
856        };
857        let anchor = xref_part_anchor(part);
858        Some(page.as_ref().and_then(|page| {
859            anchors.contains(&anchor).then(|| format!("{}#{anchor}", page_link(page, current_page)))
860        }))
861    }
862}
863
864/// Escape a string for use as a markdown link label.
865///
866/// Prevents MDX from treating user-controlled NatSpec label text as JSX or
867/// breaking the surrounding markdown link syntax.
868fn escape_link_label(s: &str) -> String {
869    s.replace('{', "&#123;").replace('<', "&lt;").replace('[', "\\[").replace(']', "\\]")
870}
871
872/// Replace `{Ident}` and `{xref-Ident}` with markdown links using `name_to_page`.
873///
874/// Matches the legacy pattern: `{[xref-]Ident[-part]}[label]` where `label` defaults
875/// to `Ident`.
876///
877/// Resolution prefers lexical proximity: a reference naming a member of the current
878/// contract (`{member}`, or `{Contract-member}` where `Contract` is the current
879/// contract) becomes an anchor-only link within the page; everything else goes
880/// through the global `name_to_page` index.
881pub fn replace_inline_links(
882    text: &str,
883    name_to_page: &NameToPage,
884    current_page: &Path,
885    local: Option<&LocalMembers>,
886) -> String {
887    let mut out = String::with_capacity(text.len());
888    let bytes = text.as_bytes();
889    let mut i = 0;
890
891    while i < bytes.len() {
892        if bytes[i] == b'{' {
893            // Try to parse {[xref-]Ident[-part]}[optional label].
894            if let Some((end, ident, part, label)) = parse_inline_link(&text[i..]) {
895                // Strip the leading `xref-` prefix if present.
896                let lookup_name = ident.strip_prefix("xref-").unwrap_or(ident);
897                let lookup_name = if let Some(pos) = lookup_name.find('-') {
898                    &lookup_name[..pos]
899                } else {
900                    lookup_name
901                };
902
903                // Same-contract references resolve to anchor-only links: a bare
904                // `{member}` documented on this page, or `{Contract-member}` where
905                // `Contract` is the contract being rendered.
906                if let Some(local) = local {
907                    let local_anchor = match part {
908                        None => local.member_anchor(lookup_name).map(Some),
909                        Some(member) if lookup_name == local.name => {
910                            Some(local.xref_member_anchor(member))
911                        }
912                        Some(_) => None,
913                    };
914                    if let Some(anchor) = local_anchor {
915                        if let Some(anchor) = anchor {
916                            let default_display = match part {
917                                Some(member) => format!("{lookup_name}.{member}"),
918                                None => lookup_name.to_string(),
919                            };
920                            let display = escape_link_label(label.unwrap_or(&default_display));
921                            out.push_str(&format!("[{display}](#{anchor})"));
922                        } else {
923                            let safe_name = lookup_name.replace('`', "'");
924                            out.push_str(&format!("`{safe_name}`"));
925                        }
926                        i += end;
927                        continue;
928                    }
929
930                    let inherited_link = match part {
931                        None => local.inherited_member_link(lookup_name, current_page),
932                        Some(member) => {
933                            local.inherited_contract_member_link(lookup_name, member, current_page)
934                        }
935                    };
936                    if let Some(link) = inherited_link {
937                        if let Some(link) = link {
938                            let default_display = match part {
939                                Some(member) => format!("{lookup_name}.{member}"),
940                                None => lookup_name.to_string(),
941                            };
942                            let display = escape_link_label(label.unwrap_or(&default_display));
943                            out.push_str(&format!("[{display}]({link})"));
944                        } else {
945                            let safe_name = lookup_name.replace('`', "'");
946                            out.push_str(&format!("`{safe_name}`"));
947                        }
948                        i += end;
949                        continue;
950                    }
951                }
952
953                if let Some(candidates) = name_to_page.get(lookup_name) {
954                    let page = resolve_page(candidates, current_page);
955                    let mut link = page_link(page, current_page);
956                    // Append the member anchor when the pattern is `{Type-member}`.
957                    // Sanitize to ASCII alphanumerics and `_` only, Solidity identifiers
958                    // never contain other characters, so this drops any injection attempt.
959                    if let Some(member) = part {
960                        let safe_member = xref_part_anchor(member);
961                        if !safe_member.is_empty() {
962                            link.push('#');
963                            link.push_str(&safe_member);
964                        }
965                    }
966                    let default_display = if let Some(member) = part {
967                        // default display: "Type.member"
968                        format!("{lookup_name}.{member}")
969                    } else {
970                        lookup_name.to_string()
971                    };
972                    let display = escape_link_label(label.unwrap_or(&default_display));
973                    out.push_str(&format!("[{display}]({link})"));
974                    i += end;
975                    continue;
976                }
977
978                // Unresolved {Ident}, emit as inline code to avoid MDX treating it as a
979                // JS expression. Strip backticks to avoid breaking the fence.
980                let safe_name = lookup_name.replace('`', "'");
981                out.push_str(&format!("`{safe_name}`"));
982                i += end;
983                continue;
984            }
985            // Bare `{` with no matching `}`, escape it.
986            out.push_str("&#123;");
987            i += 1;
988            continue;
989        }
990
991        if bytes[i] == b'<' {
992            // Escape `<` that would be parsed as a JSX/HTML tag by MDX.
993            // A `<` is safe only when it's already part of a markdown link `<url>` or
994            // a standard HTML entity. We unconditionally escape to `&lt;` here
995            // since Solidity natspec does not produce markdown autolinks.
996            out.push_str("&lt;");
997            i += 1;
998            continue;
999        }
1000
1001        // Advance by the full UTF-8 character to avoid corrupting multi-byte sequences.
1002        let ch = text[i..].chars().next().unwrap();
1003        out.push(ch);
1004        i += ch.len_utf8();
1005    }
1006
1007    out
1008}
1009
1010pub(crate) fn function_signature_anchor(name: &str, params: &[String]) -> String {
1011    let mut anchor = slug_anchor_segment(name);
1012    for param in params {
1013        let param = slug_anchor_segment(&normalize_sol_type(param));
1014        if !param.is_empty() {
1015            anchor.push('-');
1016            anchor.push_str(&param);
1017        }
1018    }
1019    anchor
1020}
1021
1022fn xref_part_anchor(part: &str) -> String {
1023    let mut pieces = part.split('-').filter(|piece| !piece.is_empty());
1024    let Some(member) = pieces.next() else {
1025        return String::new();
1026    };
1027    let params = pieces.map(|piece| piece.to_string()).collect::<Vec<_>>();
1028    function_signature_anchor(member, &params)
1029}
1030
1031fn slug_anchor_segment(s: &str) -> String {
1032    let mut out = String::with_capacity(s.len());
1033    let mut last_was_dash = false;
1034
1035    for ch in s.chars().flat_map(char::to_lowercase) {
1036        if ch.is_ascii_alphanumeric() || ch == '_' {
1037            out.push(ch);
1038            last_was_dash = false;
1039        } else if ch != '$' && !last_was_dash && !out.is_empty() {
1040            out.push('-');
1041            last_was_dash = true;
1042        }
1043    }
1044
1045    if last_was_dash {
1046        out.pop();
1047    }
1048
1049    out
1050}
1051
1052/// Parse `{[xref-]Ident[-part]}[label]` starting at offset 0 in `s`.
1053///
1054/// Returns `(consumed_bytes, ident, part, label)` on success.
1055fn parse_inline_link(s: &str) -> Option<(usize, &str, Option<&str>, Option<&str>)> {
1056    let s = s.strip_prefix('{')?;
1057    let close = s.find('}')?;
1058    let inner = &s[..close];
1059
1060    // inner = "[xref-]Ident[-part]"
1061    let (raw_ident, raw_part) = if let Some(rest) = inner.strip_prefix("xref-") {
1062        if let Some(dash) = rest.find('-') {
1063            (&inner[..("xref-".len() + dash)], Some(&rest[dash + 1..]))
1064        } else {
1065            (inner, None)
1066        }
1067    } else if let Some(dash) = inner.find('-') {
1068        let candidate_ident = &inner[..dash];
1069        let candidate_part = &inner[dash + 1..];
1070        if candidate_ident.chars().all(|c| c.is_alphanumeric() || c == '_')
1071            && !candidate_part.is_empty()
1072        {
1073            (candidate_ident, Some(candidate_part))
1074        } else {
1075            (inner, None)
1076        }
1077    } else {
1078        (inner, None)
1079    };
1080
1081    let mut consumed = 1 + close + 1; // '{' + inner + '}'
1082
1083    // Optional label: `[label]`
1084    let rest = &s[close + 1..];
1085    let label = if rest.starts_with('[') {
1086        if let Some(end) = rest.find(']') {
1087            let lbl = &rest[1..end];
1088            consumed += end + 1;
1089            Some(lbl)
1090        } else {
1091            None
1092        }
1093    } else {
1094        None
1095    };
1096
1097    Some((consumed, raw_ident, raw_part, label))
1098}
1099
1100// ── path helpers ──────────────────────────────────────────────────────────────
1101
1102/// Produce a vocs-style link from `page` relative to `current_page`.
1103///
1104/// vocs uses root-relative links (starting with `/`). Forward slashes are
1105/// always used so the URL stays correct on Windows.
1106fn page_link(page: &Path, _current_page: &Path) -> String {
1107    // Strip .mdx extension and produce an absolute path from the pages root.
1108    let without_ext = page.with_extension("");
1109    format!("/{}", without_ext.to_slash_lossy())
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114    use super::*;
1115
1116    #[test]
1117    fn full_signature_xref_links_member_anchor() {
1118        let mut name_to_page = NameToPage::new();
1119        name_to_page
1120            .by_name
1121            .insert("ERC721".to_string(), vec![PathBuf::from("src/contract.ERC721.mdx")]);
1122
1123        let out = replace_inline_links(
1124            "See {xref-ERC721-_safeMint-address-uint256-}.",
1125            &name_to_page,
1126            Path::new("src/contract.Child.mdx"),
1127            None,
1128        );
1129
1130        assert_eq!(
1131            out,
1132            "See [ERC721._safeMint-address-uint256-](/src/contract.ERC721#_safemint-address-uint256)."
1133        );
1134    }
1135
1136    #[test]
1137    fn same_contract_member_links_anchor_only() {
1138        let name_to_page = NameToPage::new();
1139        let mut local = LocalMembers::new("ECDSA");
1140        local.insert("toEthSignedMessageHash");
1141        local.insert("tryRecover");
1142
1143        // Bare member reference -> anchor-only link.
1144        let out = replace_inline_links(
1145            "then calling {toEthSignedMessageHash} on it.",
1146            &name_to_page,
1147            Path::new("src/library.ECDSA.mdx"),
1148            Some(&local),
1149        );
1150        assert_eq!(out, "then calling [toEthSignedMessageHash](#toethsignedmessagehash) on it.");
1151
1152        // `{Contract-member}` self-reference -> anchor-only link.
1153        let out = replace_inline_links(
1154            "Overload of {ECDSA-tryRecover} that ...",
1155            &name_to_page,
1156            Path::new("src/library.ECDSA.mdx"),
1157            Some(&local),
1158        );
1159        assert_eq!(out, "Overload of [ECDSA.tryRecover](#tryrecover) that ...");
1160
1161        // Unknown member still falls back to inline code.
1162        let out = replace_inline_links(
1163            "See {unknownMember}.",
1164            &name_to_page,
1165            Path::new("src/library.ECDSA.mdx"),
1166            Some(&local),
1167        );
1168        assert_eq!(out, "See `unknownMember`.");
1169
1170        // Unknown qualified self-reference should not create a broken same-page anchor.
1171        let out = replace_inline_links(
1172            "See {ECDSA-doesNotExist}.",
1173            &name_to_page,
1174            Path::new("src/library.ECDSA.mdx"),
1175            Some(&local),
1176        );
1177        assert_eq!(out, "See `ECDSA`.");
1178    }
1179
1180    #[test]
1181    fn local_member_wins_over_global_name() {
1182        // A top-level item elsewhere shares the member's name; lexical
1183        // proximity resolves to the same-page anchor, not the other page.
1184        let mut name_to_page = NameToPage::new();
1185        name_to_page
1186            .by_name
1187            .insert("transfer".to_string(), vec![PathBuf::from("src/other/contract.transfer.mdx")]);
1188        let mut local = LocalMembers::new("Token");
1189        local.insert("transfer");
1190
1191        let out = replace_inline_links(
1192            "Calls {transfer}.",
1193            &name_to_page,
1194            Path::new("src/contract.Token.mdx"),
1195            Some(&local),
1196        );
1197        assert_eq!(out, "Calls [transfer](#transfer).");
1198
1199        // Without local context the global index still resolves.
1200        let out = replace_inline_links(
1201            "Calls {transfer}.",
1202            &name_to_page,
1203            Path::new("src/contract.Token.mdx"),
1204            None,
1205        );
1206        assert_eq!(out, "Calls [transfer](/src/other/contract.transfer).");
1207    }
1208}