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