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 solar::{
8    ast::{
9        CommentKind, ContractKind, DocComments, FunctionKind, ItemContract, ItemEnum, ItemError,
10        ItemEvent, ItemFunction, ItemKind, ItemStruct, ItemUdvt, NatSpecKind, ParameterList,
11        SourceUnit, Span, VariableDefinition,
12    },
13    interface::{
14        Ident,
15        source_map::{FileName, SourceFile, SourceMap},
16    },
17    sema::{Gcx, hir},
18};
19use std::{
20    collections::HashMap,
21    fmt::Write as _,
22    path::{Path, PathBuf},
23    sync::Arc,
24};
25
26// ── public entry point ───────────────────────────────────────────────────────
27
28/// Render a single Solidity source file as a list of `(relative_output_path, mdx_content)` pairs.
29#[allow(clippy::too_many_arguments)]
30pub fn source<'ast, 'gcx>(
31    ast: &'ast SourceUnit<'ast>,
32    file: &Arc<SourceFile>,
33    _sm: &SourceMap,
34    rel_sol_path: &Path,
35    abs_sol_path: &Path,
36    _root: &Path,
37    gcx: Gcx<'gcx>,
38    name_to_page: &NameToPage,
39    git_url: Option<&str>,
40    deployments: &HashMap<String, Vec<Deployment>>,
41) -> Vec<(PathBuf, String)> {
42    let out_dir = rel_sol_path.parent().unwrap_or(Path::new(""));
43    let stem = rel_sol_path.file_stem().and_then(|s| s.to_str()).unwrap_or("constants");
44
45    let src_text = file.src.as_str();
46    let src_start = file.start_pos.to_usize();
47    let ctx = Ctx { src_text, src_start };
48
49    let mut pages: Vec<(PathBuf, String)> = Vec::new();
50    let mut const_vars: Vec<(Span, &VariableDefinition<'_>, &DocComments<'_>)> = Vec::new();
51    let mut free_fns: std::collections::BTreeMap<
52        String,
53        Vec<(Span, &ItemFunction<'_>, &DocComments<'_>)>,
54    > = Default::default();
55
56    for item in ast.items.iter() {
57        let span = item.span;
58        match &item.kind {
59            ItemKind::Pragma(_) | ItemKind::Import(_) | ItemKind::Using(_) => (),
60            ItemKind::Contract(c) => {
61                let kind_str = contract_kind_str(c.kind);
62                let fname = format!("{kind_str}.{}.mdx", c.name.as_str());
63                let page_path = out_dir.join(&fname);
64                // Look up HIR contract id for inheritance/inheritdoc.
65                let hir_id = find_contract_id(gcx, c.name.as_str(), abs_sol_path);
66                // Deployments only apply to non-abstract, non-interface, non-library contracts.
67                let contract_deployments = if matches!(c.kind, ContractKind::Contract) {
68                    deployments.get(c.name.as_str()).map(Vec::as_slice).unwrap_or(&[])
69                } else {
70                    &[]
71                };
72                let content = render_contract(
73                    span,
74                    c,
75                    &item.docs,
76                    &ctx,
77                    gcx,
78                    hir_id,
79                    name_to_page,
80                    &page_path,
81                    git_url,
82                    contract_deployments,
83                );
84                pages.push((page_path, content));
85            }
86
87            ItemKind::Function(f) => {
88                let name = f.header.name.map(|n| n.as_str().to_string()).unwrap_or_default();
89                free_fns.entry(name).or_default().push((span, f, &item.docs));
90            }
91
92            ItemKind::Variable(v) => {
93                const_vars.push((span, v, &item.docs));
94            }
95
96            ItemKind::Struct(s) => {
97                let fname = format!("struct.{}.mdx", s.name.as_str());
98                let page_path = out_dir.join(&fname);
99                pages.push((
100                    page_path.clone(),
101                    render_struct(span, s, &item.docs, &ctx, name_to_page, &page_path, git_url),
102                ));
103            }
104
105            ItemKind::Enum(e) => {
106                let fname = format!("enum.{}.mdx", e.name.as_str());
107                let page_path = out_dir.join(&fname);
108                pages.push((
109                    page_path.clone(),
110                    render_enum(span, e, &item.docs, &ctx, name_to_page, &page_path, git_url),
111                ));
112            }
113
114            ItemKind::Udvt(u) => {
115                let fname = format!("type.{}.mdx", u.name.as_str());
116                let page_path = out_dir.join(&fname);
117                pages.push((
118                    page_path.clone(),
119                    render_udvt(span, u, &item.docs, &ctx, name_to_page, &page_path, git_url),
120                ));
121            }
122
123            ItemKind::Error(e) => {
124                let fname = format!("error.{}.mdx", e.name.as_str());
125                let page_path = out_dir.join(&fname);
126                pages.push((
127                    page_path.clone(),
128                    render_error(span, e, &item.docs, &ctx, name_to_page, &page_path, git_url),
129                ));
130            }
131
132            ItemKind::Event(e) => {
133                let fname = format!("event.{}.mdx", e.name.as_str());
134                let page_path = out_dir.join(&fname);
135                pages.push((
136                    page_path.clone(),
137                    render_event(span, e, &item.docs, &ctx, name_to_page, &page_path, git_url),
138                ));
139            }
140        }
141    }
142
143    for (name, overloads) in &free_fns {
144        let fname = format!("function.{name}.mdx");
145        let page_path = out_dir.join(&fname);
146        let content =
147            render_free_functions(name, overloads, &ctx, name_to_page, &page_path, git_url);
148        pages.push((page_path, content));
149    }
150
151    if !const_vars.is_empty() {
152        let fname = format!("constants.{stem}.mdx");
153        let page_path = out_dir.join(&fname);
154        let content = render_constants(stem, &const_vars, &ctx, name_to_page, &page_path, git_url);
155        pages.push((page_path, content));
156    }
157
158    pages
159}
160
161// ── rendering context ────────────────────────────────────────────────────────
162
163struct Ctx<'a> {
164    src_text: &'a str,
165    src_start: usize,
166}
167
168impl<'a> Ctx<'a> {
169    fn snippet(&self, span: Span) -> &'a str {
170        let lo = span.lo().to_usize().saturating_sub(self.src_start);
171        let hi = span.hi().to_usize().saturating_sub(self.src_start);
172        let lo = lo.min(self.src_text.len());
173        let hi = hi.min(self.src_text.len());
174        &self.src_text[lo..hi]
175    }
176
177    fn dedented_snippet(&self, span: Span) -> String {
178        dedent(self.snippet(span))
179    }
180}
181
182// ── contract ─────────────────────────────────────────────────────────────────
183
184#[allow(clippy::too_many_arguments)]
185fn render_contract<'ast, 'gcx>(
186    _span: Span,
187    c: &'ast ItemContract<'ast>,
188    docs: &'ast DocComments<'ast>,
189    ctx: &Ctx<'_>,
190    gcx: Gcx<'gcx>,
191    hir_id: Option<hir::ContractId>,
192    name_to_page: &NameToPage,
193    page_path: &Path,
194    git_url: Option<&str>,
195    deployments: &[Deployment],
196) -> String {
197    let name = c.name.as_str();
198    let comments = collect_comments(docs, name_to_page, page_path);
199    let mut out = String::new();
200    write_frontmatter(&mut out, name, first_notice(&comments).as_deref());
201    writeln!(out, "# {name}").unwrap();
202    writeln!(out).unwrap();
203    write_git_source(&mut out, git_url);
204    write_deployments_table(&mut out, deployments);
205
206    // inheritance links.
207    if let Some(id) = hir_id
208        && let Some(inherits) = hir_ext::inheritance_links(gcx, id, name_to_page, page_path)
209    {
210        writeln!(out, "{inherits}").unwrap();
211        writeln!(out).unwrap();
212    }
213
214    write_comment_block(&mut out, &comments);
215
216    // Group members.
217    let mut constants: Vec<(Span, &VariableDefinition<'_>, &DocComments<'_>)> = Vec::new();
218    let mut state_vars: Vec<(Span, &VariableDefinition<'_>, &DocComments<'_>)> = Vec::new();
219    let mut functions: Vec<(Span, &ItemFunction<'_>, &DocComments<'_>)> = Vec::new();
220    let mut events: Vec<(Span, &ItemEvent<'_>, &DocComments<'_>)> = Vec::new();
221    let mut errors: Vec<(Span, &ItemError<'_>, &DocComments<'_>)> = Vec::new();
222    let mut structs: Vec<(Span, &ItemStruct<'_>, &DocComments<'_>)> = Vec::new();
223    let mut enums: Vec<(Span, &ItemEnum<'_>, &DocComments<'_>)> = Vec::new();
224    let mut udvts: Vec<(Span, &ItemUdvt<'_>, &DocComments<'_>)> = Vec::new();
225
226    for member in c.body.iter() {
227        let s = member.span;
228        match &member.kind {
229            ItemKind::Variable(v) => {
230                // constants and immutables get their own section.
231                if v.mutability.is_some_and(|m| m.is_constant() || m.is_immutable()) {
232                    constants.push((s, v, &member.docs));
233                } else {
234                    state_vars.push((s, v, &member.docs));
235                }
236            }
237            ItemKind::Function(f) => functions.push((s, f, &member.docs)),
238            ItemKind::Event(e) => events.push((s, e, &member.docs)),
239            ItemKind::Error(e) => errors.push((s, e, &member.docs)),
240            ItemKind::Struct(st) => structs.push((s, st, &member.docs)),
241            ItemKind::Enum(e) => enums.push((s, e, &member.docs)),
242            ItemKind::Udvt(u) => udvts.push((s, u, &member.docs)),
243            _ => {}
244        }
245    }
246
247    let write_vars =
248        |out: &mut String, vars: &[(Span, &VariableDefinition<'_>, &DocComments<'_>)]| {
249            for (span, v, docs) in vars {
250                let vname = v.name.map(|n| n.as_str().to_string()).unwrap_or_default();
251                writeln!(out, "### {vname}").unwrap();
252                writeln!(out).unwrap();
253                let mut c = collect_comments(docs, name_to_page, page_path);
254                // Attempt @inheritdoc resolution for public state variables.
255                let inherited = inheritdoc_base(docs).and_then(|base| {
256                    hir_id.and_then(|cid| hir_ext::resolve_inheritdoc_var(gcx, cid, &vname, &base))
257                });
258                if let Some(ref base_doc) = inherited {
259                    let sanitize =
260                        |s: &str| hir_ext::replace_inline_links(s, name_to_page, page_path);
261                    if c.notices.is_empty() {
262                        let inherited_notices: Vec<String> =
263                            base_doc.notices.iter().map(|s| sanitize(s)).collect();
264                        let mut new_desc: Vec<Description> = inherited_notices
265                            .iter()
266                            .map(|s| Description { kind: DescKind::Notice, content: s.clone() })
267                            .collect();
268                        new_desc.append(&mut c.descriptions);
269                        c.descriptions = new_desc;
270                        c.notices.extend(inherited_notices);
271                    }
272                    if c.devs.is_empty() {
273                        let inherited_devs: Vec<String> =
274                            base_doc.devs.iter().map(|s| sanitize(s)).collect();
275                        c.descriptions.extend(
276                            inherited_devs
277                                .iter()
278                                .map(|s| Description { kind: DescKind::Dev, content: s.clone() }),
279                        );
280                        c.devs.extend(inherited_devs);
281                    }
282                    for (name, desc) in &base_doc.returns {
283                        if !c.returns.iter().any(|(n, _)| n == name) {
284                            c.returns.push((name.clone(), sanitize(desc)));
285                        }
286                    }
287                }
288                write_comment_block(out, &c);
289                write_code_block(out, &ctx.dedented_snippet(*span));
290                if !c.returns.is_empty() {
291                    let ty = format!("`{}`", ctx.snippet(v.ty.span).trim());
292                    writeln!(out, "**Returns**").unwrap();
293                    writeln!(out).unwrap();
294                    writeln!(out, "| Name | Type | Description |").unwrap();
295                    writeln!(out, "| ---- | ---- | ----------- |").unwrap();
296                    for (name, desc) in &c.returns {
297                        // Solar parses `@return <first-word> <rest>` where the first word
298                        // becomes `name` and the rest becomes `desc`. For unnamed returns the
299                        // first word is actually part of the description, so recombine them.
300                        let full_desc =
301                            if desc.is_empty() { name.clone() } else { format!("{name} {desc}") };
302                        let desc_cell = escape_table_cell(&full_desc);
303                        writeln!(out, "| &lt;none&gt; | {ty} | {desc_cell} |").unwrap();
304                    }
305                    writeln!(out).unwrap();
306                }
307            }
308        };
309
310    if !constants.is_empty() {
311        writeln!(out, "## Constants").unwrap();
312        writeln!(out).unwrap();
313        write_vars(&mut out, &constants);
314    }
315
316    if !state_vars.is_empty() {
317        writeln!(out, "## State Variables").unwrap();
318        writeln!(out).unwrap();
319        write_vars(&mut out, &state_vars);
320    }
321
322    if !functions.is_empty() {
323        writeln!(out, "## Functions").unwrap();
324        writeln!(out).unwrap();
325        for (span, f, docs) in &functions {
326            let fn_name = f.header.name.map(|n| n.as_str().to_string());
327            // Attempt inheritdoc resolution.
328            let inherited = fn_name.as_deref().and_then(|fname| {
329                let base = inheritdoc_base(docs)?;
330                let param_types = hir_ext::parameter_type_strings(gcx, &f.header.parameters);
331                hir_id.and_then(|cid| {
332                    hir_ext::resolve_inheritdoc(gcx, cid, fname, &base, Some(&param_types))
333                })
334            });
335            render_function_section(
336                &mut out,
337                *span,
338                f,
339                docs,
340                ctx,
341                name_to_page,
342                page_path,
343                inherited.as_ref(),
344            );
345        }
346    }
347
348    if !events.is_empty() {
349        writeln!(out, "## Events").unwrap();
350        writeln!(out).unwrap();
351        for (span, e, docs) in &events {
352            writeln!(out, "### {}", e.name.as_str()).unwrap();
353            writeln!(out).unwrap();
354            let c = collect_comments(docs, name_to_page, page_path);
355            write_comment_block(&mut out, &c);
356            write_code_block(&mut out, &ctx.dedented_snippet(*span));
357            write_param_table(&mut out, "Parameters", &e.parameters, &c, ctx);
358        }
359    }
360
361    if !errors.is_empty() {
362        writeln!(out, "## Errors").unwrap();
363        writeln!(out).unwrap();
364        for (span, e, docs) in &errors {
365            writeln!(out, "### {}", e.name.as_str()).unwrap();
366            writeln!(out).unwrap();
367            let c = collect_comments(docs, name_to_page, page_path);
368            write_comment_block(&mut out, &c);
369            write_code_block(&mut out, &ctx.dedented_snippet(*span));
370            write_param_table(&mut out, "Parameters", &e.parameters, &c, ctx);
371        }
372    }
373
374    if !structs.is_empty() {
375        writeln!(out, "## Structs").unwrap();
376        writeln!(out).unwrap();
377        for (span, s, docs) in &structs {
378            writeln!(out, "### {}", s.name.as_str()).unwrap();
379            writeln!(out).unwrap();
380            let c = collect_comments(docs, name_to_page, page_path);
381            write_comment_block(&mut out, &c);
382            write_code_block(&mut out, &ctx.dedented_snippet(*span));
383            write_struct_properties_table(&mut out, s.fields, &c, ctx);
384        }
385    }
386
387    if !enums.is_empty() {
388        writeln!(out, "## Enums").unwrap();
389        writeln!(out).unwrap();
390        for (span, e, docs) in &enums {
391            writeln!(out, "### {}", e.name.as_str()).unwrap();
392            writeln!(out).unwrap();
393            let c = collect_comments(docs, name_to_page, page_path);
394            write_comment_block(&mut out, &c);
395            write_code_block(&mut out, &ctx.dedented_snippet(*span));
396            write_enum_variants_table(&mut out, e.variants, &c);
397        }
398    }
399
400    if !udvts.is_empty() {
401        writeln!(out, "## Custom Types").unwrap();
402        writeln!(out).unwrap();
403        for (span, u, docs) in &udvts {
404            writeln!(out, "### {}", u.name.as_str()).unwrap();
405            writeln!(out).unwrap();
406            let c = collect_comments(docs, name_to_page, page_path);
407            write_comment_block(&mut out, &c);
408            write_code_block(&mut out, &format!("{};", ctx.dedented_snippet(*span)));
409        }
410    }
411
412    out
413}
414
415// ── free functions ────────────────────────────────────────────────────────────
416
417fn render_free_functions(
418    name: &str,
419    overloads: &[(Span, &ItemFunction<'_>, &DocComments<'_>)],
420    ctx: &Ctx<'_>,
421    name_to_page: &NameToPage,
422    page_path: &Path,
423    git_url: Option<&str>,
424) -> String {
425    let title = if name.is_empty() { "function" } else { name };
426    let first_comments = collect_comments(overloads[0].2, name_to_page, page_path);
427    let mut out = String::new();
428    write_frontmatter(&mut out, title, first_notice(&first_comments).as_deref());
429    writeln!(out, "# {title}").unwrap();
430    writeln!(out).unwrap();
431    write_git_source(&mut out, git_url);
432    for (span, f, docs) in overloads {
433        render_function_section(&mut out, *span, f, docs, ctx, name_to_page, page_path, None);
434    }
435    out
436}
437
438// ── constants ─────────────────────────────────────────────────────────────────
439
440fn render_constants(
441    stem: &str,
442    vars: &[(Span, &VariableDefinition<'_>, &DocComments<'_>)],
443    ctx: &Ctx<'_>,
444    name_to_page: &NameToPage,
445    page_path: &Path,
446    git_url: Option<&str>,
447) -> String {
448    let title = format!("{stem} Constants");
449    let mut out = String::new();
450    write_frontmatter(&mut out, &title, None);
451    writeln!(out, "# {title}").unwrap();
452    writeln!(out).unwrap();
453    write_git_source(&mut out, git_url);
454    for (span, v, docs) in vars {
455        let name = v.name.map(|n| n.as_str().to_string()).unwrap_or_else(|| "_".to_string());
456        writeln!(out, "## {name}").unwrap();
457        writeln!(out).unwrap();
458        let c = collect_comments(docs, name_to_page, page_path);
459        write_comment_block(&mut out, &c);
460        write_code_block(&mut out, &ctx.dedented_snippet(*span));
461    }
462    out
463}
464
465// ── standalone items ──────────────────────────────────────────────────────────
466
467fn render_struct<'ast>(
468    span: Span,
469    s: &'ast ItemStruct<'ast>,
470    docs: &'ast DocComments<'ast>,
471    ctx: &Ctx<'_>,
472    name_to_page: &NameToPage,
473    page_path: &Path,
474    git_url: Option<&str>,
475) -> String {
476    let name = s.name.as_str();
477    let c = collect_comments(docs, name_to_page, page_path);
478    let mut out = String::new();
479    write_frontmatter(&mut out, name, first_notice(&c).as_deref());
480    writeln!(out, "# {name}").unwrap();
481    writeln!(out).unwrap();
482    write_git_source(&mut out, git_url);
483    write_comment_block(&mut out, &c);
484    write_code_block(&mut out, &ctx.dedented_snippet(span));
485    write_struct_properties_table(&mut out, s.fields, &c, ctx);
486    out
487}
488
489fn render_enum<'ast>(
490    span: Span,
491    e: &'ast ItemEnum<'ast>,
492    docs: &'ast DocComments<'ast>,
493    ctx: &Ctx<'_>,
494    name_to_page: &NameToPage,
495    page_path: &Path,
496    git_url: Option<&str>,
497) -> String {
498    let name = e.name.as_str();
499    let c = collect_comments(docs, name_to_page, page_path);
500    let mut out = String::new();
501    write_frontmatter(&mut out, name, first_notice(&c).as_deref());
502    writeln!(out, "# {name}").unwrap();
503    writeln!(out).unwrap();
504    write_git_source(&mut out, git_url);
505    write_comment_block(&mut out, &c);
506    write_code_block(&mut out, &ctx.dedented_snippet(span));
507    write_enum_variants_table(&mut out, e.variants, &c);
508    out
509}
510
511fn render_udvt<'ast>(
512    span: Span,
513    u: &'ast ItemUdvt<'ast>,
514    docs: &'ast DocComments<'ast>,
515    ctx: &Ctx<'_>,
516    name_to_page: &NameToPage,
517    page_path: &Path,
518    git_url: Option<&str>,
519) -> String {
520    let name = u.name.as_str();
521    let c = collect_comments(docs, name_to_page, page_path);
522    let mut out = String::new();
523    write_frontmatter(&mut out, name, first_notice(&c).as_deref());
524    writeln!(out, "# {name}").unwrap();
525    writeln!(out).unwrap();
526    write_git_source(&mut out, git_url);
527    write_comment_block(&mut out, &c);
528    write_code_block(&mut out, &format!("{};", ctx.dedented_snippet(span)));
529    out
530}
531
532fn render_error<'ast>(
533    span: Span,
534    e: &'ast ItemError<'ast>,
535    docs: &'ast DocComments<'ast>,
536    ctx: &Ctx<'_>,
537    name_to_page: &NameToPage,
538    page_path: &Path,
539    git_url: Option<&str>,
540) -> String {
541    let name = e.name.as_str();
542    let c = collect_comments(docs, name_to_page, page_path);
543    let mut out = String::new();
544    write_frontmatter(&mut out, name, first_notice(&c).as_deref());
545    writeln!(out, "# {name}").unwrap();
546    writeln!(out).unwrap();
547    write_git_source(&mut out, git_url);
548    write_comment_block(&mut out, &c);
549    write_code_block(&mut out, &ctx.dedented_snippet(span));
550    write_param_table(&mut out, "Parameters", &e.parameters, &c, ctx);
551    out
552}
553
554fn render_event<'ast>(
555    span: Span,
556    e: &'ast ItemEvent<'ast>,
557    docs: &'ast DocComments<'ast>,
558    ctx: &Ctx<'_>,
559    name_to_page: &NameToPage,
560    page_path: &Path,
561    git_url: Option<&str>,
562) -> String {
563    let name = e.name.as_str();
564    let c = collect_comments(docs, name_to_page, page_path);
565    let mut out = String::new();
566    write_frontmatter(&mut out, name, first_notice(&c).as_deref());
567    writeln!(out, "# {name}").unwrap();
568    writeln!(out).unwrap();
569    write_git_source(&mut out, git_url);
570    write_comment_block(&mut out, &c);
571    write_code_block(&mut out, &ctx.dedented_snippet(span));
572    write_param_table(&mut out, "Parameters", &e.parameters, &c, ctx);
573    out
574}
575
576// ── function section ──────────────────────────────────────────────────────────
577#[allow(clippy::too_many_arguments)]
578fn render_function_section(
579    out: &mut String,
580    span: Span,
581    f: &ItemFunction<'_>,
582    docs: &DocComments<'_>,
583    ctx: &Ctx<'_>,
584    name_to_page: &NameToPage,
585    page_path: &Path,
586    inherited: Option<&hir_ext::InheritedDoc>,
587) {
588    let heading = function_heading(f);
589    if let Some(anchor) = function_signature_anchor(f, ctx) {
590        writeln!(out, "<a id=\"{anchor}\"></a>").unwrap();
591        writeln!(out).unwrap();
592    }
593    writeln!(out, "### {heading}").unwrap();
594    writeln!(out).unwrap();
595    let mut c = collect_comments(docs, name_to_page, page_path);
596    // Merge inherited natspec for missing tags.
597    if let Some(inherited) = inherited {
598        let sanitize = |s: &str| hir_ext::replace_inline_links(s, name_to_page, page_path);
599        let inherited_notices: Vec<String> =
600            inherited.notices.iter().map(|s| sanitize(s)).collect();
601        let inherited_devs: Vec<String> = inherited.devs.iter().map(|s| sanitize(s)).collect();
602        if c.notices.is_empty() {
603            let mut new_desc: Vec<Description> = inherited_notices
604                .iter()
605                .map(|s| Description { kind: DescKind::Notice, content: s.clone() })
606                .collect();
607            new_desc.append(&mut c.descriptions);
608            c.descriptions = new_desc;
609            c.notices.extend_from_slice(&inherited_notices);
610        }
611        if c.devs.is_empty() {
612            c.devs.extend_from_slice(&inherited_devs);
613            c.descriptions.extend(
614                inherited_devs
615                    .iter()
616                    .map(|s| Description { kind: DescKind::Dev, content: s.clone() }),
617            );
618        }
619        for (name, desc) in &inherited.params {
620            if !c.params.iter().any(|(n, _)| n == name) {
621                c.params.push((name.clone(), sanitize(desc)));
622            }
623        }
624        for (name, desc) in &inherited.returns {
625            if !c.returns.iter().any(|(n, _)| n == name) {
626                c.returns.push((name.clone(), sanitize(desc)));
627            }
628        }
629    }
630    write_comment_block(out, &c);
631    let hspan = if f.header.span.lo() == f.header.span.hi() { span } else { f.header.span };
632    let snippet = ctx.dedented_snippet(hspan);
633    write_code_block(out, &format!("{snippet};"));
634    write_param_table(out, "Parameters", &f.header.parameters, &c, ctx);
635    if let Some(returns) = &f.header.returns {
636        write_param_table(out, "Returns", returns, &c, ctx);
637    }
638}
639
640fn function_heading(f: &ItemFunction<'_>) -> String {
641    match f.kind {
642        FunctionKind::Constructor => "constructor".to_string(),
643        FunctionKind::Fallback => "fallback".to_string(),
644        FunctionKind::Receive => "receive".to_string(),
645        FunctionKind::Function | FunctionKind::Modifier => {
646            f.header.name.map(|n| n.as_str().to_string()).unwrap_or_else(|| "function".to_string())
647        }
648    }
649}
650
651fn function_signature_anchor(f: &ItemFunction<'_>, ctx: &Ctx<'_>) -> Option<String> {
652    let name = match f.kind {
653        FunctionKind::Constructor => "constructor".to_string(),
654        FunctionKind::Fallback => "fallback".to_string(),
655        FunctionKind::Receive => "receive".to_string(),
656        FunctionKind::Function | FunctionKind::Modifier => f.header.name?.as_str().to_string(),
657    };
658    let params = f
659        .header
660        .parameters
661        .vars
662        .iter()
663        .map(|v| ctx.snippet(v.ty.span).trim().to_string())
664        .collect::<Vec<_>>();
665
666    Some(hir_ext::function_signature_anchor(&name, &params))
667}
668
669// ── natspec comment collection ────────────────────────────────────────────────
670
671#[derive(Clone, Copy, PartialEq, Eq)]
672enum DescKind {
673    Notice,
674    Dev,
675}
676
677struct Description {
678    kind: DescKind,
679    content: String,
680}
681
682struct CommentData {
683    titles: Vec<String>,
684    authors: Vec<String>,
685    /// Real `@notice` items (used for inheritdoc merging and frontmatter description).
686    notices: Vec<String>,
687    /// Real `@dev` items (used for inheritdoc merging).
688    devs: Vec<String>,
689    /// All notice/dev text in source order, tagged with their kind, with continuation
690    /// lines joined to their parent. Used for rendering to preserve correct paragraph
691    /// ordering and to italicize `@dev` paragraphs as a whole.
692    descriptions: Vec<Description>,
693    params: Vec<(String, String)>,
694    returns: Vec<(String, String)>,
695    customs: Vec<(String, String)>,
696    /// `@custom:name <name>` values, used to fill in unnamed function parameters.
697    unnamed_param_names: Vec<String>,
698}
699
700/// Collect natspec from doc comments, applying inline link replacement.
701///
702/// Solar emits each `///` line as a separate `DocComment`. Lines without a `@` tag become
703/// synthetic `@notice` items with leading whitespace in their raw content. We detect these
704/// continuation lines and join them to the previous description paragraph so that multi-line
705/// natspec tags appear as a single coherent block in the right source order.
706fn collect_comments(
707    docs: &DocComments<'_>,
708    name_to_page: &NameToPage,
709    page_path: &Path,
710) -> CommentData {
711    let mut data = CommentData {
712        titles: Vec::new(),
713        authors: Vec::new(),
714        notices: Vec::new(),
715        devs: Vec::new(),
716        descriptions: Vec::new(),
717        params: Vec::new(),
718        returns: Vec::new(),
719        customs: Vec::new(),
720        unnamed_param_names: Vec::new(),
721    };
722
723    // Tags that are not user-facing natspec; do not warn on these.
724    const FILTERED_CUSTOM: &[&str] = &["solidity", "src", "use-src", "ast-id"];
725    // Recognised natspec custom tags (mirror legacy behaviour).
726    const KNOWN_CUSTOM: &[&str] = &["name"];
727
728    // Track whether the previous DocComment was blank (empty natspec), which signals a
729    // paragraph break even between continuation lines.
730    let mut prev_doc_was_blank = false;
731    #[derive(Clone, Copy)]
732    enum LastSection {
733        Desc, // notice or dev (both go through descriptions)
734        Param,
735        Return,
736    }
737    let mut last_section: Option<LastSection> = None;
738
739    for doc in docs.iter() {
740        if doc.natspec.is_empty() {
741            prev_doc_was_blank = true;
742            continue;
743        }
744
745        for item in doc.natspec.iter() {
746            let raw = doc.natspec_content(item);
747            // For /** */ block comments Solar preserves raw ` * ` line decorations inside the
748            // content range. Strip them so multi-line content renders cleanly.
749            let raw: &str =
750                if doc.kind == CommentKind::Block { &clean_block_doc_content(raw) } else { raw };
751
752            // Detect a solar "synthetic" @notice: a continuation line with no `@` tag.
753            // Solar produces these when a `///` line has no tag; the raw content starts
754            // with whitespace (the indentation after `///`).
755            let is_continuation = matches!(item.kind, NatSpecKind::Notice)
756                && raw.starts_with(|c: char| c.is_whitespace());
757
758            let trimmed = raw.trim();
759            if trimmed.is_empty() {
760                prev_doc_was_blank = true;
761                continue;
762            }
763
764            // Apply inline {Ident} -> markdown link replacement.
765            let content = hir_ext::replace_inline_links(trimmed, name_to_page, page_path);
766
767            if is_continuation && !prev_doc_was_blank {
768                let appended = match last_section {
769                    Some(LastSection::Desc) => data.descriptions.last_mut().map(|d| &mut d.content),
770                    Some(LastSection::Param) => data.params.last_mut().map(|(_, d)| d),
771                    Some(LastSection::Return) => data.returns.last_mut().map(|(_, d)| d),
772                    None => None,
773                };
774                if let Some(last) = appended {
775                    last.push('\n');
776                    last.push_str(&content);
777                    prev_doc_was_blank = false;
778                    continue;
779                }
780            }
781
782            prev_doc_was_blank = false;
783
784            match item.kind {
785                NatSpecKind::Title => data.titles.push(content),
786                NatSpecKind::Author => data.authors.push(content),
787                NatSpecKind::Notice => {
788                    data.notices.push(content.clone());
789                    data.descriptions.push(Description { kind: DescKind::Notice, content });
790                    last_section = Some(LastSection::Desc);
791                }
792                NatSpecKind::Dev => {
793                    data.devs.push(content.clone());
794                    data.descriptions.push(Description { kind: DescKind::Dev, content });
795                    last_section = Some(LastSection::Desc);
796                }
797                NatSpecKind::Param { name } => {
798                    data.params.push((name.as_str().to_string(), content));
799                    last_section = Some(LastSection::Param);
800                }
801                NatSpecKind::Return { name } => {
802                    data.returns.push((
803                        name.map(|name| name.as_str().to_string()).unwrap_or_default(),
804                        content,
805                    ));
806                    last_section = Some(LastSection::Return);
807                }
808                NatSpecKind::Inheritdoc { .. } => {} // resolved separately via HIR
809                NatSpecKind::Custom { name } => {
810                    let tag = name.as_str();
811                    if FILTERED_CUSTOM.contains(&tag) {
812                        // Silently ignored.
813                    } else if tag == "name" {
814                        // `@custom:name <name>` -> unnamed param name (legacy parity).
815                        if let Some(first) = content.split_whitespace().next() {
816                            data.unnamed_param_names.push(first.to_string());
817                        }
818                    } else {
819                        // unknown-natspec-tag warning.
820                        if !KNOWN_CUSTOM.contains(&tag) && !is_known_custom_tag(tag) {
821                            warn!("unknown natspec custom tag: @custom:{tag}");
822                        }
823                        data.customs.push((tag.to_string(), content));
824                    }
825                }
826                NatSpecKind::Internal { .. } => {}
827            }
828        }
829    }
830
831    data
832}
833
834/// Returns true if `tag` looks like a generally-recognised natspec custom tag.
835///
836/// We accept any non-empty alphanumeric/dash identifier as "known enough" not
837/// to warn, only obviously malformed tags trigger the warning channel.
838fn is_known_custom_tag(tag: &str) -> bool {
839    !tag.is_empty() && tag.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
840}
841
842/// Returns the base contract name from `@inheritdoc Base`, or `None`.
843fn inheritdoc_base(docs: &DocComments<'_>) -> Option<String> {
844    for doc in docs.iter() {
845        for item in doc.natspec.iter() {
846            if let NatSpecKind::Inheritdoc { contract } = item.kind {
847                return Some(contract.as_str().to_string());
848            }
849        }
850    }
851    None
852}
853
854fn first_notice(data: &CommentData) -> Option<String> {
855    data.notices.first().cloned()
856}
857
858// ── markdown output helpers ───────────────────────────────────────────────────
859
860fn write_frontmatter(out: &mut String, title: &str, description: Option<&str>) {
861    writeln!(out, "---").unwrap();
862    writeln!(out, "title: \"{}\"", yaml_escape_double_quoted(title)).unwrap();
863    if let Some(desc) = description {
864        // Collapse whitespace so multi-line notices stay on one line, then escape.
865        let collapsed: String = desc.split_whitespace().collect::<Vec<_>>().join(" ");
866        writeln!(out, "description: \"{}\"", yaml_escape_double_quoted(&collapsed)).unwrap();
867    }
868    writeln!(out, "---").unwrap();
869    writeln!(out).unwrap();
870}
871
872/// Escape a string for use as a YAML double-quoted scalar.
873///
874/// Per the YAML 1.2 spec, double-quoted scalars must escape `"` and `\`, and
875/// any control character (including newline, tab, carriage return) must be
876/// represented via an escape sequence rather than embedded literally.
877fn yaml_escape_double_quoted(s: &str) -> String {
878    let mut out = String::with_capacity(s.len());
879    for c in s.chars() {
880        match c {
881            '\\' => out.push_str("\\\\"),
882            '"' => out.push_str("\\\""),
883            '\n' => out.push_str("\\n"),
884            '\r' => out.push_str("\\r"),
885            '\t' => out.push_str("\\t"),
886            '\u{0}' => out.push_str("\\0"),
887            c if (c as u32) < 0x20 => {
888                out.push_str(&format!("\\x{:02x}", c as u32));
889            }
890            c => out.push(c),
891        }
892    }
893    out
894}
895
896/// Italicize a `@dev` block by wrapping it in `<i>...</i>` HTML tags. Surrounding
897/// blank lines around the tags ensure MDX/CommonMark parses the inner content as
898/// block-level markdown (lists, code fences, multiple paragraphs all work).
899fn italicize_dev(content: &str) -> String {
900    let trimmed = content.trim_matches('\n');
901    if trimmed.is_empty() { String::new() } else { format!("<i>\n\n{trimmed}\n\n</i>") }
902}
903
904fn write_comment_block(out: &mut String, data: &CommentData) {
905    if !data.titles.is_empty() {
906        let label = if data.titles.len() == 1 { "Title" } else { "Titles" };
907        writeln!(out, "**{label}:** {}", data.titles.join(", ")).unwrap();
908        writeln!(out).unwrap();
909    }
910    if !data.authors.is_empty() {
911        let label = if data.authors.len() == 1 { "Author" } else { "Authors" };
912        writeln!(out, "**{label}:** {}", data.authors.join(", ")).unwrap();
913        writeln!(out).unwrap();
914    }
915    // Render descriptions in source order (notices and devs interleaved, continuations joined).
916    // `@dev` paragraphs are wrapped in `_..._` per paragraph so each multi-line block renders
917    // as a single italic span (markdown emphasis cannot cross blank lines).
918    for desc in &data.descriptions {
919        match desc.kind {
920            DescKind::Notice => writeln!(out, "{}", desc.content).unwrap(),
921            DescKind::Dev => writeln!(out, "{}", italicize_dev(&desc.content)).unwrap(),
922        }
923        writeln!(out).unwrap();
924    }
925    if !data.customs.is_empty() {
926        let label = if data.customs.len() == 1 { "Note" } else { "Notes" };
927        writeln!(out, "**{label}:**").unwrap();
928        writeln!(out).unwrap();
929        for (tag, content) in &data.customs {
930            writeln!(out, "- **{tag}:** {content}").unwrap();
931        }
932        writeln!(out).unwrap();
933    }
934}
935
936fn write_code_block(out: &mut String, snippet: &str) {
937    writeln!(out, "```solidity").unwrap();
938    writeln!(out, "{}", snippet.trim_end()).unwrap();
939    writeln!(out, "```").unwrap();
940    writeln!(out).unwrap();
941}
942
943/// Write link if `git_url` is set.
944fn write_git_source(out: &mut String, git_url: Option<&str>) {
945    if let Some(url) = git_url {
946        writeln!(out, "[Git Source]({url})").unwrap();
947        writeln!(out).unwrap();
948    }
949}
950
951/// Escape a value so it is safe inside a markdown (GFM) table cell:
952/// - replace `|` with `\|` (column separator)
953/// - replace newlines with `<br/>` (cells must be one logical line)
954/// - replace `\r` so CRLF natspec doesn't create stray spaces
955fn escape_table_cell(s: &str) -> String {
956    s.replace('\\', "\\\\")
957        .replace('|', "\\|")
958        .replace("\r\n", "<br/>")
959        .replace(['\n', '\r'], "<br/>")
960}
961
962/// Write the **Deployments** table for a contract page.
963fn write_deployments_table(out: &mut String, deployments: &[Deployment]) {
964    if deployments.is_empty() {
965        return;
966    }
967    writeln!(out, "**Deployments**").unwrap();
968    writeln!(out).unwrap();
969    writeln!(out, "| Network | Address |").unwrap();
970    writeln!(out, "| ------- | ------- |").unwrap();
971    for d in deployments {
972        let network = escape_table_cell(d.network.as_deref().unwrap_or("-"));
973        writeln!(out, "| {network} | `{:#x}` |", d.address).unwrap();
974    }
975    writeln!(out).unwrap();
976}
977
978fn write_param_table(
979    out: &mut String,
980    heading: &str,
981    params: &ParameterList<'_>,
982    comments: &CommentData,
983    ctx: &Ctx<'_>,
984) {
985    if params.is_empty() {
986        return;
987    }
988    writeln!(out, "**{heading}**").unwrap();
989    writeln!(out).unwrap();
990    writeln!(out, "| Name | Type | Description |").unwrap();
991    writeln!(out, "| ---- | ---- | ----------- |").unwrap();
992    let is_return = heading == "Returns";
993    // Positional fall-back to `@custom:name <name>` for unnamed params
994    // (parameters only, return names aren't substituted).
995    let mut unnamed_iter = comments.unnamed_param_names.iter();
996    for (index, var) in params.iter().enumerate() {
997        let name = match var.name {
998            Some(n) => n.as_str().to_string(),
999            None if !is_return => unnamed_iter.next().cloned().unwrap_or_else(|| "_".to_string()),
1000            None => "&lt;none&gt;".to_string(),
1001        };
1002        let ty = format!("`{}`", ctx.snippet(var.ty.span).trim());
1003        let desc = if is_return {
1004            return_description(comments, index, var.name.map(|_| name.as_str()))
1005        } else {
1006            comments.params.iter().find(|(n, _)| n == &name).map(|(_, d)| d.as_str()).unwrap_or("")
1007        };
1008        let name = escape_table_cell(&name);
1009        let desc = escape_table_cell(desc);
1010        writeln!(out, "| {name} | {ty} | {desc} |").unwrap();
1011    }
1012    writeln!(out).unwrap();
1013}
1014
1015fn return_description<'a>(
1016    comments: &'a CommentData,
1017    index: usize,
1018    return_name: Option<&str>,
1019) -> &'a str {
1020    if let Some(return_name) = return_name
1021        && let Some((_, desc)) = comments.returns.iter().find(|(n, _)| n == return_name)
1022    {
1023        return desc;
1024    }
1025
1026    let Some((doc_name, desc)) = comments.returns.get(index) else {
1027        return "";
1028    };
1029
1030    if !doc_name.is_empty() {
1031        return desc;
1032    }
1033
1034    match return_name {
1035        Some(return_name) => desc.strip_prefix(return_name).and_then(strip_one_ws).unwrap_or(desc),
1036        None => split_first_word(desc).map(|(_, desc)| desc).unwrap_or(desc),
1037    }
1038}
1039
1040fn strip_one_ws(s: &str) -> Option<&str> {
1041    let mut chars = s.char_indices();
1042    let (_, first) = chars.next()?;
1043    first.is_whitespace().then(|| chars.next().map(|(idx, _)| &s[idx..]).unwrap_or(""))
1044}
1045
1046fn split_first_word(s: &str) -> Option<(&str, &str)> {
1047    let trimmed = s.trim_start();
1048    let split = trimmed.find(char::is_whitespace)?;
1049    let (first, rest) = trimmed.split_at(split);
1050    Some((first, rest.trim_start()))
1051}
1052
1053fn write_struct_properties_table(
1054    out: &mut String,
1055    fields: &[VariableDefinition<'_>],
1056    comments: &CommentData,
1057    ctx: &Ctx<'_>,
1058) {
1059    if fields.is_empty() {
1060        return;
1061    }
1062    writeln!(out, "**Properties**").unwrap();
1063    writeln!(out).unwrap();
1064    writeln!(out, "| Name | Type | Description |").unwrap();
1065    writeln!(out, "| ---- | ---- | ----------- |").unwrap();
1066    for field in fields {
1067        let name = field.name.map(|n| n.as_str().to_string()).unwrap_or_else(|| "_".to_string());
1068        let ty = format!("`{}`", ctx.snippet(field.ty.span).trim());
1069        let desc =
1070            comments.params.iter().find(|(n, _)| n == &name).map(|(_, d)| d.as_str()).unwrap_or("");
1071        let name = escape_table_cell(&name);
1072        let desc = escape_table_cell(desc);
1073        writeln!(out, "| {name} | {ty} | {desc} |").unwrap();
1074    }
1075    writeln!(out).unwrap();
1076}
1077
1078fn write_enum_variants_table(out: &mut String, variants: &[Ident], comments: &CommentData) {
1079    if variants.is_empty() {
1080        return;
1081    }
1082    writeln!(out, "**Variants**").unwrap();
1083    writeln!(out).unwrap();
1084    writeln!(out, "| Name | Description |").unwrap();
1085    writeln!(out, "| ---- | ----------- |").unwrap();
1086    for variant in variants {
1087        let name = variant.as_str();
1088        let desc =
1089            comments.params.iter().find(|(n, _)| n == name).map(|(_, d)| d.as_str()).unwrap_or("");
1090        let name = escape_table_cell(name);
1091        let desc = escape_table_cell(desc);
1092        writeln!(out, "| {name} | {desc} |").unwrap();
1093    }
1094    writeln!(out).unwrap();
1095}
1096
1097const fn contract_kind_str(kind: ContractKind) -> &'static str {
1098    match kind {
1099        ContractKind::Contract => "contract",
1100        ContractKind::AbstractContract => "abstract",
1101        ContractKind::Interface => "interface",
1102        ContractKind::Library => "library",
1103    }
1104}
1105
1106/// Find the HIR `ContractId` for a contract by name, requiring the contract to
1107/// live in the source file currently being rendered (compared via absolute path)
1108/// so contracts that share a file stem across `src/` and `lib/` cannot collide.
1109fn find_contract_id<'gcx>(
1110    gcx: Gcx<'gcx>,
1111    name: &str,
1112    abs_sol_path: &Path,
1113) -> Option<hir::ContractId> {
1114    gcx.hir.contract_ids().find(|&id| {
1115        let c = gcx.hir.contract(id);
1116        if c.name.as_str() != name {
1117            return false;
1118        }
1119        match &gcx.hir.source(c.source).file.name {
1120            FileName::Real(p) => p == abs_sol_path,
1121            _ => false,
1122        }
1123    })
1124}
1125
1126/// Strip common leading whitespace from all non-empty lines.
1127fn dedent(s: &str) -> String {
1128    let lines: Vec<&str> = s.lines().collect();
1129    if lines.is_empty() {
1130        return s.to_string();
1131    }
1132    let indent = lines
1133        .iter()
1134        .filter(|l| !l.trim().is_empty())
1135        .map(|l| l.len() - l.trim_start().len())
1136        .min()
1137        .unwrap_or(0);
1138    lines
1139        .iter()
1140        .map(|l| if l.len() >= indent { &l[indent..] } else { l.trim() })
1141        .collect::<Vec<_>>()
1142        .join("\n")
1143}