Skip to main content

forge_doc/
render.rs

1//! Rendering: `solar` AST -> vocs MDX.
2
3use crate::{
4    hir_ext::{self, NameToPage, clean_block_doc_content},
5    utils::Deployment,
6};
7use foundry_common::sh_warn;
8use markdown::{ParseOptions, mdast::Node, to_mdast};
9use solar::{
10    ast::{
11        CommentKind, ContractKind, DocComments, FunctionKind, ItemContract, ItemEnum, ItemError,
12        ItemEvent, ItemFunction, ItemKind, ItemStruct, ItemUdvt, NatSpecKind, ParameterList,
13        SourceUnit, Span, VariableDefinition,
14    },
15    interface::{
16        Ident,
17        source_map::{FileName, SourceFile, SourceMap},
18    },
19    sema::{Gcx, hir},
20};
21use std::{
22    collections::HashMap,
23    fmt::Write as _,
24    ops::Range,
25    path::{Path, PathBuf},
26    sync::Arc,
27};
28
29// ── public entry point ───────────────────────────────────────────────────────
30
31/// Render a single Solidity source file as a list of `(relative_output_path, mdx_content)` pairs.
32#[allow(clippy::too_many_arguments)]
33pub fn source<'ast, 'gcx>(
34    ast: &'ast SourceUnit<'ast>,
35    file: &Arc<SourceFile>,
36    _sm: &SourceMap,
37    rel_sol_path: &Path,
38    abs_sol_path: &Path,
39    _root: &Path,
40    gcx: Gcx<'gcx>,
41    name_to_page: &NameToPage,
42    git_url: Option<&str>,
43    deployments: &HashMap<String, Vec<Deployment>>,
44) -> Vec<(PathBuf, String)> {
45    let out_dir = rel_sol_path.parent().unwrap_or(Path::new(""));
46    let stem = rel_sol_path.file_stem().and_then(|s| s.to_str()).unwrap_or("constants");
47
48    let src_text = file.src.as_str();
49    let src_start = file.start_pos.to_usize();
50    let ctx = Ctx { src_text, src_start };
51
52    let mut pages: Vec<(PathBuf, String)> = Vec::new();
53    let mut const_vars: Vec<(Span, &VariableDefinition<'_>, &DocComments<'_>)> = Vec::new();
54    let mut free_fns: std::collections::BTreeMap<
55        String,
56        Vec<(Span, &ItemFunction<'_>, &DocComments<'_>)>,
57    > = Default::default();
58
59    for item in ast.items.iter() {
60        let span = item.span;
61        match &item.kind {
62            ItemKind::Pragma(_) | ItemKind::Import(_) | ItemKind::Using(_) => (),
63            ItemKind::Contract(c) => {
64                let kind_str = contract_kind_str(c.kind);
65                let fname = format!("{kind_str}.{}.mdx", c.name.as_str());
66                let page_path = out_dir.join(&fname);
67                // Look up HIR contract id for inheritance/inheritdoc.
68                let hir_id = find_contract_id(gcx, c.name.as_str(), abs_sol_path);
69                // Deployments only apply to non-abstract, non-interface, non-library contracts.
70                let contract_deployments = if matches!(c.kind, ContractKind::Contract) {
71                    deployments.get(c.name.as_str()).map(Vec::as_slice).unwrap_or(&[])
72                } else {
73                    &[]
74                };
75                let content = render_contract(
76                    span,
77                    c,
78                    &item.docs,
79                    &ctx,
80                    gcx,
81                    hir_id,
82                    name_to_page,
83                    &page_path,
84                    git_url,
85                    contract_deployments,
86                );
87                pages.push((page_path, content));
88            }
89
90            ItemKind::Function(f) => {
91                let name = f.header.name.map(|n| n.as_str().to_string()).unwrap_or_default();
92                free_fns.entry(name).or_default().push((span, f, &item.docs));
93            }
94
95            ItemKind::Variable(v) => {
96                const_vars.push((span, v, &item.docs));
97            }
98
99            ItemKind::Struct(s) => {
100                let fname = format!("struct.{}.mdx", s.name.as_str());
101                let page_path = out_dir.join(&fname);
102                pages.push((
103                    page_path.clone(),
104                    render_struct(span, s, &item.docs, &ctx, name_to_page, &page_path, git_url),
105                ));
106            }
107
108            ItemKind::Enum(e) => {
109                let fname = format!("enum.{}.mdx", e.name.as_str());
110                let page_path = out_dir.join(&fname);
111                pages.push((
112                    page_path.clone(),
113                    render_enum(span, e, &item.docs, &ctx, name_to_page, &page_path, git_url),
114                ));
115            }
116
117            ItemKind::Udvt(u) => {
118                let fname = format!("type.{}.mdx", u.name.as_str());
119                let page_path = out_dir.join(&fname);
120                pages.push((
121                    page_path.clone(),
122                    render_udvt(span, u, &item.docs, &ctx, name_to_page, &page_path, git_url),
123                ));
124            }
125
126            ItemKind::Error(e) => {
127                let fname = format!("error.{}.mdx", e.name.as_str());
128                let page_path = out_dir.join(&fname);
129                pages.push((
130                    page_path.clone(),
131                    render_error(span, e, &item.docs, &ctx, name_to_page, &page_path, git_url),
132                ));
133            }
134
135            ItemKind::Event(e) => {
136                let fname = format!("event.{}.mdx", e.name.as_str());
137                let page_path = out_dir.join(&fname);
138                pages.push((
139                    page_path.clone(),
140                    render_event(span, e, &item.docs, &ctx, name_to_page, &page_path, git_url),
141                ));
142            }
143        }
144    }
145
146    for (name, overloads) in &free_fns {
147        let fname = format!("function.{name}.mdx");
148        let page_path = out_dir.join(&fname);
149        let content =
150            render_free_functions(name, overloads, &ctx, name_to_page, &page_path, git_url);
151        pages.push((page_path, content));
152    }
153
154    if !const_vars.is_empty() {
155        let fname = format!("constants.{stem}.mdx");
156        let page_path = out_dir.join(&fname);
157        let content = render_constants(stem, &const_vars, &ctx, name_to_page, &page_path, git_url);
158        pages.push((page_path, content));
159    }
160
161    pages
162}
163
164// ── rendering context ────────────────────────────────────────────────────────
165
166struct Ctx<'a> {
167    src_text: &'a str,
168    src_start: usize,
169}
170
171impl<'a> Ctx<'a> {
172    fn snippet(&self, span: Span) -> &'a str {
173        let lo = span.lo().to_usize().saturating_sub(self.src_start);
174        let hi = span.hi().to_usize().saturating_sub(self.src_start);
175        let lo = lo.min(self.src_text.len());
176        let hi = hi.min(self.src_text.len());
177        &self.src_text[lo..hi]
178    }
179
180    fn dedented_snippet(&self, span: Span) -> String {
181        dedent(self.snippet(span))
182    }
183}
184
185// ── contract ─────────────────────────────────────────────────────────────────
186
187#[allow(clippy::too_many_arguments)]
188fn render_contract<'ast, 'gcx>(
189    _span: Span,
190    c: &'ast ItemContract<'ast>,
191    docs: &'ast DocComments<'ast>,
192    ctx: &Ctx<'_>,
193    gcx: Gcx<'gcx>,
194    hir_id: Option<hir::ContractId>,
195    name_to_page: &NameToPage,
196    page_path: &Path,
197    git_url: Option<&str>,
198    deployments: &[Deployment],
199) -> String {
200    let name = c.name.as_str();
201
202    // Index the members rendered as headings on this page so `{member}` and
203    // `{Contract-member}` self-references resolve to anchor-only links.
204    let mut local = hir_id.map_or_else(
205        || hir_ext::LocalMembers::new(name),
206        |id| hir_ext::LocalMembers::for_contract(gcx, id, name_to_page),
207    );
208    for member in c.body.iter() {
209        match &member.kind {
210            ItemKind::Variable(v) => {
211                if let Some(n) = v.name {
212                    local.insert(n.as_str());
213                }
214            }
215            ItemKind::Function(f) => {
216                local.insert(&function_heading(f));
217                if let Some(anchor) = function_signature_anchor(f, ctx) {
218                    local.insert_anchor(anchor);
219                }
220            }
221            ItemKind::Event(e) => local.insert(e.name.as_str()),
222            ItemKind::Error(e) => local.insert(e.name.as_str()),
223            ItemKind::Struct(s) => local.insert(s.name.as_str()),
224            ItemKind::Enum(e) => local.insert(e.name.as_str()),
225            ItemKind::Udvt(u) => local.insert(u.name.as_str()),
226            _ => {}
227        }
228    }
229    let local = Some(&local);
230
231    let comments = collect_comments(docs, name_to_page, page_path, local);
232    let mut out = String::new();
233    write_frontmatter(&mut out, name, first_notice(&comments).as_deref());
234    writeln!(out, "# {name}").unwrap();
235    writeln!(out).unwrap();
236    write_git_source(&mut out, git_url);
237    write_deployments_table(&mut out, deployments);
238
239    // inheritance links.
240    if let Some(id) = hir_id
241        && let Some(inherits) = hir_ext::inheritance_links(gcx, id, name_to_page, page_path)
242    {
243        writeln!(out, "{inherits}").unwrap();
244        writeln!(out).unwrap();
245    }
246
247    write_comment_block(&mut out, &comments);
248
249    // Group members.
250    let mut constants: Vec<(Span, &VariableDefinition<'_>, &DocComments<'_>)> = Vec::new();
251    let mut state_vars: Vec<(Span, &VariableDefinition<'_>, &DocComments<'_>)> = Vec::new();
252    let mut functions: Vec<(Span, &ItemFunction<'_>, &DocComments<'_>)> = Vec::new();
253    let mut events: Vec<(Span, &ItemEvent<'_>, &DocComments<'_>)> = Vec::new();
254    let mut errors: Vec<(Span, &ItemError<'_>, &DocComments<'_>)> = Vec::new();
255    let mut structs: Vec<(Span, &ItemStruct<'_>, &DocComments<'_>)> = Vec::new();
256    let mut enums: Vec<(Span, &ItemEnum<'_>, &DocComments<'_>)> = Vec::new();
257    let mut udvts: Vec<(Span, &ItemUdvt<'_>, &DocComments<'_>)> = Vec::new();
258
259    for member in c.body.iter() {
260        let s = member.span;
261        match &member.kind {
262            ItemKind::Variable(v) => {
263                // constants and immutables get their own section.
264                if v.mutability.is_some_and(|m| m.is_constant() || m.is_immutable()) {
265                    constants.push((s, v, &member.docs));
266                } else {
267                    state_vars.push((s, v, &member.docs));
268                }
269            }
270            ItemKind::Function(f) => functions.push((s, f, &member.docs)),
271            ItemKind::Event(e) => events.push((s, e, &member.docs)),
272            ItemKind::Error(e) => errors.push((s, e, &member.docs)),
273            ItemKind::Struct(st) => structs.push((s, st, &member.docs)),
274            ItemKind::Enum(e) => enums.push((s, e, &member.docs)),
275            ItemKind::Udvt(u) => udvts.push((s, u, &member.docs)),
276            _ => {}
277        }
278    }
279
280    let write_vars =
281        |out: &mut String, vars: &[(Span, &VariableDefinition<'_>, &DocComments<'_>)]| {
282            for (span, v, docs) in vars {
283                let vname = v.name.map(|n| n.as_str().to_string()).unwrap_or_default();
284                writeln!(out, "### {vname}").unwrap();
285                writeln!(out).unwrap();
286                let mut c = collect_comments(docs, name_to_page, page_path, local);
287                // Explicit `@inheritdoc` merges into a partial local doc; implicit
288                // inheritance only runs when the variable has no local NatSpec at all.
289                let vid = hir_id.and_then(|cid| {
290                    gcx.hir.contract(cid).items.iter().find_map(|&item| match item {
291                        hir::ItemId::Variable(id) if gcx.hir.variable(id).span == *span => Some(id),
292                        _ => None,
293                    })
294                });
295                let inherited = vid.and_then(|id| {
296                    let explicit = inheritdoc_base(docs).is_some();
297                    (explicit || !has_local_natspec(docs))
298                        .then(|| hir_ext::natspec_doc(gcx, id.into(), !explicit))
299                        .flatten()
300                });
301                let sanitize =
302                    |s: &str| hir_ext::replace_inline_links(s, name_to_page, page_path, local);
303                if let Some(ref base_doc) = inherited {
304                    if c.notices.is_empty() {
305                        let inherited_notices: Vec<String> =
306                            base_doc.notices.iter().map(|s| sanitize(s)).collect();
307                        let mut new_desc: Vec<Description> = inherited_notices
308                            .iter()
309                            .map(|s| Description { kind: DescKind::Notice, content: s.clone() })
310                            .collect();
311                        new_desc.append(&mut c.descriptions);
312                        c.descriptions = new_desc;
313                        c.notices.extend(inherited_notices);
314                    }
315                    if c.devs.is_empty() {
316                        let inherited_devs: Vec<String> =
317                            base_doc.devs.iter().map(|s| sanitize(s)).collect();
318                        c.descriptions.extend(
319                            inherited_devs
320                                .iter()
321                                .map(|s| Description { kind: DescKind::Dev, content: s.clone() }),
322                        );
323                        c.devs.extend(inherited_devs);
324                    }
325                }
326                write_comment_block(out, &c);
327                write_code_block(out, &ctx.dedented_snippet(*span));
328                // When the documentation is inherited from the variable's generated getter,
329                // render the getter's parameter and return signature instead of the declared
330                // (possibly mapping) type.
331                let getter_doc = inherited.as_ref().filter(|d| !d.getter_returns.is_empty());
332                if let Some(base_doc) = getter_doc {
333                    write_getter_table(out, "Parameters", &base_doc.getter_params, &sanitize);
334                    write_getter_table(out, "Returns", &base_doc.getter_returns, &sanitize);
335                } else if !c.returns.is_empty() {
336                    let ty = format!("`{}`", ctx.snippet(v.ty.span).trim());
337                    writeln!(out, "**Returns**").unwrap();
338                    writeln!(out).unwrap();
339                    writeln!(out, "| Name | Type | Description |").unwrap();
340                    writeln!(out, "| ---- | ---- | ----------- |").unwrap();
341                    for (name, desc) in &c.returns {
342                        // Solar parses `@return <first-word> <rest>` where the first word
343                        // becomes `name` and the rest becomes `desc`. For unnamed returns the
344                        // first word is actually part of the description, so recombine them.
345                        let full_desc =
346                            if desc.is_empty() { name.clone() } else { format!("{name} {desc}") };
347                        let desc_cell = escape_table_cell(&full_desc);
348                        writeln!(out, "| &lt;none&gt; | {ty} | {desc_cell} |").unwrap();
349                    }
350                    writeln!(out).unwrap();
351                }
352            }
353        };
354
355    if !constants.is_empty() {
356        writeln!(out, "## Constants").unwrap();
357        writeln!(out).unwrap();
358        write_vars(&mut out, &constants);
359    }
360
361    if !state_vars.is_empty() {
362        writeln!(out, "## State Variables").unwrap();
363        writeln!(out).unwrap();
364        write_vars(&mut out, &state_vars);
365    }
366
367    if !functions.is_empty() {
368        writeln!(out, "## Functions").unwrap();
369        writeln!(out).unwrap();
370        for (span, f, docs) in &functions {
371            let fn_name = match f.kind {
372                FunctionKind::Constructor => None,
373                FunctionKind::Fallback => Some("fallback".to_string()),
374                FunctionKind::Receive => Some("receive".to_string()),
375                FunctionKind::Function | FunctionKind::Modifier => {
376                    f.header.name.map(|name| name.as_str().to_string())
377                }
378            };
379            // Explicit `@inheritdoc` merges into a partial local doc; implicit inheritance
380            // only runs when the function has no local NatSpec at all.
381            let inherited = fn_name.as_deref().and_then(|fname| {
382                let fid = hir_id.and_then(|cid| {
383                    gcx.hir.contract(cid).items.iter().find_map(|&item| match item {
384                        hir::ItemId::Function(id) if gcx.hir.function(id).span == *span => Some(id),
385                        _ => None,
386                    })
387                });
388                match inheritdoc_base(docs) {
389                    Some(_) => {
390                        if hir_id.is_some() && fid.is_none() {
391                            let _ = sh_warn!(
392                                "forge doc: failed to find HIR function for `{}.{fname}` while resolving @inheritdoc",
393                                c.name
394                            );
395                        }
396                        fid.and_then(|id| hir_ext::natspec_doc(gcx, id.into(), false))
397                    }
398                    None if !has_local_natspec(docs) =>
399                        fid.and_then(|id| hir_ext::natspec_doc(gcx, id.into(), true)),
400                    None => None,
401                }
402            });
403            render_function_section(
404                &mut out,
405                *span,
406                f,
407                docs,
408                ctx,
409                name_to_page,
410                page_path,
411                local,
412                inherited.as_ref(),
413            );
414        }
415    }
416
417    if !events.is_empty() {
418        writeln!(out, "## Events").unwrap();
419        writeln!(out).unwrap();
420        for (span, e, docs) in &events {
421            writeln!(out, "### {}", e.name.as_str()).unwrap();
422            writeln!(out).unwrap();
423            let c = collect_comments(docs, name_to_page, page_path, local);
424            write_comment_block(&mut out, &c);
425            write_code_block(&mut out, &ctx.dedented_snippet(*span));
426            write_param_table(&mut out, "Parameters", &e.parameters, &c, None, ctx);
427        }
428    }
429
430    if !errors.is_empty() {
431        writeln!(out, "## Errors").unwrap();
432        writeln!(out).unwrap();
433        for (span, e, docs) in &errors {
434            writeln!(out, "### {}", e.name.as_str()).unwrap();
435            writeln!(out).unwrap();
436            let c = collect_comments(docs, name_to_page, page_path, local);
437            write_comment_block(&mut out, &c);
438            write_code_block(&mut out, &ctx.dedented_snippet(*span));
439            write_param_table(&mut out, "Parameters", &e.parameters, &c, None, ctx);
440        }
441    }
442
443    if !structs.is_empty() {
444        writeln!(out, "## Structs").unwrap();
445        writeln!(out).unwrap();
446        for (span, s, docs) in &structs {
447            writeln!(out, "### {}", s.name.as_str()).unwrap();
448            writeln!(out).unwrap();
449            let c = collect_comments(docs, name_to_page, page_path, local);
450            write_comment_block(&mut out, &c);
451            write_code_block(&mut out, &ctx.dedented_snippet(*span));
452            write_struct_properties_table(&mut out, s.fields, &c, ctx);
453        }
454    }
455
456    if !enums.is_empty() {
457        writeln!(out, "## Enums").unwrap();
458        writeln!(out).unwrap();
459        for (span, e, docs) in &enums {
460            writeln!(out, "### {}", e.name.as_str()).unwrap();
461            writeln!(out).unwrap();
462            let c = collect_comments(docs, name_to_page, page_path, local);
463            write_comment_block(&mut out, &c);
464            write_code_block(&mut out, &ctx.dedented_snippet(*span));
465            write_enum_variants_table(&mut out, e.variants, &c);
466        }
467    }
468
469    if !udvts.is_empty() {
470        writeln!(out, "## Custom Types").unwrap();
471        writeln!(out).unwrap();
472        for (span, u, docs) in &udvts {
473            writeln!(out, "### {}", u.name.as_str()).unwrap();
474            writeln!(out).unwrap();
475            let c = collect_comments(docs, name_to_page, page_path, local);
476            write_comment_block(&mut out, &c);
477            write_code_block(&mut out, &format!("{};", ctx.dedented_snippet(*span)));
478        }
479    }
480
481    out
482}
483
484// ── free functions ────────────────────────────────────────────────────────────
485
486fn render_free_functions(
487    name: &str,
488    overloads: &[(Span, &ItemFunction<'_>, &DocComments<'_>)],
489    ctx: &Ctx<'_>,
490    name_to_page: &NameToPage,
491    page_path: &Path,
492    git_url: Option<&str>,
493) -> String {
494    let title = if name.is_empty() { "function" } else { name };
495    let first_comments = collect_comments(overloads[0].2, name_to_page, page_path, None);
496    let mut out = String::new();
497    write_frontmatter(&mut out, title, first_notice(&first_comments).as_deref());
498    writeln!(out, "# {title}").unwrap();
499    writeln!(out).unwrap();
500    write_git_source(&mut out, git_url);
501    for (span, f, docs) in overloads {
502        render_function_section(&mut out, *span, f, docs, ctx, name_to_page, page_path, None, None);
503    }
504    out
505}
506
507// ── constants ─────────────────────────────────────────────────────────────────
508
509fn render_constants(
510    stem: &str,
511    vars: &[(Span, &VariableDefinition<'_>, &DocComments<'_>)],
512    ctx: &Ctx<'_>,
513    name_to_page: &NameToPage,
514    page_path: &Path,
515    git_url: Option<&str>,
516) -> String {
517    let title = format!("{stem} Constants");
518    let mut out = String::new();
519    write_frontmatter(&mut out, &title, None);
520    writeln!(out, "# {title}").unwrap();
521    writeln!(out).unwrap();
522    write_git_source(&mut out, git_url);
523    for (span, v, docs) in vars {
524        let name = v.name.map(|n| n.as_str().to_string()).unwrap_or_else(|| "_".to_string());
525        writeln!(out, "## {name}").unwrap();
526        writeln!(out).unwrap();
527        let c = collect_comments(docs, name_to_page, page_path, None);
528        write_comment_block(&mut out, &c);
529        write_code_block(&mut out, &ctx.dedented_snippet(*span));
530    }
531    out
532}
533
534// ── standalone items ──────────────────────────────────────────────────────────
535
536fn render_struct<'ast>(
537    span: Span,
538    s: &'ast ItemStruct<'ast>,
539    docs: &'ast DocComments<'ast>,
540    ctx: &Ctx<'_>,
541    name_to_page: &NameToPage,
542    page_path: &Path,
543    git_url: Option<&str>,
544) -> String {
545    let name = s.name.as_str();
546    let c = collect_comments(docs, name_to_page, page_path, None);
547    let mut out = String::new();
548    write_frontmatter(&mut out, name, first_notice(&c).as_deref());
549    writeln!(out, "# {name}").unwrap();
550    writeln!(out).unwrap();
551    write_git_source(&mut out, git_url);
552    write_comment_block(&mut out, &c);
553    write_code_block(&mut out, &ctx.dedented_snippet(span));
554    write_struct_properties_table(&mut out, s.fields, &c, ctx);
555    out
556}
557
558fn render_enum<'ast>(
559    span: Span,
560    e: &'ast ItemEnum<'ast>,
561    docs: &'ast DocComments<'ast>,
562    ctx: &Ctx<'_>,
563    name_to_page: &NameToPage,
564    page_path: &Path,
565    git_url: Option<&str>,
566) -> String {
567    let name = e.name.as_str();
568    let c = collect_comments(docs, name_to_page, page_path, None);
569    let mut out = String::new();
570    write_frontmatter(&mut out, name, first_notice(&c).as_deref());
571    writeln!(out, "# {name}").unwrap();
572    writeln!(out).unwrap();
573    write_git_source(&mut out, git_url);
574    write_comment_block(&mut out, &c);
575    write_code_block(&mut out, &ctx.dedented_snippet(span));
576    write_enum_variants_table(&mut out, e.variants, &c);
577    out
578}
579
580fn render_udvt<'ast>(
581    span: Span,
582    u: &'ast ItemUdvt<'ast>,
583    docs: &'ast DocComments<'ast>,
584    ctx: &Ctx<'_>,
585    name_to_page: &NameToPage,
586    page_path: &Path,
587    git_url: Option<&str>,
588) -> String {
589    let name = u.name.as_str();
590    let c = collect_comments(docs, name_to_page, page_path, None);
591    let mut out = String::new();
592    write_frontmatter(&mut out, name, first_notice(&c).as_deref());
593    writeln!(out, "# {name}").unwrap();
594    writeln!(out).unwrap();
595    write_git_source(&mut out, git_url);
596    write_comment_block(&mut out, &c);
597    write_code_block(&mut out, &format!("{};", ctx.dedented_snippet(span)));
598    out
599}
600
601fn render_error<'ast>(
602    span: Span,
603    e: &'ast ItemError<'ast>,
604    docs: &'ast DocComments<'ast>,
605    ctx: &Ctx<'_>,
606    name_to_page: &NameToPage,
607    page_path: &Path,
608    git_url: Option<&str>,
609) -> String {
610    let name = e.name.as_str();
611    let c = collect_comments(docs, name_to_page, page_path, None);
612    let mut out = String::new();
613    write_frontmatter(&mut out, name, first_notice(&c).as_deref());
614    writeln!(out, "# {name}").unwrap();
615    writeln!(out).unwrap();
616    write_git_source(&mut out, git_url);
617    write_comment_block(&mut out, &c);
618    write_code_block(&mut out, &ctx.dedented_snippet(span));
619    write_param_table(&mut out, "Parameters", &e.parameters, &c, None, ctx);
620    out
621}
622
623fn render_event<'ast>(
624    span: Span,
625    e: &'ast ItemEvent<'ast>,
626    docs: &'ast DocComments<'ast>,
627    ctx: &Ctx<'_>,
628    name_to_page: &NameToPage,
629    page_path: &Path,
630    git_url: Option<&str>,
631) -> String {
632    let name = e.name.as_str();
633    let c = collect_comments(docs, name_to_page, page_path, None);
634    let mut out = String::new();
635    write_frontmatter(&mut out, name, first_notice(&c).as_deref());
636    writeln!(out, "# {name}").unwrap();
637    writeln!(out).unwrap();
638    write_git_source(&mut out, git_url);
639    write_comment_block(&mut out, &c);
640    write_code_block(&mut out, &ctx.dedented_snippet(span));
641    write_param_table(&mut out, "Parameters", &e.parameters, &c, None, ctx);
642    out
643}
644
645// ── function section ──────────────────────────────────────────────────────────
646#[allow(clippy::too_many_arguments)]
647fn render_function_section(
648    out: &mut String,
649    span: Span,
650    f: &ItemFunction<'_>,
651    docs: &DocComments<'_>,
652    ctx: &Ctx<'_>,
653    name_to_page: &NameToPage,
654    page_path: &Path,
655    local: Option<&hir_ext::LocalMembers>,
656    inherited: Option<&hir_ext::NatSpecDoc>,
657) {
658    let heading = function_heading(f);
659    if let Some(anchor) = function_signature_anchor(f, ctx) {
660        writeln!(out, "<a id=\"{anchor}\"></a>").unwrap();
661        writeln!(out).unwrap();
662    }
663    writeln!(out, "### {heading}").unwrap();
664    writeln!(out).unwrap();
665    let mut c = collect_comments(docs, name_to_page, page_path, local);
666    let mut inherited_params = None;
667    // Merge inherited natspec for missing tags.
668    if let Some(inherited) = inherited {
669        let sanitize = |s: &str| hir_ext::replace_inline_links(s, name_to_page, page_path, local);
670        let inherited_notices: Vec<String> =
671            inherited.notices.iter().map(|s| sanitize(s)).collect();
672        let inherited_devs: Vec<String> = inherited.devs.iter().map(|s| sanitize(s)).collect();
673        if c.notices.is_empty() {
674            let mut new_desc: Vec<Description> = inherited_notices
675                .iter()
676                .map(|s| Description { kind: DescKind::Notice, content: s.clone() })
677                .collect();
678            new_desc.append(&mut c.descriptions);
679            c.descriptions = new_desc;
680            c.notices.extend_from_slice(&inherited_notices);
681        }
682        if c.devs.is_empty() {
683            c.devs.extend_from_slice(&inherited_devs);
684            c.descriptions.extend(
685                inherited_devs
686                    .iter()
687                    .map(|s| Description { kind: DescKind::Dev, content: s.clone() }),
688            );
689        }
690        if c.params.is_empty() {
691            let params = inherited.params.iter().map(|desc| sanitize(desc)).collect::<Vec<_>>();
692            for (index, desc) in params.iter().enumerate() {
693                if let Some(name) = f.header.parameters.get(index).and_then(|param| param.name) {
694                    c.params.push((name.as_str().to_string(), desc.clone()));
695                }
696            }
697            inherited_params = Some(params);
698        }
699        if c.returns.is_empty() {
700            for (index, desc) in inherited.returns.iter().enumerate() {
701                let name = f
702                    .header
703                    .returns
704                    .as_ref()
705                    .and_then(|returns| returns.get(index))
706                    .and_then(|return_| return_.name)
707                    .map(|name| name.as_str().to_string())
708                    .unwrap_or_default();
709                c.returns.push((name, sanitize(desc)));
710            }
711        }
712    }
713    write_comment_block(out, &c);
714    let hspan = if f.header.span.lo() == f.header.span.hi() { span } else { f.header.span };
715    let snippet = ctx.dedented_snippet(hspan);
716    write_code_block(out, &format!("{snippet};"));
717    write_param_table(
718        out,
719        "Parameters",
720        &f.header.parameters,
721        &c,
722        inherited_params.as_deref(),
723        ctx,
724    );
725    if let Some(returns) = &f.header.returns {
726        write_param_table(out, "Returns", returns, &c, None, ctx);
727    }
728}
729
730fn function_heading(f: &ItemFunction<'_>) -> String {
731    match f.kind {
732        FunctionKind::Constructor => "constructor".to_string(),
733        FunctionKind::Fallback => "fallback".to_string(),
734        FunctionKind::Receive => "receive".to_string(),
735        FunctionKind::Function | FunctionKind::Modifier => {
736            f.header.name.map(|n| n.as_str().to_string()).unwrap_or_else(|| "function".to_string())
737        }
738    }
739}
740
741fn function_signature_anchor(f: &ItemFunction<'_>, ctx: &Ctx<'_>) -> Option<String> {
742    let name = match f.kind {
743        FunctionKind::Constructor => "constructor".to_string(),
744        FunctionKind::Fallback => "fallback".to_string(),
745        FunctionKind::Receive => "receive".to_string(),
746        FunctionKind::Function | FunctionKind::Modifier => f.header.name?.as_str().to_string(),
747    };
748    let params = f
749        .header
750        .parameters
751        .vars
752        .iter()
753        .map(|v| ctx.snippet(v.ty.span).trim().to_string())
754        .collect::<Vec<_>>();
755
756    Some(hir_ext::function_signature_anchor(&name, &params))
757}
758
759// ── natspec comment collection ────────────────────────────────────────────────
760
761#[derive(Clone, Copy, PartialEq, Eq)]
762enum DescKind {
763    Notice,
764    Dev,
765}
766
767struct Description {
768    kind: DescKind,
769    content: String,
770}
771
772struct CommentData {
773    titles: Vec<String>,
774    authors: Vec<String>,
775    /// Real `@notice` items used for inheritdoc merging.
776    notices: Vec<String>,
777    /// Real `@dev` items (used for inheritdoc merging).
778    devs: Vec<String>,
779    /// All notice/dev text in source order, tagged with their kind, with continuation
780    /// lines joined to their parent. Used for rendering to preserve correct paragraph
781    /// ordering and to italicize `@dev` paragraphs as a whole.
782    descriptions: Vec<Description>,
783    params: Vec<(String, String)>,
784    returns: Vec<(String, String)>,
785    customs: Vec<(String, String)>,
786    /// `@custom:name <name>` values, used to fill in unnamed function parameters.
787    unnamed_param_names: Vec<String>,
788}
789
790/// Collect natspec from doc comments, applying inline link replacement.
791///
792/// Solar emits each `///` line as a separate `DocComment`. Lines without a `@` tag become
793/// synthetic `@notice` items with leading whitespace in their raw content. We detect these
794/// continuation lines and join them to the previous description paragraph so that multi-line
795/// natspec tags appear as a single coherent block in the right source order.
796fn collect_comments(
797    docs: &DocComments<'_>,
798    name_to_page: &NameToPage,
799    page_path: &Path,
800    local: Option<&hir_ext::LocalMembers>,
801) -> CommentData {
802    let mut data = CommentData {
803        titles: Vec::new(),
804        authors: Vec::new(),
805        notices: Vec::new(),
806        devs: Vec::new(),
807        descriptions: Vec::new(),
808        params: Vec::new(),
809        returns: Vec::new(),
810        customs: Vec::new(),
811        unnamed_param_names: Vec::new(),
812    };
813
814    // Tags that are not user-facing natspec; do not warn on these.
815    const FILTERED_CUSTOM: &[&str] = &["solidity", "src", "use-src", "ast-id"];
816    // Recognised natspec custom tags (mirror legacy behaviour).
817    const KNOWN_CUSTOM: &[&str] = &["name"];
818
819    // Track whether the previous DocComment was blank (empty natspec), which signals a
820    // paragraph break even between continuation lines.
821    let mut prev_doc_was_blank = false;
822    #[derive(Clone, Copy)]
823    enum LastSection {
824        Desc, // notice or dev (both go through descriptions)
825        Param,
826        Return,
827    }
828    let mut last_section: Option<LastSection> = None;
829
830    for doc in docs.iter() {
831        if doc.natspec.is_empty() {
832            prev_doc_was_blank = true;
833            continue;
834        }
835
836        for item in doc.natspec.iter() {
837            let raw = doc.natspec_content(item);
838            // For /** */ block comments Solar preserves raw ` * ` line decorations inside the
839            // content range. Strip them so multi-line content renders cleanly.
840            let raw: &str =
841                if doc.kind == CommentKind::Block { &clean_block_doc_content(raw) } else { raw };
842
843            // Detect a solar "synthetic" @notice: a continuation line with no `@` tag.
844            // Solar produces these when a `///` line has no tag; the raw content starts
845            // with whitespace (the indentation after `///`).
846            let is_continuation = matches!(item.kind, NatSpecKind::Notice)
847                && raw.starts_with(|c: char| c.is_whitespace());
848
849            let trimmed = raw.trim();
850            if trimmed.is_empty() {
851                prev_doc_was_blank = true;
852                continue;
853            }
854
855            // Apply inline {Ident} -> markdown link replacement.
856            let content = hir_ext::replace_inline_links(trimmed, name_to_page, page_path, local);
857
858            if is_continuation && !prev_doc_was_blank {
859                let appended = match last_section {
860                    Some(LastSection::Desc) => data.descriptions.last_mut().map(|d| &mut d.content),
861                    Some(LastSection::Param) => data.params.last_mut().map(|(_, d)| d),
862                    Some(LastSection::Return) => data.returns.last_mut().map(|(_, d)| d),
863                    None => None,
864                };
865                if let Some(last) = appended {
866                    last.push('\n');
867                    last.push_str(&content);
868                    prev_doc_was_blank = false;
869                    continue;
870                }
871            }
872
873            prev_doc_was_blank = false;
874
875            match item.kind {
876                NatSpecKind::Title => data.titles.push(content),
877                NatSpecKind::Author => data.authors.push(content),
878                NatSpecKind::Notice => {
879                    data.notices.push(content.clone());
880                    data.descriptions.push(Description { kind: DescKind::Notice, content });
881                    last_section = Some(LastSection::Desc);
882                }
883                NatSpecKind::Dev => {
884                    data.devs.push(content.clone());
885                    data.descriptions.push(Description { kind: DescKind::Dev, content });
886                    last_section = Some(LastSection::Desc);
887                }
888                NatSpecKind::Param { name } => {
889                    data.params.push((name.as_str().to_string(), content));
890                    last_section = Some(LastSection::Param);
891                }
892                NatSpecKind::Return { name } => {
893                    data.returns.push((
894                        name.map(|name| name.as_str().to_string()).unwrap_or_default(),
895                        content,
896                    ));
897                    last_section = Some(LastSection::Return);
898                }
899                NatSpecKind::Inheritdoc { .. } => {} // resolved separately via HIR
900                NatSpecKind::Custom { name } => {
901                    let tag = name.as_str();
902                    if FILTERED_CUSTOM.contains(&tag) {
903                        // Silently ignored.
904                    } else if tag == "name" {
905                        // `@custom:name <name>` -> unnamed param name (legacy parity).
906                        if let Some(first) = content.split_whitespace().next() {
907                            data.unnamed_param_names.push(first.to_string());
908                        }
909                    } else {
910                        // unknown-natspec-tag warning.
911                        if !KNOWN_CUSTOM.contains(&tag) && !is_known_custom_tag(tag) {
912                            warn!("unknown natspec custom tag: @custom:{tag}");
913                        }
914                        data.customs.push((tag.to_string(), content));
915                    }
916                }
917                NatSpecKind::Internal { .. } => {}
918            }
919        }
920    }
921
922    data
923}
924
925/// Returns true if `tag` looks like a generally-recognised natspec custom tag.
926///
927/// We accept any non-empty alphanumeric/dash identifier as "known enough" not
928/// to warn, only obviously malformed tags trigger the warning channel.
929fn is_known_custom_tag(tag: &str) -> bool {
930    !tag.is_empty() && tag.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
931}
932
933/// Returns the base contract name from `@inheritdoc Base`, or `None`.
934/// Whether the declaration carries any local NatSpec item other than `@inheritdoc`. Solidity
935/// only auto-inherits documentation for a member with none, so implicit inheritance is gated
936/// on this being false; a local `@custom:*`, `@title` or `@author` counts as local
937/// documentation just like `@notice`/`@dev`/`@param`/`@return`.
938fn has_local_natspec(docs: &DocComments<'_>) -> bool {
939    docs.iter().any(|doc| {
940        doc.natspec.iter().any(|item| !matches!(item.kind, NatSpecKind::Inheritdoc { .. }))
941    })
942}
943
944/// Render a getter signature table (`Parameters` or `Returns`) from its inherited rows.
945fn write_getter_table(
946    out: &mut String,
947    heading: &str,
948    fields: &[hir_ext::GetterField],
949    sanitize: &impl Fn(&str) -> String,
950) {
951    if fields.is_empty() {
952        return;
953    }
954    writeln!(out, "**{heading}**").unwrap();
955    writeln!(out).unwrap();
956    writeln!(out, "| Name | Type | Description |").unwrap();
957    writeln!(out, "| ---- | ---- | ----------- |").unwrap();
958    for field in fields {
959        let name = field
960            .name
961            .as_deref()
962            .map(escape_table_cell)
963            .unwrap_or_else(|| "&lt;none&gt;".to_string());
964        let ty = escape_table_cell(&field.ty);
965        let desc = escape_table_cell(&sanitize(&field.description));
966        writeln!(out, "| {name} | `{ty}` | {desc} |").unwrap();
967    }
968    writeln!(out).unwrap();
969}
970
971fn inheritdoc_base(docs: &DocComments<'_>) -> Option<String> {
972    for doc in docs.iter() {
973        for item in doc.natspec.iter() {
974            if let NatSpecKind::Inheritdoc { contract } = item.kind {
975                return Some(contract.as_str().to_string());
976            }
977        }
978    }
979    None
980}
981
982fn first_notice(data: &CommentData) -> Option<String> {
983    data.descriptions
984        .iter()
985        .find(|description| description.kind == DescKind::Notice)
986        .map(|description| description.content.clone())
987}
988
989// ── markdown output helpers ───────────────────────────────────────────────────
990
991fn write_frontmatter(out: &mut String, title: &str, description: Option<&str>) {
992    writeln!(out, "---").unwrap();
993    writeln!(out, "title: \"{}\"", yaml_escape_double_quoted(title)).unwrap();
994    if let Some(desc) = description {
995        // Collapse whitespace so multi-line notices stay on one line, then escape.
996        let collapsed: String = desc.split_whitespace().collect::<Vec<_>>().join(" ");
997        writeln!(out, "description: \"{}\"", yaml_escape_double_quoted(&collapsed)).unwrap();
998    }
999    writeln!(out, "---").unwrap();
1000    writeln!(out).unwrap();
1001}
1002
1003/// Escape a string for use as a YAML double-quoted scalar.
1004///
1005/// Per the YAML 1.2 spec, double-quoted scalars must escape `"` and `\`, and
1006/// any control character (including newline, tab, carriage return) must be
1007/// represented via an escape sequence rather than embedded literally.
1008fn yaml_escape_double_quoted(s: &str) -> String {
1009    let mut out = String::with_capacity(s.len());
1010    for c in s.chars() {
1011        match c {
1012            '\\' => out.push_str("\\\\"),
1013            '"' => out.push_str("\\\""),
1014            '\n' => out.push_str("\\n"),
1015            '\r' => out.push_str("\\r"),
1016            '\t' => out.push_str("\\t"),
1017            '\u{0}' => out.push_str("\\0"),
1018            c if (c as u32) < 0x20 => {
1019                out.push_str(&format!("\\x{:02x}", c as u32));
1020            }
1021            c => out.push(c),
1022        }
1023    }
1024    out
1025}
1026
1027/// Italicize a `@dev` block by wrapping it in `<i>...</i>` HTML tags. Surrounding
1028/// blank lines around the tags ensure MDX/CommonMark parses the inner content as
1029/// block-level markdown (lists, code fences, multiple paragraphs all work).
1030fn italicize_dev(content: &str) -> String {
1031    let trimmed = content.trim_matches('\n');
1032    if trimmed.is_empty() { String::new() } else { format!("<i>\n\n{trimmed}\n\n</i>") }
1033}
1034
1035/// Byte ranges that MDX parses as code. An HTML entity would render literally inside these ranges,
1036/// so neutralization skips them. If malformed MDX cannot be parsed, returning no ranges favors
1037/// neutralizing possible ESM over preserving an invalid code example byte-for-byte.
1038fn code_regions(text: &str) -> Vec<Range<usize>> {
1039    let Ok(tree) = to_mdast(text, &ParseOptions::mdx()) else { return Vec::new() };
1040    let mut regions = Vec::new();
1041    collect_code_regions(&tree, &mut regions);
1042    regions
1043}
1044
1045/// Collect fenced and inline code positions from the MDX-aware syntax tree.
1046fn collect_code_regions(node: &Node, regions: &mut Vec<Range<usize>>) {
1047    if matches!(node, Node::Code(_) | Node::InlineCode(_))
1048        && let Some(position) = node.position()
1049    {
1050        regions.push(position.start.offset..position.end.offset);
1051    }
1052    if let Some(children) = node.children() {
1053        for child in children {
1054            collect_code_regions(child, regions);
1055        }
1056    }
1057}
1058
1059/// Logical lines and their byte offsets in the original text. CRLF is one separator; lone CR and
1060/// LF are separators too. The separator bytes are excluded from the returned slices and preserved
1061/// in the source string.
1062fn logical_lines(text: &str) -> impl Iterator<Item = (usize, &str)> {
1063    let bytes = text.as_bytes();
1064    let mut offset = 0;
1065    std::iter::from_fn(move || {
1066        if offset >= bytes.len() {
1067            return None;
1068        }
1069        let start = offset;
1070        let end = bytes[start..]
1071            .iter()
1072            .position(|&byte| byte == b'\n' || byte == b'\r')
1073            .map_or(bytes.len(), |position| start + position);
1074        offset = if end == bytes.len() {
1075            end
1076        } else if bytes[end] == b'\r' && bytes.get(end + 1) == Some(&b'\n') {
1077            end + 2
1078        } else {
1079            end + 1
1080        };
1081        Some((start, &text[start..end]))
1082    })
1083}
1084
1085/// Check a position against sorted, merged ranges while advancing monotonically.
1086fn region_contains(regions: &[Range<usize>], cursor: &mut usize, position: usize) -> bool {
1087    while regions.get(*cursor).is_some_and(|region| region.end <= position) {
1088        *cursor += 1;
1089    }
1090    regions.get(*cursor).is_some_and(|region| region.start <= position)
1091}
1092
1093/// Neutralize any line MDX would parse as an ESM statement (`import ` or `export ` at column one):
1094/// the keyword's prefix becomes HTML entities, so the line renders the same but no longer
1095/// begins with an ESM token. NatSpec text can be inherited from a dependency via `@inheritdoc`, so
1096/// this must run wherever displayed prose is assembled. A keyword that falls inside a Markdown
1097/// code span or fenced code block is left untouched (see `code_regions`): the entity would render
1098/// literally and corrupt the example, and MDX would not execute it there.
1099fn neutralize_esm(text: &str) -> String {
1100    let regions = code_regions(text);
1101    let mut region_cursor = 0;
1102    let mut copied = 0;
1103    let mut out = String::with_capacity(text.len());
1104
1105    for (line_start, line) in logical_lines(text) {
1106        let replacement = if line.starts_with("import ") {
1107            Some(("&#105;&#109;", 2))
1108        } else if line.starts_with("export ") {
1109            Some(("&#101;", 1))
1110        } else {
1111            None
1112        };
1113        let Some((entity, prefix_len)) = replacement else { continue };
1114        if region_contains(&regions, &mut region_cursor, line_start) {
1115            continue;
1116        }
1117        out.push_str(&text[copied..line_start]);
1118        out.push_str(entity);
1119        copied = line_start + prefix_len;
1120    }
1121
1122    out.push_str(&text[copied..]);
1123    out
1124}
1125
1126fn write_comment_block(out: &mut String, data: &CommentData) {
1127    let mut block = String::new();
1128    if !data.titles.is_empty() {
1129        let label = if data.titles.len() == 1 { "Title" } else { "Titles" };
1130        writeln!(block, "**{label}:** {}", data.titles.join(", ")).unwrap();
1131        writeln!(block).unwrap();
1132    }
1133    if !data.authors.is_empty() {
1134        let label = if data.authors.len() == 1 { "Author" } else { "Authors" };
1135        writeln!(block, "**{label}:** {}", data.authors.join(", ")).unwrap();
1136        writeln!(block).unwrap();
1137    }
1138    // Render descriptions in source order (notices and devs interleaved, continuations joined).
1139    // `@dev` paragraphs are wrapped in `_..._` per paragraph so each multi-line block renders
1140    // as a single italic span (markdown emphasis cannot cross blank lines).
1141    for desc in &data.descriptions {
1142        match desc.kind {
1143            DescKind::Notice => writeln!(block, "{}", desc.content).unwrap(),
1144            DescKind::Dev => writeln!(block, "{}", italicize_dev(&desc.content)).unwrap(),
1145        }
1146        writeln!(block).unwrap();
1147    }
1148    if !data.customs.is_empty() {
1149        let label = if data.customs.len() == 1 { "Note" } else { "Notes" };
1150        writeln!(block, "**{label}:**").unwrap();
1151        writeln!(block).unwrap();
1152        for (tag, content) in &data.customs {
1153            writeln!(block, "- **{tag}:** {content}").unwrap();
1154        }
1155        writeln!(block).unwrap();
1156    }
1157    // Neutralize the fully assembled block once: fence state stays continuous across the
1158    // whole block, and every displayed line (authors, notices, custom notes), not just
1159    // descriptions, is covered.
1160    out.push_str(&neutralize_esm(&block));
1161}
1162
1163fn write_code_block(out: &mut String, snippet: &str) {
1164    writeln!(out, "```solidity").unwrap();
1165    writeln!(out, "{}", snippet.trim_end()).unwrap();
1166    writeln!(out, "```").unwrap();
1167    writeln!(out).unwrap();
1168}
1169
1170/// Write link if `git_url` is set.
1171fn write_git_source(out: &mut String, git_url: Option<&str>) {
1172    if let Some(url) = git_url {
1173        writeln!(out, "[Git Source]({url})").unwrap();
1174        writeln!(out).unwrap();
1175    }
1176}
1177
1178/// Escape a value so it is safe inside a markdown (GFM) table cell:
1179/// - replace `|` with `\|` (column separator)
1180/// - replace newlines with `<br/>` (cells must be one logical line)
1181/// - replace `\r` so CRLF natspec doesn't create stray spaces
1182fn escape_table_cell(s: &str) -> String {
1183    s.replace('\\', "\\\\")
1184        .replace('|', "\\|")
1185        .replace("\r\n", "<br/>")
1186        .replace(['\n', '\r'], "<br/>")
1187}
1188
1189/// Write the **Deployments** table for a contract page.
1190fn write_deployments_table(out: &mut String, deployments: &[Deployment]) {
1191    if deployments.is_empty() {
1192        return;
1193    }
1194    writeln!(out, "**Deployments**").unwrap();
1195    writeln!(out).unwrap();
1196    writeln!(out, "| Network | Address |").unwrap();
1197    writeln!(out, "| ------- | ------- |").unwrap();
1198    for d in deployments {
1199        let network = escape_table_cell(d.network.as_deref().unwrap_or("-"));
1200        writeln!(out, "| {network} | `{:#x}` |", d.address).unwrap();
1201    }
1202    writeln!(out).unwrap();
1203}
1204
1205fn write_param_table(
1206    out: &mut String,
1207    heading: &str,
1208    params: &ParameterList<'_>,
1209    comments: &CommentData,
1210    inherited_params: Option<&[String]>,
1211    ctx: &Ctx<'_>,
1212) {
1213    if params.is_empty() {
1214        return;
1215    }
1216    writeln!(out, "**{heading}**").unwrap();
1217    writeln!(out).unwrap();
1218    writeln!(out, "| Name | Type | Description |").unwrap();
1219    writeln!(out, "| ---- | ---- | ----------- |").unwrap();
1220    let is_return = heading == "Returns";
1221    // Positional fall-back to `@custom:name <name>` for unnamed params
1222    // (parameters only, return names aren't substituted).
1223    let mut unnamed_iter = comments.unnamed_param_names.iter();
1224    for (index, var) in params.iter().enumerate() {
1225        let name = match var.name {
1226            Some(n) => n.as_str().to_string(),
1227            None if !is_return => unnamed_iter.next().cloned().unwrap_or_else(|| "_".to_string()),
1228            None => "&lt;none&gt;".to_string(),
1229        };
1230        let ty = format!("`{}`", ctx.snippet(var.ty.span).trim());
1231        let desc = if is_return {
1232            return_description(comments, index, var.name.map(|_| name.as_str()))
1233        } else {
1234            let named = comments.params.iter().find(|(n, _)| n == &name).map(|(_, d)| d.as_str());
1235            if var.name.is_none() {
1236                inherited_params
1237                    .and_then(|params| params.get(index))
1238                    .map(String::as_str)
1239                    .or(named)
1240                    .unwrap_or("")
1241            } else {
1242                named.unwrap_or("")
1243            }
1244        };
1245        let name = escape_table_cell(&name);
1246        let desc = escape_table_cell(desc);
1247        writeln!(out, "| {name} | {ty} | {desc} |").unwrap();
1248    }
1249    writeln!(out).unwrap();
1250}
1251
1252fn return_description<'a>(
1253    comments: &'a CommentData,
1254    index: usize,
1255    return_name: Option<&str>,
1256) -> &'a str {
1257    if let Some(return_name) = return_name
1258        && let Some((_, desc)) = comments.returns.iter().find(|(n, _)| n == return_name)
1259    {
1260        return desc;
1261    }
1262
1263    let Some((doc_name, desc)) = comments.returns.get(index) else {
1264        return "";
1265    };
1266
1267    if !doc_name.is_empty() {
1268        return desc;
1269    }
1270
1271    match return_name {
1272        Some(return_name) => desc.strip_prefix(return_name).and_then(strip_one_ws).unwrap_or(desc),
1273        None => desc,
1274    }
1275}
1276
1277fn strip_one_ws(s: &str) -> Option<&str> {
1278    let mut chars = s.char_indices();
1279    let (_, first) = chars.next()?;
1280    first.is_whitespace().then(|| chars.next().map(|(idx, _)| &s[idx..]).unwrap_or(""))
1281}
1282
1283fn write_struct_properties_table(
1284    out: &mut String,
1285    fields: &[VariableDefinition<'_>],
1286    comments: &CommentData,
1287    ctx: &Ctx<'_>,
1288) {
1289    if fields.is_empty() {
1290        return;
1291    }
1292    writeln!(out, "**Properties**").unwrap();
1293    writeln!(out).unwrap();
1294    writeln!(out, "| Name | Type | Description |").unwrap();
1295    writeln!(out, "| ---- | ---- | ----------- |").unwrap();
1296    for field in fields {
1297        let name = field.name.map(|n| n.as_str().to_string()).unwrap_or_else(|| "_".to_string());
1298        let ty = format!("`{}`", ctx.snippet(field.ty.span).trim());
1299        let desc =
1300            comments.params.iter().find(|(n, _)| n == &name).map(|(_, d)| d.as_str()).unwrap_or("");
1301        let name = escape_table_cell(&name);
1302        let desc = escape_table_cell(desc);
1303        writeln!(out, "| {name} | {ty} | {desc} |").unwrap();
1304    }
1305    writeln!(out).unwrap();
1306}
1307
1308fn write_enum_variants_table(out: &mut String, variants: &[Ident], comments: &CommentData) {
1309    if variants.is_empty() {
1310        return;
1311    }
1312    writeln!(out, "**Variants**").unwrap();
1313    writeln!(out).unwrap();
1314    writeln!(out, "| Name | Description |").unwrap();
1315    writeln!(out, "| ---- | ----------- |").unwrap();
1316    for variant in variants {
1317        let name = variant.as_str();
1318        let desc =
1319            comments.params.iter().find(|(n, _)| n == name).map(|(_, d)| d.as_str()).unwrap_or("");
1320        let name = escape_table_cell(name);
1321        let desc = escape_table_cell(desc);
1322        writeln!(out, "| {name} | {desc} |").unwrap();
1323    }
1324    writeln!(out).unwrap();
1325}
1326
1327const fn contract_kind_str(kind: ContractKind) -> &'static str {
1328    match kind {
1329        ContractKind::Contract => "contract",
1330        ContractKind::AbstractContract => "abstract",
1331        ContractKind::Interface => "interface",
1332        ContractKind::Library => "library",
1333    }
1334}
1335
1336/// Find the HIR `ContractId` for a contract by name, requiring the contract to
1337/// live in the source file currently being rendered (compared via absolute path)
1338/// so contracts that share a file stem across `src/` and `lib/` cannot collide.
1339fn find_contract_id<'gcx>(
1340    gcx: Gcx<'gcx>,
1341    name: &str,
1342    abs_sol_path: &Path,
1343) -> Option<hir::ContractId> {
1344    gcx.hir.contract_ids().find(|&id| {
1345        let c = gcx.hir.contract(id);
1346        if c.name.as_str() != name {
1347            return false;
1348        }
1349        match &gcx.hir.source(c.source).file.name {
1350            FileName::Real(p) => p == abs_sol_path,
1351            _ => false,
1352        }
1353    })
1354}
1355
1356/// Strip common leading whitespace from all non-empty lines.
1357fn dedent(s: &str) -> String {
1358    let lines: Vec<&str> = s.lines().collect();
1359    if lines.is_empty() {
1360        return s.to_string();
1361    }
1362    let indent = lines
1363        .iter()
1364        .filter(|l| !l.trim().is_empty())
1365        .map(|l| l.len() - l.trim_start().len())
1366        .min()
1367        .unwrap_or(0);
1368    lines
1369        .iter()
1370        .map(|l| if l.len() >= indent { &l[indent..] } else { l.trim() })
1371        .collect::<Vec<_>>()
1372        .join("\n")
1373}
1374
1375#[cfg(test)]
1376mod tests {
1377    use super::neutralize_esm;
1378    use markdown::{MdxSignal, ParseOptions, mdast::Node, to_mdast};
1379
1380    fn parse_mdx(text: &str) -> Node {
1381        let mut options = ParseOptions::mdx();
1382        options.mdx_esm_parse = Some(Box::new(|_| MdxSignal::Ok));
1383        to_mdast(text, &options).unwrap()
1384    }
1385
1386    fn contains_mdx_esm(node: &Node) -> bool {
1387        matches!(node, Node::MdxjsEsm(_))
1388            || node.children().is_some_and(|children| children.iter().any(contains_mdx_esm))
1389    }
1390
1391    #[test]
1392    fn preserves_line_endings_while_neutralizing_esm() {
1393        assert_eq!(
1394            neutralize_esm("Intro.\r\rexport const afterCarriageReturn = 1"),
1395            "Intro.\r\r&#101;xport const afterCarriageReturn = 1"
1396        );
1397        for (input, expected) in [
1398            (
1399                "```\rimport inside\r```\rexport outside",
1400                "```\rimport inside\r```\r&#101;xport outside",
1401            ),
1402            (
1403                "```\r\nimport inside\r\n```\r\nexport outside",
1404                "```\r\nimport inside\r\n```\r\n&#101;xport outside",
1405            ),
1406            (
1407                "`example:\rimport inside`\rexport outside",
1408                "`example:\rimport inside`\r&#101;xport outside",
1409            ),
1410            (
1411                "`example:\r\nimport inside`\r\nexport outside",
1412                "`example:\r\nimport inside`\r\n&#101;xport outside",
1413            ),
1414            (
1415                "    ```\r    import inside\r    ```\rexport outside",
1416                "    ```\r    import inside\r    ```\r&#101;xport outside",
1417            ),
1418            ("```\rinside\r    ```\rexport outside", "```\rinside\r    ```\r&#101;xport outside"),
1419        ] {
1420            assert_eq!(neutralize_esm(input), expected);
1421        }
1422        assert_eq!(
1423            neutralize_esm("Intro.\r\nimport outside"),
1424            "Intro.\r\n&#105;&#109;port outside"
1425        );
1426        assert_eq!(
1427            neutralize_esm("export first\r\npréface `span:\rimport inside`\r\nimport last"),
1428            "&#101;xport first\r\npréface `span:\rimport inside`\r\n&#105;&#109;port last"
1429        );
1430    }
1431
1432    #[test]
1433    fn traverses_sorted_code_regions() {
1434        let input = "`first`\n    ```\n    import inside fence\n    ```\n`last`\nexport outside";
1435        let expected =
1436            "`first`\n    ```\n    import inside fence\n    ```\n`last`\n&#101;xport outside";
1437        assert_eq!(neutralize_esm(input), expected);
1438    }
1439
1440    #[test]
1441    fn preserves_code_across_mdx_edge_cases() {
1442        for (input, expected) in [
1443            (
1444                "```\nimport inside\n```\nexport outside",
1445                "```\nimport inside\n```\n&#101;xport outside",
1446            ),
1447            (
1448                "```\n~~~\nimport inside\n```\nexport outside",
1449                "```\n~~~\nimport inside\n```\n&#101;xport outside",
1450            ),
1451            (
1452                "````\n```\nimport inside\n```\n````\nexport outside",
1453                "````\n```\nimport inside\n```\n````\n&#101;xport outside",
1454            ),
1455            (
1456                "```\nimport inside\n```suffix\nexport inside\n```\nimport outside",
1457                "```\nimport inside\n```suffix\nexport inside\n```\n&#105;&#109;port outside",
1458            ),
1459            (
1460                "`example:\nimport inside`\nexport outside",
1461                "`example:\nimport inside`\n&#101;xport outside",
1462            ),
1463            (
1464                "```\ninside\n    ```\n```\nimport inside second\n```\nexport outside",
1465                "```\ninside\n    ```\n```\nimport inside second\n```\n&#101;xport outside",
1466            ),
1467            (
1468                "- ```\n  import inside list\n  ```\n\nexport outside",
1469                "- ```\n  import inside list\n  ```\n\n&#101;xport outside",
1470            ),
1471            (
1472                "    ```\n    import inside indented\n    ```\nexport outside",
1473                "    ```\n    import inside indented\n    ```\n&#101;xport outside",
1474            ),
1475            ("    ```\n    import inside unclosed", "    ```\n    import inside unclosed"),
1476        ] {
1477            assert_eq!(neutralize_esm(input), expected, "input:\n{input}");
1478        }
1479    }
1480
1481    #[test]
1482    fn neutralized_output_contains_no_mdx_esm() {
1483        let input = "import injected from \"x\"\n\n```js\nexport const example = 1\n```";
1484        assert!(contains_mdx_esm(&parse_mdx(input)));
1485
1486        let output = neutralize_esm(input);
1487        assert_eq!(
1488            output,
1489            "&#105;&#109;port injected from \"x\"\n\n```js\nexport const example = 1\n```"
1490        );
1491
1492        let tree = parse_mdx(&output);
1493        assert!(!contains_mdx_esm(&tree), "{tree:#?}");
1494        assert!(tree.children().is_some_and(|children| {
1495            children.iter().any(
1496                |node| matches!(node, Node::Code(code) if code.value == "export const example = 1"),
1497            )
1498        }));
1499    }
1500
1501    #[test]
1502    fn leaves_non_esm_prefixes_unchanged() {
1503        let input = "important\nexporter\nimport_\nexport$\nImport value\nExport value\n    import value\n\t export value\nimport(value)\nexport: value\nimport\tvalue";
1504        assert_eq!(neutralize_esm(input), input);
1505    }
1506
1507    #[test]
1508    fn neutralizes_only_exact_mdx_esm_prefixes() {
1509        assert_eq!(
1510            neutralize_esm("import value\nexport value\nimport  value\nexport  value"),
1511            "&#105;&#109;port value\n&#101;xport value\n&#105;&#109;port  value\n&#101;xport  value"
1512        );
1513    }
1514
1515    #[test]
1516    fn neutralizes_many_candidates_across_many_code_regions() {
1517        let input = "`code`\nexport outside\n".repeat(10_000);
1518        let output = neutralize_esm(&input);
1519        assert_eq!(output.matches("`code`").count(), 10_000);
1520        assert_eq!(output.matches("&#101;xport outside").count(), 10_000);
1521    }
1522}