Skip to main content

forge_doc/
hir_ext.rs

1//! HIR-aware enrichments.
2//!
3//! Pure functions over `solar`'s HIR:
4//! * `build_name_to_page`: maps contract names to their MDX page paths.
5//! * `inheritance_links`: `**Inherits:**` line for a contract page.
6//! * `resolve_inheritdoc`: pulls natspec from a base contract member.
7//! * `replace_inline_links`: rewrites `{Ident}` to markdown links.
8
9use path_slash::PathBufExt;
10use solar::{
11    ast::{
12        CommentKind, ContractKind, DocComments, FunctionKind, ItemKind, NatSpecKind, ParameterList,
13    },
14    interface::source_map::FileName,
15    sema::{
16        Gcx,
17        hir::{ContractId, FunctionId, ItemId, SourceId, VariableId},
18    },
19};
20use std::{
21    collections::{HashMap, HashSet},
22    path::{Path, PathBuf},
23};
24use tracing::warn;
25
26// ── 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    // Collect and sort by (source_path, name) so that last-insert-wins is deterministic
67    // across platforms even when the HIR iteration order is unspecified.
68    let mut item_ids: Vec<_> = gcx.hir.item_ids().collect();
69    item_ids.sort_by_key(|id| {
70        let (name, source) = match id {
71            ItemId::Contract(id) => {
72                let c = gcx.hir.contract(*id);
73                (c.name.as_str().to_string(), c.source)
74            }
75            ItemId::Struct(id) => {
76                let s = gcx.hir.strukt(*id);
77                (s.name.as_str().to_string(), s.source)
78            }
79            ItemId::Enum(id) => {
80                let e = gcx.hir.enumm(*id);
81                (e.name.as_str().to_string(), e.source)
82            }
83            ItemId::Error(id) => {
84                let e = gcx.hir.error(*id);
85                (e.name.as_str().to_string(), e.source)
86            }
87            ItemId::Event(id) => {
88                let e = gcx.hir.event(*id);
89                (e.name.as_str().to_string(), e.source)
90            }
91            ItemId::Udvt(id) => {
92                let u = gcx.hir.udvt(*id);
93                (u.name.as_str().to_string(), u.source)
94            }
95            ItemId::Function(_) | ItemId::Variable(_) => {
96                return (String::new(), String::new());
97            }
98        };
99        let path = source_paths(gcx, source, root)
100            .map(|(_, rel)| rel.to_string_lossy().into_owned())
101            .unwrap_or_default();
102        (path, name)
103    });
104
105    for item_id in item_ids {
106        let (name, source, contract, prefix) = match item_id {
107            ItemId::Contract(id) => {
108                let c = gcx.hir.contract(id);
109                let kind = match c.kind {
110                    ContractKind::Contract => "contract",
111                    ContractKind::AbstractContract => "abstract",
112                    ContractKind::Interface => "interface",
113                    ContractKind::Library => "library",
114                };
115                (c.name, c.source, None, kind)
116            }
117            ItemId::Struct(id) => {
118                let s = gcx.hir.strukt(id);
119                (s.name, s.source, s.contract, "struct")
120            }
121            ItemId::Enum(id) => {
122                let e = gcx.hir.enumm(id);
123                (e.name, e.source, e.contract, "enum")
124            }
125            ItemId::Error(id) => {
126                let e = gcx.hir.error(id);
127                (e.name, e.source, e.contract, "error")
128            }
129            ItemId::Event(id) => {
130                let e = gcx.hir.event(id);
131                (e.name, e.source, e.contract, "event")
132            }
133            ItemId::Udvt(id) => {
134                let u = gcx.hir.udvt(id);
135                (u.name, u.source, u.contract, "type")
136            }
137            ItemId::Function(_) | ItemId::Variable(_) => continue,
138        };
139
140        // For non-contract items, skip those defined inside a contract (they appear on the
141        // contract page, not their own page).
142        if contract.is_some() && !matches!(item_id, ItemId::Contract(_)) {
143            continue;
144        }
145
146        if let Some((abs, rel)) = source_paths(gcx, source, root) {
147            if !allowed_sources.contains(&abs) {
148                continue;
149            }
150            let out_dir = rel.parent().unwrap_or(Path::new("")).to_owned();
151            let page = out_dir.join(format!("{prefix}.{}.mdx", name.as_str()));
152            let name_str = name.as_str().to_string();
153            let entry = map.by_name.entry(name_str.clone()).or_default();
154            if !entry.is_empty() {
155                warn!(
156                    "forge doc: duplicate top-level name `{name_str}`; \
157                     cross-reference `{{{name_str}}}` will resolve by proximity to the referencing page"
158                );
159            }
160            entry.push(page.clone());
161
162            // Record exact contract -> page so inheritance / id-keyed lookups
163            // don't go through the ambiguous name index.
164            if let ItemId::Contract(cid) = item_id {
165                map.by_contract.insert(cid, page);
166            }
167        }
168    }
169
170    map
171}
172
173fn source_paths(gcx: Gcx<'_>, source_id: SourceId, root: &Path) -> Option<(PathBuf, PathBuf)> {
174    let file = &gcx.hir.source(source_id).file;
175    if let FileName::Real(p) = &file.name {
176        let rel = if let Ok(r) = p.strip_prefix(root) {
177            r.to_path_buf()
178        } else {
179            // Outside-root files (e.g. absolute lib paths) get a synthetic
180            // `lib/<tail>` path that matches what builder.rs emits.
181            let comps: Vec<_> = p.components().collect();
182            let start = comps.len().saturating_sub(3);
183            let tail: PathBuf = comps[start..].iter().collect();
184            PathBuf::from("lib").join(tail)
185        };
186        Some((p.clone(), rel))
187    } else {
188        None
189    }
190}
191
192/// Pick the best candidate page for a given cross-reference lookup.
193///
194/// When only one candidate exists the choice is trivial. When multiple files define
195/// the same top-level name the page whose *directory* shares the longest common
196/// path prefix with `current_page` wins; ties fall back to the first entry (which
197/// is deterministic because `build_name_to_page` sorts before inserting).
198fn resolve_page<'a>(candidates: &'a [PathBuf], current_page: &Path) -> &'a PathBuf {
199    if candidates.len() == 1 {
200        return &candidates[0];
201    }
202    let current_dir = current_page.parent().unwrap_or(Path::new(""));
203    candidates
204        .iter()
205        .max_by_key(|page| {
206            let page_dir = page.parent().unwrap_or(Path::new(""));
207            current_dir.components().zip(page_dir.components()).take_while(|(a, b)| a == b).count()
208        })
209        .unwrap_or(&candidates[0])
210}
211
212// ── inheritance links ─────────────────────────────────────────────────────────
213
214/// Returns the `**Inherits:**` markdown string for a contract, or `None` if it has no bases.
215///
216/// Each base is either a bare name (when no page is known) or a markdown link.
217pub fn inheritance_links(
218    gcx: Gcx<'_>,
219    contract_id: ContractId,
220    name_to_page: &NameToPage,
221    current_page: &Path,
222) -> Option<String> {
223    let contract = gcx.hir.contract(contract_id);
224    if contract.bases.is_empty() {
225        return None;
226    }
227
228    let parts: Vec<String> = contract
229        .bases
230        .iter()
231        .map(|&base_id| {
232            let base = gcx.hir.contract(base_id);
233            let name = base.name.as_str();
234            // Prefer the exact base id; only fall back to the ambiguous name
235            // index when the base has no rendered page of its own.
236            if let Some(page) = name_to_page.get_contract(base_id) {
237                let link = page_link(page, current_page);
238                format!("[{name}]({link})")
239            } else if let Some(candidates) = name_to_page.get(name) {
240                let page = resolve_page(candidates, current_page);
241                let link = page_link(page, current_page);
242                format!("[{name}]({link})")
243            } else {
244                name.to_string()
245            }
246        })
247        .collect();
248
249    Some(format!("**Inherits:** {}", parts.join(", ")))
250}
251
252// ── inheritdoc resolution ─────────────────────────────────────────────────────
253
254/// Collected natspec tags from an inherited base member.
255pub struct InheritedDoc {
256    pub notices: Vec<String>,
257    pub devs: Vec<String>,
258    pub params: Vec<(String, String)>,
259    pub returns: Vec<(String, String)>,
260}
261
262/// Resolve `@inheritdoc BaseContract` for a function named `fn_name` inside
263/// `contract_id` (the current contract). Walks the linearized bases to find a
264/// matching function and returns its natspec if found.
265///
266/// When `param_types` is `Some`, the resolver prefers a function whose parameter
267/// type signature matches exactly; this disambiguates overloads. If no exact
268/// signature match is found, it falls back to the first name match.
269pub fn resolve_inheritdoc(
270    gcx: Gcx<'_>,
271    contract_id: ContractId,
272    fn_name: &str,
273    base_name: &str,
274    param_types: Option<&[String]>,
275) -> Option<InheritedDoc> {
276    let contract = gcx.hir.contract(contract_id);
277
278    // Find the named base contract in the linearized hierarchy.
279    let base_id = contract
280        .linearized_bases
281        .iter()
282        .copied()
283        .find(|&bid| gcx.hir.contract(bid).name.as_str() == base_name)?;
284
285    // Search the named base and then its own linearized chain so that `@inheritdoc Base`
286    // resolves even when `Base` itself inherits the member without redeclaring NatSpec.
287    // We prefer the first level that has both a name match AND non-empty documentation.
288    let base_contract = gcx.hir.contract(base_id);
289
290    let search_contracts: Vec<ContractId> = std::iter::once(base_id)
291        .chain(base_contract.linearized_bases.iter().copied().filter(|&id| id != base_id))
292        .collect();
293
294    for search_id in &search_contracts {
295        let search_contract = gcx.hir.contract(*search_id);
296        let mut name_matches: Vec<FunctionId> = Vec::new();
297        for &item_id in search_contract.items {
298            if let ItemId::Function(fid) = item_id {
299                let f = gcx.hir.function(fid);
300                let matches = match f.kind {
301                    FunctionKind::Constructor => fn_name == "constructor",
302                    FunctionKind::Fallback => fn_name == "fallback",
303                    FunctionKind::Receive => fn_name == "receive",
304                    _ => f.name.map(|n| n.as_str() == fn_name).unwrap_or(false),
305                };
306                if matches {
307                    name_matches.push(fid);
308                }
309            }
310        }
311        if name_matches.is_empty() {
312            continue;
313        }
314
315        // Prefer an exact signature match when overloads exist.
316        if let Some(want) = param_types
317            && name_matches.len() > 1
318        {
319            // Try to find a signature-exact match with docs; fall through to name matches below.
320            for &fid in &name_matches {
321                if let Some(got) = function_param_types(gcx, fid)
322                    && got.len() == want.len()
323                    && got.iter().zip(want).all(|(a, b)| a == b)
324                    && let Some(doc) = extract_inherited_doc(gcx, fid)
325                    && (!doc.notices.is_empty()
326                        || !doc.devs.is_empty()
327                        || !doc.params.is_empty()
328                        || !doc.returns.is_empty())
329                {
330                    return Some(doc);
331                }
332            }
333        }
334
335        if name_matches.len() > 1 && param_types.is_some() {
336            continue;
337        }
338
339        // Return the first candidate that has actual documentation; if none have docs
340        // at this inheritance level continue walking up the chain.
341        for &fid in &name_matches {
342            if let Some(doc) = extract_inherited_doc(gcx, fid)
343                && (!doc.notices.is_empty()
344                    || !doc.devs.is_empty()
345                    || !doc.params.is_empty()
346                    || !doc.returns.is_empty())
347            {
348                return Some(doc);
349            }
350        }
351    }
352
353    None
354}
355
356/// Resolve `@inheritdoc BaseContract` for a **state variable** named `var_name`
357/// inside `contract_id`. Walks the linearised bases to find a matching public
358/// variable and returns its natspec if found.
359pub fn resolve_inheritdoc_var(
360    gcx: Gcx<'_>,
361    contract_id: ContractId,
362    var_name: &str,
363    base_name: &str,
364) -> Option<InheritedDoc> {
365    let contract = gcx.hir.contract(contract_id);
366    let base_id = contract
367        .linearized_bases
368        .iter()
369        .copied()
370        .find(|&bid| gcx.hir.contract(bid).name.as_str() == base_name)?;
371
372    let base_contract = gcx.hir.contract(base_id);
373    let search_contracts: Vec<ContractId> = std::iter::once(base_id)
374        .chain(base_contract.linearized_bases.iter().copied().filter(|&id| id != base_id))
375        .collect();
376
377    for search_id in &search_contracts {
378        let search_contract = gcx.hir.contract(*search_id);
379        for &item_id in search_contract.items {
380            match item_id {
381                ItemId::Variable(vid) => {
382                    let v = gcx.hir.variable(vid);
383                    if v.name.map(|n| n.as_str() == var_name).unwrap_or(false)
384                        && let Some(doc) = extract_inherited_doc_var(gcx, vid)
385                        && (!doc.notices.is_empty() || !doc.devs.is_empty())
386                    {
387                        return Some(doc);
388                    }
389                }
390                // A public state variable can implement an interface getter declared as a
391                // zero-arg function (e.g. `function totalSupply() external view returns
392                // (uint256)`). Fall back to matching a same-name zero-parameter function so
393                // `@inheritdoc IERC20` on `uint256 public totalSupply` picks up the
394                // interface's notice/return docs.
395                ItemId::Function(fid) => {
396                    let f = gcx.hir.function(fid);
397                    if f.name.map(|n| n.as_str() == var_name).unwrap_or(false)
398                        && function_param_types(gcx, fid).map(|p| p.is_empty()).unwrap_or(false)
399                        && let Some(doc) = extract_inherited_doc(gcx, fid)
400                        && (!doc.notices.is_empty()
401                            || !doc.devs.is_empty()
402                            || !doc.returns.is_empty())
403                    {
404                        return Some(doc);
405                    }
406                }
407                _ => {}
408            }
409        }
410    }
411    None
412}
413
414fn extract_inherited_doc_var(gcx: Gcx<'_>, vid: VariableId) -> Option<InheritedDoc> {
415    let v = gcx.hir.variable(vid);
416    let ast_source = gcx.sources.get(v.source)?;
417    let ast = ast_source.ast.as_ref()?;
418    let var_span = v.span;
419
420    let docs = ast.items.iter().find_map(|item| {
421        if item.span == var_span {
422            return Some(&item.docs);
423        }
424        if let ItemKind::Contract(c) = &item.kind {
425            for member in c.body.iter() {
426                if member.span == var_span {
427                    return Some(&member.docs);
428                }
429            }
430        }
431        None
432    })?;
433
434    Some(collect_inherited_doc(docs))
435}
436
437/// Extract the parameter type strings (in source order) for a function.
438///
439/// Returns `None` if the function's AST item cannot be located.
440fn function_param_types(gcx: Gcx<'_>, fid: FunctionId) -> Option<Vec<String>> {
441    let f = gcx.hir.function(fid);
442    let ast_source = gcx.sources.get(f.source)?;
443    let ast = ast_source.ast.as_ref()?;
444    let fn_span = f.span;
445
446    let params = ast.items.iter().find_map(|item| match &item.kind {
447        solar::ast::ItemKind::Function(func) if item.span == fn_span => {
448            Some(&func.header.parameters)
449        }
450        solar::ast::ItemKind::Contract(c) => c.body.iter().find_map(|m| match &m.kind {
451            solar::ast::ItemKind::Function(func) if m.span == fn_span => {
452                Some(&func.header.parameters)
453            }
454            _ => None,
455        }),
456        _ => None,
457    })?;
458
459    Some(parameter_type_strings(gcx, params))
460}
461
462/// Compute the parameter type strings of a [`ParameterList`] from the source map.
463///
464/// Types are normalized to their canonical ABI form so that `uint` and `uint256`
465/// (and `int` / `int256`) compare equal during overload matching.
466pub fn parameter_type_strings(gcx: Gcx<'_>, params: &ParameterList<'_>) -> Vec<String> {
467    let sm = gcx.sess.source_map();
468    params
469        .vars
470        .iter()
471        .map(|v| {
472            let raw = sm.span_to_snippet(v.ty.span).unwrap_or_default();
473            let t = raw.split_whitespace().collect::<Vec<_>>().join(" ");
474            normalize_sol_type(&t)
475        })
476        .collect()
477}
478
479/// Canonicalize Solidity type aliases so that e.g. `uint[]` and `uint256[]`
480/// compare equal during overload matching.
481///
482/// Replaces every occurrence of the bare alias tokens `uint` / `int` (not
483/// followed by a digit) with their canonical ABI equivalents `uint256` /
484/// `int256`.
485fn normalize_sol_type(t: &str) -> String {
486    // Walk char-by-char and replace `uint` / `int` that are not followed by a
487    // digit (i.e. are bare aliases, not `uint8`, `uint256`, etc.).
488    let bytes = t.as_bytes();
489    let len = bytes.len();
490    let mut out = String::with_capacity(len + 8);
491    let mut i = 0;
492    while i < len {
493        // Try to match the longer alias first (`uint` before `int`) to avoid
494        // a prefix match of `int` inside `uint`.
495        if bytes[i..].starts_with(b"uint")
496            && !bytes.get(i + 4).copied().map(|b| b.is_ascii_digit()).unwrap_or(false)
497        {
498            out.push_str("uint256");
499            i += 4;
500        } else if bytes[i..].starts_with(b"int")
501            && !bytes.get(i + 3).copied().map(|b| b.is_ascii_digit()).unwrap_or(false)
502        {
503            out.push_str("int256");
504            i += 3;
505        } else if let Some(ch) = t[i..].chars().next() {
506            out.push(ch);
507            i += ch.len_utf8();
508        } else {
509            break;
510        }
511    }
512    out
513}
514
515fn extract_inherited_doc(gcx: Gcx<'_>, fid: FunctionId) -> Option<InheritedDoc> {
516    // HIR functions store a span; we need the AST doc comments.
517    // The AST source has the doc comments on the Item.
518    // We find the source file for this function and look up the AST item by span.
519    let f = gcx.hir.function(fid);
520    let ast_source = gcx.sources.get(f.source)?;
521    let ast = ast_source.ast.as_ref()?;
522
523    let fn_span = f.span;
524    // Walk the AST to find the Item whose span matches.
525    let docs = ast.items.iter().find_map(|item| {
526        if item.span == fn_span {
527            return Some(&item.docs);
528        }
529        // Also search inside contracts.
530        if let solar::ast::ItemKind::Contract(c) = &item.kind {
531            for member in c.body.iter() {
532                if member.span == fn_span {
533                    return Some(&member.docs);
534                }
535            }
536        }
537        None
538    })?;
539
540    Some(collect_inherited_doc(docs))
541}
542
543fn collect_inherited_doc(docs: &DocComments<'_>) -> InheritedDoc {
544    let mut result = InheritedDoc {
545        notices: Vec::new(),
546        devs: Vec::new(),
547        params: Vec::new(),
548        returns: Vec::new(),
549    };
550    let mut prev_doc_was_blank = false;
551    #[derive(Clone, Copy)]
552    enum LastSection {
553        Notice,
554        Dev,
555        Param,
556        Return,
557    }
558    let mut last_section: Option<LastSection> = None;
559
560    for doc in docs.iter() {
561        if doc.natspec.is_empty() {
562            prev_doc_was_blank = true;
563            continue;
564        }
565        for item in doc.natspec.iter() {
566            let raw = doc.natspec_content(item);
567            // For /** */ block comments Solar preserves raw ` * ` line decorations; strip them.
568            let raw: &str =
569                if doc.kind == CommentKind::Block { &clean_block_doc_content(raw) } else { raw };
570            // Solar emits lines without a `@` tag as synthetic @notice with leading whitespace.
571            let is_continuation = matches!(item.kind, NatSpecKind::Notice)
572                && raw.starts_with(|c: char| c.is_whitespace());
573            let content = raw.trim().to_string();
574            if content.is_empty() {
575                prev_doc_was_blank = true;
576                continue;
577            }
578            if is_continuation && !prev_doc_was_blank {
579                let last: Option<&mut String> = match last_section {
580                    Some(LastSection::Notice) => result.notices.last_mut(),
581                    Some(LastSection::Dev) => result.devs.last_mut(),
582                    Some(LastSection::Param) => result.params.last_mut().map(|(_, d)| d),
583                    Some(LastSection::Return) => result.returns.last_mut().map(|(_, d)| d),
584                    None => None,
585                };
586                if let Some(last) = last {
587                    last.push('\n');
588                    last.push_str(&content);
589                    continue;
590                }
591            }
592            prev_doc_was_blank = false;
593            match item.kind {
594                NatSpecKind::Notice => {
595                    result.notices.push(content);
596                    last_section = Some(LastSection::Notice);
597                }
598                NatSpecKind::Dev => {
599                    result.devs.push(content);
600                    last_section = Some(LastSection::Dev);
601                }
602                NatSpecKind::Param { name } => {
603                    result.params.push((name.as_str().to_string(), content));
604                    last_section = Some(LastSection::Param);
605                }
606                NatSpecKind::Return { name } => {
607                    result.returns.push((
608                        name.map(|name| name.as_str().to_string()).unwrap_or_default(),
609                        content,
610                    ));
611                    last_section = Some(LastSection::Return);
612                }
613                _ => {}
614            }
615        }
616    }
617    result
618}
619
620/// Strip the ` * ` block-comment line decoration from each line of a `/** */` NatSpec item's
621/// content. Solar preserves raw source bytes, so continuation lines look like ` * text` and blank
622/// separator lines look like ` *`. This normalises them to plain text / empty lines.
623pub(crate) fn clean_block_doc_content(raw: &str) -> String {
624    raw.lines()
625        .map(|line| {
626            let t = line.trim_start();
627            if let Some(rest) = t.strip_prefix('*') {
628                rest.strip_prefix(' ').unwrap_or(rest)
629            } else {
630                line
631            }
632        })
633        .collect::<Vec<_>>()
634        .join("\n")
635}
636
637// ── inline link replacement ───────────────────────────────────────────────────
638
639/// Escape a string for use as a markdown link label.
640///
641/// Prevents MDX from treating user-controlled NatSpec label text as JSX or
642/// breaking the surrounding markdown link syntax.
643fn escape_link_label(s: &str) -> String {
644    s.replace('{', "&#123;").replace('<', "&lt;").replace('[', "\\[").replace(']', "\\]")
645}
646
647/// Replace `{Ident}` and `{xref-Ident}` with markdown links using `name_to_page`.
648///
649/// Matches the legacy pattern: `{[xref-]Ident[-part]}[label]` where `label` defaults
650/// to `Ident`.
651pub fn replace_inline_links(text: &str, name_to_page: &NameToPage, current_page: &Path) -> String {
652    let mut out = String::with_capacity(text.len());
653    let bytes = text.as_bytes();
654    let mut i = 0;
655
656    while i < bytes.len() {
657        if bytes[i] == b'{' {
658            // Try to parse {[xref-]Ident[-part]}[optional label].
659            if let Some((end, ident, part, label)) = parse_inline_link(&text[i..]) {
660                // Strip the leading `xref-` prefix if present.
661                let lookup_name = ident.strip_prefix("xref-").unwrap_or(ident);
662                let lookup_name = if let Some(pos) = lookup_name.find('-') {
663                    &lookup_name[..pos]
664                } else {
665                    lookup_name
666                };
667
668                if let Some(candidates) = name_to_page.get(lookup_name) {
669                    let page = resolve_page(candidates, current_page);
670                    let mut link = page_link(page, current_page);
671                    // Append the member anchor when the pattern is `{Type-member}`.
672                    // Sanitize to ASCII alphanumerics and `_` only, Solidity identifiers
673                    // never contain other characters, so this drops any injection attempt.
674                    if let Some(member) = part {
675                        let safe_member = xref_part_anchor(member);
676                        if !safe_member.is_empty() {
677                            link.push('#');
678                            link.push_str(&safe_member);
679                        }
680                    }
681                    let default_display = if let Some(member) = part {
682                        // default display: "Type.member"
683                        format!("{lookup_name}.{member}")
684                    } else {
685                        lookup_name.to_string()
686                    };
687                    let display = escape_link_label(label.unwrap_or(&default_display));
688                    out.push_str(&format!("[{display}]({link})"));
689                    i += end;
690                    continue;
691                }
692
693                // Unresolved {Ident}, emit as inline code to avoid MDX treating it as a
694                // JS expression. Strip backticks to avoid breaking the fence.
695                let safe_name = lookup_name.replace('`', "'");
696                out.push_str(&format!("`{safe_name}`"));
697                i += end;
698                continue;
699            }
700            // Bare `{` with no matching `}`, escape it.
701            out.push_str("&#123;");
702            i += 1;
703            continue;
704        }
705
706        if bytes[i] == b'<' {
707            // Escape `<` that would be parsed as a JSX/HTML tag by MDX.
708            // A `<` is safe only when it's already part of a markdown link `<url>` or
709            // a standard HTML entity. We unconditionally escape to `&lt;` here
710            // since Solidity natspec does not produce markdown autolinks.
711            out.push_str("&lt;");
712            i += 1;
713            continue;
714        }
715
716        // Advance by the full UTF-8 character to avoid corrupting multi-byte sequences.
717        let ch = text[i..].chars().next().unwrap();
718        out.push(ch);
719        i += ch.len_utf8();
720    }
721
722    out
723}
724
725pub(crate) fn function_signature_anchor(name: &str, params: &[String]) -> String {
726    let mut anchor = slug_anchor_segment(name);
727    for param in params {
728        let param = slug_anchor_segment(&normalize_sol_type(param));
729        if !param.is_empty() {
730            anchor.push('-');
731            anchor.push_str(&param);
732        }
733    }
734    anchor
735}
736
737fn xref_part_anchor(part: &str) -> String {
738    let mut pieces = part.split('-').filter(|piece| !piece.is_empty());
739    let Some(member) = pieces.next() else {
740        return String::new();
741    };
742    let params = pieces.map(|piece| piece.to_string()).collect::<Vec<_>>();
743    function_signature_anchor(member, &params)
744}
745
746fn slug_anchor_segment(s: &str) -> String {
747    let mut out = String::with_capacity(s.len());
748    let mut last_was_dash = false;
749
750    for ch in s.chars().flat_map(char::to_lowercase) {
751        if ch.is_ascii_alphanumeric() || ch == '_' {
752            out.push(ch);
753            last_was_dash = false;
754        } else if !last_was_dash && !out.is_empty() {
755            out.push('-');
756            last_was_dash = true;
757        }
758    }
759
760    if last_was_dash {
761        out.pop();
762    }
763
764    out
765}
766
767/// Parse `{[xref-]Ident[-part]}[label]` starting at offset 0 in `s`.
768///
769/// Returns `(consumed_bytes, ident, part, label)` on success.
770fn parse_inline_link(s: &str) -> Option<(usize, &str, Option<&str>, Option<&str>)> {
771    let s = s.strip_prefix('{')?;
772    let close = s.find('}')?;
773    let inner = &s[..close];
774
775    // inner = "[xref-]Ident[-part]"
776    let (raw_ident, raw_part) = if let Some(rest) = inner.strip_prefix("xref-") {
777        if let Some(dash) = rest.find('-') {
778            (&inner[..("xref-".len() + dash)], Some(&rest[dash + 1..]))
779        } else {
780            (inner, None)
781        }
782    } else if let Some(dash) = inner.find('-') {
783        let candidate_ident = &inner[..dash];
784        let candidate_part = &inner[dash + 1..];
785        if candidate_ident.chars().all(|c| c.is_alphanumeric() || c == '_')
786            && !candidate_part.is_empty()
787        {
788            (candidate_ident, Some(candidate_part))
789        } else {
790            (inner, None)
791        }
792    } else {
793        (inner, None)
794    };
795
796    let mut consumed = 1 + close + 1; // '{' + inner + '}'
797
798    // Optional label: `[label]`
799    let rest = &s[close + 1..];
800    let label = if rest.starts_with('[') {
801        if let Some(end) = rest.find(']') {
802            let lbl = &rest[1..end];
803            consumed += end + 1;
804            Some(lbl)
805        } else {
806            None
807        }
808    } else {
809        None
810    };
811
812    Some((consumed, raw_ident, raw_part, label))
813}
814
815// ── path helpers ──────────────────────────────────────────────────────────────
816
817/// Produce a vocs-style link from `page` relative to `current_page`.
818///
819/// vocs uses root-relative links (starting with `/`). Forward slashes are
820/// always used so the URL stays correct on Windows.
821fn page_link(page: &Path, _current_page: &Path) -> String {
822    // Strip .mdx extension and produce an absolute path from the pages root.
823    let without_ext = page.with_extension("");
824    format!("/{}", without_ext.to_slash_lossy())
825}
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830
831    #[test]
832    fn full_signature_xref_links_member_anchor() {
833        let mut name_to_page = NameToPage::new();
834        name_to_page
835            .by_name
836            .insert("ERC721".to_string(), vec![PathBuf::from("src/contract.ERC721.mdx")]);
837
838        let out = replace_inline_links(
839            "See {xref-ERC721-_safeMint-address-uint256-}.",
840            &name_to_page,
841            Path::new("src/contract.Child.mdx"),
842        );
843
844        assert_eq!(
845            out,
846            "See [ERC721._safeMint-address-uint256-](/src/contract.ERC721#_safemint-address-uint256)."
847        );
848    }
849}