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