1use crate::{
4 hir_ext::{self, NameToPage, clean_block_doc_content},
5 utils::Deployment,
6};
7use foundry_common::sh_warn;
8use markdown::{ParseOptions, mdast::Node, to_mdast};
9use solar::{
10 ast::{
11 CommentKind, ContractKind, DocComments, FunctionKind, ItemContract, ItemEnum, ItemError,
12 ItemEvent, ItemFunction, ItemKind, ItemStruct, ItemUdvt, NatSpecKind, ParameterList,
13 SourceUnit, Span, VariableDefinition,
14 },
15 interface::{
16 Ident,
17 source_map::{FileName, SourceFile, SourceMap},
18 },
19 sema::{Gcx, hir},
20};
21use std::{
22 collections::HashMap,
23 fmt::Write as _,
24 ops::Range,
25 path::{Path, PathBuf},
26 sync::Arc,
27};
28
29struct Ctx<'a> {
32 src_text: &'a str,
33 src_start: usize,
34}
35
36impl<'a> Ctx<'a> {
37 fn snippet(&self, span: Span) -> &'a str {
38 let lo = span.lo().to_usize().saturating_sub(self.src_start);
39 let hi = span.hi().to_usize().saturating_sub(self.src_start);
40 let lo = lo.min(self.src_text.len());
41 let hi = hi.min(self.src_text.len());
42 &self.src_text[lo..hi]
43 }
44
45 fn dedented_snippet(&self, span: Span) -> String {
46 dedent(self.snippet(span))
47 }
48}
49
50#[allow(clippy::too_many_arguments)]
53fn render_contract<'ast, 'gcx>(
54 _span: Span,
55 c: &'ast ItemContract<'ast>,
56 docs: &'ast DocComments<'ast>,
57 ctx: &Ctx<'_>,
58 gcx: Gcx<'gcx>,
59 hir_id: Option<hir::ContractId>,
60 name_to_page: &NameToPage,
61 page_path: &Path,
62 git_url: Option<&str>,
63 deployments: &[Deployment],
64) -> String {
65 let name = c.name.as_str();
66
67 let mut local = hir_id.map_or_else(
70 || hir_ext::LocalMembers::new(name),
71 |id| hir_ext::LocalMembers::for_contract(gcx, id, name_to_page),
72 );
73 for member in c.body.iter() {
74 match &member.kind {
75 ItemKind::Variable(v) => {
76 if let Some(n) = v.name {
77 local.insert(n.as_str());
78 }
79 }
80 ItemKind::Function(f) => {
81 local.insert(&function_heading(f));
82 if let Some(anchor) = function_signature_anchor(f, ctx) {
83 local.insert_anchor(anchor);
84 }
85 }
86 ItemKind::Event(e) => local.insert(e.name.as_str()),
87 ItemKind::Error(e) => local.insert(e.name.as_str()),
88 ItemKind::Struct(s) => local.insert(s.name.as_str()),
89 ItemKind::Enum(e) => local.insert(e.name.as_str()),
90 ItemKind::Udvt(u) => local.insert(u.name.as_str()),
91 _ => {}
92 }
93 }
94 let local = Some(&local);
95
96 let comments = collect_comments(docs, name_to_page, page_path, local);
97 let mut out = write_page_header(name, first_notice(&comments).as_deref(), git_url);
98 write_deployments_table(&mut out, deployments);
99
100 if let Some(id) = hir_id
102 && let Some(inherits) = hir_ext::inheritance_links(gcx, id, name_to_page, page_path)
103 {
104 writeln!(out, "{inherits}").unwrap();
105 writeln!(out).unwrap();
106 }
107
108 write_comment_block(&mut out, &comments);
109
110 let mut constants: Vec<(Span, &VariableDefinition<'_>, &DocComments<'_>)> = Vec::new();
112 let mut state_vars: Vec<(Span, &VariableDefinition<'_>, &DocComments<'_>)> = Vec::new();
113 let mut functions: Vec<(Span, &ItemFunction<'_>, &DocComments<'_>)> = Vec::new();
114 let mut events: Vec<(Span, &ItemEvent<'_>, &DocComments<'_>)> = Vec::new();
115 let mut errors: Vec<(Span, &ItemError<'_>, &DocComments<'_>)> = Vec::new();
116 let mut structs: Vec<(Span, &ItemStruct<'_>, &DocComments<'_>)> = Vec::new();
117 let mut enums: Vec<(Span, &ItemEnum<'_>, &DocComments<'_>)> = Vec::new();
118 let mut udvts: Vec<(Span, &ItemUdvt<'_>, &DocComments<'_>)> = Vec::new();
119
120 for member in c.body.iter() {
121 let s = member.span;
122 match &member.kind {
123 ItemKind::Variable(v) => {
124 if v.mutability.is_some_and(|m| m.is_constant() || m.is_immutable()) {
126 constants.push((s, v, &member.docs));
127 } else {
128 state_vars.push((s, v, &member.docs));
129 }
130 }
131 ItemKind::Function(f) => functions.push((s, f, &member.docs)),
132 ItemKind::Event(e) => events.push((s, e, &member.docs)),
133 ItemKind::Error(e) => errors.push((s, e, &member.docs)),
134 ItemKind::Struct(st) => structs.push((s, st, &member.docs)),
135 ItemKind::Enum(e) => enums.push((s, e, &member.docs)),
136 ItemKind::Udvt(u) => udvts.push((s, u, &member.docs)),
137 _ => {}
138 }
139 }
140
141 let write_vars =
142 |out: &mut String, vars: &[(Span, &VariableDefinition<'_>, &DocComments<'_>)]| {
143 for (span, v, docs) in vars {
144 let vname = v.name.map(|n| n.as_str().to_string()).unwrap_or_default();
145 writeln!(out, "### {vname}").unwrap();
146 writeln!(out).unwrap();
147 let mut c = collect_comments(docs, name_to_page, page_path, local);
148 let vid = hir_id.and_then(|cid| {
151 gcx.hir.contract(cid).items.iter().find_map(|&item| match item {
152 hir::ItemId::Variable(id) if gcx.hir.variable(id).span == *span => Some(id),
153 _ => None,
154 })
155 });
156 let inherited = vid.and_then(|id| {
157 let explicit = inheritdoc_base(docs).is_some();
158 (explicit || !has_local_natspec(docs))
159 .then(|| hir_ext::natspec_doc(gcx, id.into(), !explicit))
160 .flatten()
161 });
162 let sanitize =
163 |s: &str| hir_ext::replace_inline_links(s, name_to_page, page_path, local);
164 let sanitize_description =
165 |s: &str| replace_description_links(s, name_to_page, page_path, local);
166 if let Some(base_doc) = &inherited {
167 c.inherit_descriptions(base_doc, &sanitize_description);
168 }
169 write_comment_block(out, &c);
170 write_code_block(out, &ctx.dedented_snippet(*span));
171 let getter_doc = inherited.as_ref().filter(|d| !d.getter_returns.is_empty());
175 if let Some(base_doc) = getter_doc {
176 write_getter_table(out, "Parameters", &base_doc.getter_params, &sanitize);
177 write_getter_table(out, "Returns", &base_doc.getter_returns, &sanitize);
178 } else if !c.returns.is_empty() {
179 let ty = format!("`{}`", ctx.snippet(v.ty.span).trim());
180 writeln!(out, "**Returns**").unwrap();
181 writeln!(out).unwrap();
182 writeln!(out, "| Name | Type | Description |").unwrap();
183 writeln!(out, "| ---- | ---- | ----------- |").unwrap();
184 for (name, desc) in &c.returns {
185 let full_desc =
189 if desc.is_empty() { name.clone() } else { format!("{name} {desc}") };
190 let desc_cell = escape_table_cell(&full_desc);
191 writeln!(out, "| <none> | {ty} | {desc_cell} |").unwrap();
192 }
193 writeln!(out).unwrap();
194 }
195 }
196 };
197
198 if !constants.is_empty() {
199 writeln!(out, "## Constants").unwrap();
200 writeln!(out).unwrap();
201 write_vars(&mut out, &constants);
202 }
203
204 if !state_vars.is_empty() {
205 writeln!(out, "## State Variables").unwrap();
206 writeln!(out).unwrap();
207 write_vars(&mut out, &state_vars);
208 }
209
210 if !functions.is_empty() {
211 writeln!(out, "## Functions").unwrap();
212 writeln!(out).unwrap();
213 for (span, f, docs) in &functions {
214 let fn_name = match f.kind {
215 FunctionKind::Constructor => None,
216 FunctionKind::Fallback => Some("fallback".to_string()),
217 FunctionKind::Receive => Some("receive".to_string()),
218 FunctionKind::Function | FunctionKind::Modifier => {
219 f.header.name.map(|name| name.as_str().to_string())
220 }
221 };
222 let inherited = fn_name.as_deref().and_then(|fname| {
225 let fid = hir_id.and_then(|cid| {
226 gcx.hir.contract(cid).items.iter().find_map(|&item| match item {
227 hir::ItemId::Function(id) if gcx.hir.function(id).span == *span => Some(id),
228 _ => None,
229 })
230 });
231 match inheritdoc_base(docs) {
232 Some(_) => {
233 if hir_id.is_some() && fid.is_none() {
234 let _ = sh_warn!(
235 "forge doc: failed to find HIR function for `{}.{fname}` while resolving @inheritdoc",
236 c.name
237 );
238 }
239 fid.and_then(|id| hir_ext::natspec_doc(gcx, id.into(), false))
240 }
241 None if !has_local_natspec(docs) =>
242 fid.and_then(|id| hir_ext::natspec_doc(gcx, id.into(), true)),
243 None => None,
244 }
245 });
246 render_function_section(
247 &mut out,
248 *span,
249 f,
250 docs,
251 ctx,
252 name_to_page,
253 page_path,
254 local,
255 inherited.as_ref(),
256 );
257 }
258 }
259
260 if !events.is_empty() {
261 writeln!(out, "## Events").unwrap();
262 writeln!(out).unwrap();
263 for (span, e, docs) in &events {
264 writeln!(out, "### {}", e.name.as_str()).unwrap();
265 writeln!(out).unwrap();
266 let c = collect_comments(docs, name_to_page, page_path, local);
267 write_comment_block(&mut out, &c);
268 write_code_block(&mut out, &ctx.dedented_snippet(*span));
269 write_param_table(&mut out, "Parameters", &e.parameters, &c, None, ctx);
270 }
271 }
272
273 if !errors.is_empty() {
274 writeln!(out, "## Errors").unwrap();
275 writeln!(out).unwrap();
276 for (span, e, docs) in &errors {
277 writeln!(out, "### {}", e.name.as_str()).unwrap();
278 writeln!(out).unwrap();
279 let c = collect_comments(docs, name_to_page, page_path, local);
280 write_comment_block(&mut out, &c);
281 write_code_block(&mut out, &ctx.dedented_snippet(*span));
282 write_param_table(&mut out, "Parameters", &e.parameters, &c, None, ctx);
283 }
284 }
285
286 if !structs.is_empty() {
287 writeln!(out, "## Structs").unwrap();
288 writeln!(out).unwrap();
289 for (span, s, docs) in &structs {
290 writeln!(out, "### {}", s.name.as_str()).unwrap();
291 writeln!(out).unwrap();
292 let c = collect_comments(docs, name_to_page, page_path, local);
293 write_comment_block(&mut out, &c);
294 write_code_block(&mut out, &ctx.dedented_snippet(*span));
295 write_struct_properties_table(&mut out, s.fields, &c, ctx);
296 }
297 }
298
299 if !enums.is_empty() {
300 writeln!(out, "## Enums").unwrap();
301 writeln!(out).unwrap();
302 for (span, e, docs) in &enums {
303 writeln!(out, "### {}", e.name.as_str()).unwrap();
304 writeln!(out).unwrap();
305 let c = collect_comments(docs, name_to_page, page_path, local);
306 write_comment_block(&mut out, &c);
307 write_code_block(&mut out, &ctx.dedented_snippet(*span));
308 write_enum_variants_table(&mut out, e.variants, &c);
309 }
310 }
311
312 if !udvts.is_empty() {
313 writeln!(out, "## Custom Types").unwrap();
314 writeln!(out).unwrap();
315 for (span, u, docs) in &udvts {
316 writeln!(out, "### {}", u.name.as_str()).unwrap();
317 writeln!(out).unwrap();
318 let c = collect_comments(docs, name_to_page, page_path, local);
319 write_comment_block(&mut out, &c);
320 write_code_block(&mut out, &format!("{};", ctx.dedented_snippet(*span)));
321 }
322 }
323
324 out
325}
326
327fn render_free_functions(
330 name: &str,
331 overloads: &[(Span, &ItemFunction<'_>, &DocComments<'_>)],
332 ctx: &Ctx<'_>,
333 name_to_page: &NameToPage,
334 page_path: &Path,
335 git_url: Option<&str>,
336) -> String {
337 let title = if name.is_empty() { "function" } else { name };
338 let first_comments = collect_comments(overloads[0].2, name_to_page, page_path, None);
339 let mut out = write_page_header(title, first_notice(&first_comments).as_deref(), git_url);
340 for (span, f, docs) in overloads {
341 render_function_section(&mut out, *span, f, docs, ctx, name_to_page, page_path, None, None);
342 }
343 out
344}
345
346fn render_constants(
349 stem: &str,
350 vars: &[(Span, &VariableDefinition<'_>, &DocComments<'_>)],
351 ctx: &Ctx<'_>,
352 name_to_page: &NameToPage,
353 page_path: &Path,
354 git_url: Option<&str>,
355) -> String {
356 let title = format!("{stem} Constants");
357 let mut out = write_page_header(&title, None, git_url);
358 for (span, v, docs) in vars {
359 let name = v.name.map(|n| n.as_str().to_string()).unwrap_or_else(|| "_".to_string());
360 writeln!(out, "## {name}").unwrap();
361 writeln!(out).unwrap();
362 let c = collect_comments(docs, name_to_page, page_path, None);
363 write_comment_block(&mut out, &c);
364 write_code_block(&mut out, &ctx.dedented_snippet(*span));
365 }
366 out
367}
368
369fn render_struct<'ast>(
372 span: Span,
373 s: &'ast ItemStruct<'ast>,
374 docs: &'ast DocComments<'ast>,
375 ctx: &Ctx<'_>,
376 name_to_page: &NameToPage,
377 page_path: &Path,
378 git_url: Option<&str>,
379) -> String {
380 let name = s.name.as_str();
381 let c = collect_comments(docs, name_to_page, page_path, None);
382 let mut out = write_page_header(name, first_notice(&c).as_deref(), git_url);
383 write_comment_block(&mut out, &c);
384 write_code_block(&mut out, &ctx.dedented_snippet(span));
385 write_struct_properties_table(&mut out, s.fields, &c, ctx);
386 out
387}
388
389fn render_enum<'ast>(
390 span: Span,
391 e: &'ast ItemEnum<'ast>,
392 docs: &'ast DocComments<'ast>,
393 ctx: &Ctx<'_>,
394 name_to_page: &NameToPage,
395 page_path: &Path,
396 git_url: Option<&str>,
397) -> String {
398 let name = e.name.as_str();
399 let c = collect_comments(docs, name_to_page, page_path, None);
400 let mut out = write_page_header(name, first_notice(&c).as_deref(), git_url);
401 write_comment_block(&mut out, &c);
402 write_code_block(&mut out, &ctx.dedented_snippet(span));
403 write_enum_variants_table(&mut out, e.variants, &c);
404 out
405}
406
407fn render_udvt<'ast>(
408 span: Span,
409 u: &'ast ItemUdvt<'ast>,
410 docs: &'ast DocComments<'ast>,
411 ctx: &Ctx<'_>,
412 name_to_page: &NameToPage,
413 page_path: &Path,
414 git_url: Option<&str>,
415) -> String {
416 let name = u.name.as_str();
417 let c = collect_comments(docs, name_to_page, page_path, None);
418 let mut out = write_page_header(name, first_notice(&c).as_deref(), git_url);
419 write_comment_block(&mut out, &c);
420 write_code_block(&mut out, &format!("{};", ctx.dedented_snippet(span)));
421 out
422}
423
424fn render_error<'ast>(
425 span: Span,
426 e: &'ast ItemError<'ast>,
427 docs: &'ast DocComments<'ast>,
428 ctx: &Ctx<'_>,
429 name_to_page: &NameToPage,
430 page_path: &Path,
431 git_url: Option<&str>,
432) -> String {
433 let name = e.name.as_str();
434 let c = collect_comments(docs, name_to_page, page_path, None);
435 let mut out = write_page_header(name, first_notice(&c).as_deref(), git_url);
436 write_comment_block(&mut out, &c);
437 write_code_block(&mut out, &ctx.dedented_snippet(span));
438 write_param_table(&mut out, "Parameters", &e.parameters, &c, None, ctx);
439 out
440}
441
442fn render_event<'ast>(
443 span: Span,
444 e: &'ast ItemEvent<'ast>,
445 docs: &'ast DocComments<'ast>,
446 ctx: &Ctx<'_>,
447 name_to_page: &NameToPage,
448 page_path: &Path,
449 git_url: Option<&str>,
450) -> String {
451 let name = e.name.as_str();
452 let c = collect_comments(docs, name_to_page, page_path, None);
453 let mut out = write_page_header(name, first_notice(&c).as_deref(), git_url);
454 write_comment_block(&mut out, &c);
455 write_code_block(&mut out, &ctx.dedented_snippet(span));
456 write_param_table(&mut out, "Parameters", &e.parameters, &c, None, ctx);
457 out
458}
459
460#[allow(clippy::too_many_arguments)]
462fn render_function_section(
463 out: &mut String,
464 span: Span,
465 f: &ItemFunction<'_>,
466 docs: &DocComments<'_>,
467 ctx: &Ctx<'_>,
468 name_to_page: &NameToPage,
469 page_path: &Path,
470 local: Option<&hir_ext::LocalMembers>,
471 inherited: Option<&hir_ext::NatSpecDoc>,
472) {
473 let heading = function_heading(f);
474 if let Some(anchor) = function_signature_anchor(f, ctx) {
475 writeln!(out, "<a id=\"{anchor}\"></a>").unwrap();
476 writeln!(out).unwrap();
477 }
478 writeln!(out, "### {heading}").unwrap();
479 writeln!(out).unwrap();
480 let mut c = collect_comments(docs, name_to_page, page_path, local);
481 let mut inherited_params = None;
482 if let Some(inherited) = inherited {
484 let sanitize = |s: &str| hir_ext::replace_inline_links(s, name_to_page, page_path, local);
485 let sanitize_description =
486 |s: &str| replace_description_links(s, name_to_page, page_path, local);
487 c.inherit_descriptions(inherited, &sanitize_description);
488 if c.params.is_empty() {
489 let params = inherited.params.iter().map(|desc| sanitize(desc)).collect::<Vec<_>>();
490 for (index, desc) in params.iter().enumerate() {
491 if let Some(name) = f.header.parameters.get(index).and_then(|param| param.name) {
492 c.params.push((name.as_str().to_string(), desc.clone()));
493 }
494 }
495 inherited_params = Some(params);
496 }
497 if c.returns.is_empty() {
498 for (index, desc) in inherited.returns.iter().enumerate() {
499 let name = f
500 .header
501 .returns
502 .as_ref()
503 .and_then(|returns| returns.get(index))
504 .and_then(|return_| return_.name)
505 .map(|name| name.as_str().to_string())
506 .unwrap_or_default();
507 c.returns.push((name, sanitize(desc)));
508 }
509 }
510 }
511 write_comment_block(out, &c);
512 let hspan = if f.header.span.lo() == f.header.span.hi() { span } else { f.header.span };
513 let snippet = ctx.dedented_snippet(hspan);
514 write_code_block(out, &format!("{snippet};"));
515 write_param_table(
516 out,
517 "Parameters",
518 &f.header.parameters,
519 &c,
520 inherited_params.as_deref(),
521 ctx,
522 );
523 if let Some(returns) = &f.header.returns {
524 write_param_table(out, "Returns", returns, &c, None, ctx);
525 }
526}
527
528fn function_heading(f: &ItemFunction<'_>) -> String {
529 match f.kind {
530 FunctionKind::Constructor => "constructor".to_string(),
531 FunctionKind::Fallback => "fallback".to_string(),
532 FunctionKind::Receive => "receive".to_string(),
533 FunctionKind::Function | FunctionKind::Modifier => {
534 f.header.name.map(|n| n.as_str().to_string()).unwrap_or_else(|| "function".to_string())
535 }
536 }
537}
538
539fn function_signature_anchor(f: &ItemFunction<'_>, ctx: &Ctx<'_>) -> Option<String> {
540 let name = match f.kind {
541 FunctionKind::Constructor => "constructor".to_string(),
542 FunctionKind::Fallback => "fallback".to_string(),
543 FunctionKind::Receive => "receive".to_string(),
544 FunctionKind::Function | FunctionKind::Modifier => f.header.name?.as_str().to_string(),
545 };
546 let params = f
547 .header
548 .parameters
549 .vars
550 .iter()
551 .map(|v| ctx.snippet(v.ty.span).trim().to_string())
552 .collect::<Vec<_>>();
553
554 Some(hir_ext::function_signature_anchor(&name, ¶ms))
555}
556
557#[derive(Clone, Copy, PartialEq, Eq)]
560enum DescKind {
561 Notice,
562 Dev,
563}
564
565struct Description {
566 kind: DescKind,
567 content: String,
568}
569
570struct CommentData {
571 titles: Vec<String>,
572 authors: Vec<String>,
573 notices: Vec<String>,
575 devs: Vec<String>,
577 descriptions: Vec<Description>,
581 params: Vec<(String, String)>,
582 returns: Vec<(String, String)>,
583 customs: Vec<(String, String)>,
584 unnamed_param_names: Vec<String>,
586}
587
588impl CommentData {
589 fn inherit_descriptions(
591 &mut self,
592 inherited: &hir_ext::NatSpecDoc,
593 sanitize: &impl Fn(&str) -> String,
594 ) {
595 if self.notices.is_empty() {
596 self.notices = inherited.notices.iter().map(|s| sanitize(s)).collect();
597 let mut descriptions = self
598 .notices
599 .iter()
600 .map(|s| Description { kind: DescKind::Notice, content: s.clone() })
601 .collect::<Vec<_>>();
602 descriptions.append(&mut self.descriptions);
603 self.descriptions = descriptions;
604 }
605 if self.devs.is_empty() {
606 self.devs = inherited.devs.iter().map(|s| sanitize(s)).collect();
607 self.descriptions.extend(
608 self.devs.iter().map(|s| Description { kind: DescKind::Dev, content: s.clone() }),
609 );
610 }
611 }
612}
613
614fn collect_comments(
620 docs: &DocComments<'_>,
621 name_to_page: &NameToPage,
622 page_path: &Path,
623 local: Option<&hir_ext::LocalMembers>,
624) -> CommentData {
625 let mut data = CommentData {
626 titles: Vec::new(),
627 authors: Vec::new(),
628 notices: Vec::new(),
629 devs: Vec::new(),
630 descriptions: Vec::new(),
631 params: Vec::new(),
632 returns: Vec::new(),
633 customs: Vec::new(),
634 unnamed_param_names: Vec::new(),
635 };
636
637 const FILTERED_CUSTOM: &[&str] = &["solidity", "src", "use-src", "ast-id"];
639 const KNOWN_CUSTOM: &[&str] = &["name"];
641
642 let mut prev_doc_was_blank = false;
645 #[derive(Clone, Copy)]
646 enum LastSection {
647 Desc, Param,
649 Return,
650 }
651 let mut last_section: Option<LastSection> = None;
652 for doc in docs.iter() {
653 if doc.natspec.is_empty() {
654 prev_doc_was_blank = true;
655 continue;
656 }
657
658 for item in doc.natspec.iter() {
659 let raw = doc.natspec_content(item);
660 let raw: &str =
663 if doc.kind == CommentKind::Block { &clean_block_doc_content(raw) } else { raw };
664
665 let is_continuation = matches!(item.kind, NatSpecKind::Notice) && item.span == doc.span;
669
670 let trimmed = raw.trim();
671 if trimmed.is_empty() {
672 prev_doc_was_blank = true;
673 continue;
674 }
675
676 let content = trimmed.to_string();
679
680 if is_continuation && !prev_doc_was_blank {
681 let appended = match last_section {
682 Some(LastSection::Desc) => data.descriptions.last_mut().map(|d| &mut d.content),
683 Some(LastSection::Param) => data.params.last_mut().map(|(_, d)| d),
684 Some(LastSection::Return) => data.returns.last_mut().map(|(_, d)| d),
685 None => None,
686 };
687 if let Some(last) = appended {
688 last.push('\n');
689 last.push_str(&content);
690 prev_doc_was_blank = false;
691 continue;
692 }
693 }
694
695 prev_doc_was_blank = false;
696
697 match item.kind {
698 NatSpecKind::Title => data.titles.push(content),
699 NatSpecKind::Author => data.authors.push(content),
700 NatSpecKind::Notice => {
701 data.notices.push(content.clone());
702 data.descriptions.push(Description { kind: DescKind::Notice, content });
703 last_section = Some(LastSection::Desc);
704 }
705 NatSpecKind::Dev => {
706 data.devs.push(content.clone());
707 data.descriptions.push(Description { kind: DescKind::Dev, content });
708 last_section = Some(LastSection::Desc);
709 }
710 NatSpecKind::Param { name } => {
711 data.params.push((name.as_str().to_string(), content));
712 last_section = Some(LastSection::Param);
713 }
714 NatSpecKind::Return { name } => {
715 data.returns.push((
716 name.map(|name| name.as_str().to_string()).unwrap_or_default(),
717 content,
718 ));
719 last_section = Some(LastSection::Return);
720 }
721 NatSpecKind::Inheritdoc { .. } => {} NatSpecKind::Custom { name } => {
723 let tag = name.as_str();
724 if FILTERED_CUSTOM.contains(&tag) {
725 } else if tag == "name" {
727 let content =
729 hir_ext::replace_inline_links(&content, name_to_page, page_path, local);
730 if let Some(first) = content.split_whitespace().next() {
731 data.unnamed_param_names.push(first.to_string());
732 }
733 } else {
734 if !KNOWN_CUSTOM.contains(&tag) && !is_known_custom_tag(tag) {
736 warn!("unknown natspec custom tag: @custom:{tag}");
737 }
738 data.customs.push((tag.to_string(), content));
739 }
740 }
741 NatSpecKind::Internal { .. } => {}
742 }
743 }
744 }
745
746 let sanitize = |s: &str| hir_ext::replace_inline_links(s, name_to_page, page_path, local);
747 for content in &mut data.titles {
748 *content = sanitize_description_prose(content, name_to_page, page_path, local);
749 }
750 for content in &mut data.authors {
751 *content = sanitize_description_prose(content, name_to_page, page_path, local);
752 }
753 for (_, content) in &mut data.params {
754 *content = sanitize(content);
755 }
756 for (_, content) in &mut data.returns {
757 *content = sanitize(content);
758 }
759 for (_, content) in &mut data.customs {
760 *content = sanitize_description_prose(content, name_to_page, page_path, local);
761 }
762 for description in &mut data.descriptions {
763 description.content =
764 replace_description_links(&description.content, name_to_page, page_path, local);
765 }
766
767 data
768}
769
770fn is_known_custom_tag(tag: &str) -> bool {
775 !tag.is_empty() && tag.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
776}
777
778fn has_local_natspec(docs: &DocComments<'_>) -> bool {
784 docs.iter().any(|doc| {
785 doc.natspec.iter().any(|item| !matches!(item.kind, NatSpecKind::Inheritdoc { .. }))
786 })
787}
788
789fn write_getter_table(
791 out: &mut String,
792 heading: &str,
793 fields: &[hir_ext::GetterField],
794 sanitize: &impl Fn(&str) -> String,
795) {
796 if fields.is_empty() {
797 return;
798 }
799 writeln!(out, "**{heading}**").unwrap();
800 writeln!(out).unwrap();
801 writeln!(out, "| Name | Type | Description |").unwrap();
802 writeln!(out, "| ---- | ---- | ----------- |").unwrap();
803 for field in fields {
804 let name = field
805 .name
806 .as_deref()
807 .map(escape_table_cell)
808 .unwrap_or_else(|| "<none>".to_string());
809 let ty = escape_table_cell(&field.ty);
810 let desc = escape_table_cell(&sanitize(&field.description));
811 writeln!(out, "| {name} | `{ty}` | {desc} |").unwrap();
812 }
813 writeln!(out).unwrap();
814}
815
816fn inheritdoc_base(docs: &DocComments<'_>) -> Option<String> {
817 for doc in docs.iter() {
818 for item in doc.natspec.iter() {
819 if let NatSpecKind::Inheritdoc { contract } = item.kind {
820 return Some(contract.as_str().to_string());
821 }
822 }
823 }
824 None
825}
826
827fn first_notice(data: &CommentData) -> Option<String> {
828 data.descriptions
829 .iter()
830 .find(|description| description.kind == DescKind::Notice)
831 .map(|description| description.content.clone())
832}
833
834fn write_frontmatter(out: &mut String, title: &str, description: Option<&str>) {
837 writeln!(out, "---").unwrap();
838 writeln!(out, "title: \"{}\"", yaml_escape_double_quoted(title)).unwrap();
839 if let Some(desc) = description {
840 let collapsed: String = desc.split_whitespace().collect::<Vec<_>>().join(" ");
842 writeln!(out, "description: \"{}\"", yaml_escape_double_quoted(&collapsed)).unwrap();
843 }
844 writeln!(out, "---").unwrap();
845 writeln!(out).unwrap();
846}
847
848fn yaml_escape_double_quoted(s: &str) -> String {
854 let mut out = String::with_capacity(s.len());
855 for c in s.chars() {
856 match c {
857 '\\' => out.push_str("\\\\"),
858 '"' => out.push_str("\\\""),
859 '\n' => out.push_str("\\n"),
860 '\r' => out.push_str("\\r"),
861 '\t' => out.push_str("\\t"),
862 '\u{0}' => out.push_str("\\0"),
863 c if (c as u32) < 0x20 => {
864 out.push_str(&format!("\\x{:02x}", c as u32));
865 }
866 c => out.push(c),
867 }
868 }
869 out
870}
871
872fn italicize_dev(content: &str) -> String {
876 let trimmed = content.trim_matches('\n');
877 if trimmed.is_empty() { String::new() } else { format!("<i>\n\n{trimmed}\n\n</i>") }
878}
879
880pub(crate) fn code_regions(text: &str, options: &ParseOptions) -> Vec<Range<usize>> {
885 let Ok(tree) = to_mdast(text, options) else { return Vec::new() };
886 let mut regions = Vec::new();
887 collect_code_regions(&tree, &mut regions);
888 regions
889}
890
891fn collect_code_regions(node: &Node, regions: &mut Vec<Range<usize>>) {
893 if matches!(node, Node::Code(_) | Node::InlineCode(_))
894 && let Some(position) = node.position()
895 {
896 regions.push(position.start.offset..position.end.offset);
897 }
898 if let Some(children) = node.children() {
899 for child in children {
900 collect_code_regions(child, regions);
901 }
902 }
903}
904
905fn replace_description_links(
909 text: &str,
910 name_to_page: &NameToPage,
911 current_page: &Path,
912 local: Option<&hir_ext::LocalMembers>,
913) -> String {
914 let regions = fenced_description_regions(text);
915 let mut out = String::with_capacity(text.len());
916 let mut rendered_regions = Vec::with_capacity(regions.len());
917 let mut copied = 0;
918 for region in regions {
919 out.push_str(&sanitize_description_prose(
920 &text[copied..region.start],
921 name_to_page,
922 current_page,
923 local,
924 ));
925 let start = out.len();
926 out.push_str(&text[region.clone()]);
927 rendered_regions.push(start..out.len());
928 copied = region.end;
929 }
930 out.push_str(&sanitize_description_prose(&text[copied..], name_to_page, current_page, local));
931 let mdx_regions = code_regions(&out, &ParseOptions::mdx());
932 if rendered_regions.iter().all(|region| mdx_regions.contains(region)) {
933 out
934 } else {
935 sanitize_description_prose(text, name_to_page, current_page, local)
936 }
937}
938
939fn sanitize_description_prose(
940 text: &str,
941 name_to_page: &NameToPage,
942 current_page: &Path,
943 local: Option<&hir_ext::LocalMembers>,
944) -> String {
945 let text = hir_ext::replace_inline_links(text, name_to_page, current_page, local);
946 neutralize_fence_markers(&text)
947}
948
949fn neutralize_fence_markers(text: &str) -> String {
952 let mut out = String::with_capacity(text.len());
953 let bytes = text.as_bytes();
954 let mut i = 0;
955 while i < bytes.len() {
956 if let marker @ (b'`' | b'~') = bytes[i] {
957 let length = bytes[i..].iter().take_while(|&&byte| byte == marker).count();
958 if length >= 3 {
959 out.push_str(if marker == b'`' { "`" } else { "~" });
960 out.push_str(&text[i + 1..i + length]);
961 i += length;
962 continue;
963 }
964 }
965 let ch = text[i..].chars().next().unwrap();
966 out.push(ch);
967 i += ch.len_utf8();
968 }
969 out
970}
971
972fn fenced_description_regions(text: &str) -> Vec<Range<usize>> {
976 let Ok(Node::Root(root)) = to_mdast(text, &ParseOptions::gfm()) else {
977 return Vec::new();
978 };
979 root.children
980 .iter()
981 .filter_map(|node| {
982 let Node::Code(_) = node else { return None };
983 let position = node.position()?;
984 let range = position.start.offset..position.end.offset;
985 is_complete_fence(&text[range.clone()]).then_some(range)
986 })
987 .collect()
988}
989
990fn is_complete_fence(text: &str) -> bool {
991 let mut lines = logical_lines(text);
992 let Some((_, first)) = lines.next() else { return false };
993 let Some((marker, length)) = fence_marker(first) else { return false };
994 let mut last = None;
995 for (_, line) in lines {
996 last = Some(line);
997 }
998 let Some(last) = last else { return false };
999 let indent = last.len() - last.trim_start_matches(' ').len();
1000 if indent > 3 {
1001 return false;
1002 }
1003 let last = &last[indent..];
1004 let closing_length = last.chars().take_while(|&ch| ch == marker).count();
1005 closing_length >= length && last[closing_length..].trim().is_empty()
1006}
1007
1008fn fence_marker(line: &str) -> Option<(char, usize)> {
1009 let indent = line.len() - line.trim_start_matches(' ').len();
1010 if indent > 3 {
1011 return None;
1012 }
1013 let line = &line[indent..];
1014 let marker @ ('`' | '~') = line.chars().next()? else { return None };
1015 let length = line.chars().take_while(|&ch| ch == marker).count();
1016 (length >= 3).then_some((marker, length))
1017}
1018
1019fn logical_lines(text: &str) -> impl Iterator<Item = (usize, &str)> {
1023 let bytes = text.as_bytes();
1024 let mut offset = 0;
1025 std::iter::from_fn(move || {
1026 if offset >= bytes.len() {
1027 return None;
1028 }
1029 let start = offset;
1030 let end = bytes[start..]
1031 .iter()
1032 .position(|&byte| byte == b'\n' || byte == b'\r')
1033 .map_or(bytes.len(), |position| start + position);
1034 offset = if end == bytes.len() {
1035 end
1036 } else if bytes[end] == b'\r' && bytes.get(end + 1) == Some(&b'\n') {
1037 end + 2
1038 } else {
1039 end + 1
1040 };
1041 Some((start, &text[start..end]))
1042 })
1043}
1044
1045pub(crate) fn region_contains(
1047 regions: &[Range<usize>],
1048 cursor: &mut usize,
1049 position: usize,
1050) -> bool {
1051 while regions.get(*cursor).is_some_and(|region| region.end <= position) {
1052 *cursor += 1;
1053 }
1054 regions.get(*cursor).is_some_and(|region| region.start <= position)
1055}
1056
1057fn neutralize_esm(text: &str) -> String {
1064 let regions = code_regions(text, &ParseOptions::mdx());
1065 let mut region_cursor = 0;
1066 let mut copied = 0;
1067 let mut out = String::with_capacity(text.len());
1068
1069 for (line_start, line) in logical_lines(text) {
1070 let replacement = if line.starts_with("import ") {
1071 Some(("im", 2))
1072 } else if line.starts_with("export ") {
1073 Some(("e", 1))
1074 } else {
1075 None
1076 };
1077 let Some((entity, prefix_len)) = replacement else { continue };
1078 if region_contains(®ions, &mut region_cursor, line_start) {
1079 continue;
1080 }
1081 out.push_str(&text[copied..line_start]);
1082 out.push_str(entity);
1083 copied = line_start + prefix_len;
1084 }
1085
1086 out.push_str(&text[copied..]);
1087 out
1088}
1089
1090fn write_comment_block(out: &mut String, data: &CommentData) {
1091 let mut block = String::new();
1092 if !data.titles.is_empty() {
1093 let label = if data.titles.len() == 1 { "Title" } else { "Titles" };
1094 writeln!(block, "**{label}:** {}", data.titles.join(", ")).unwrap();
1095 writeln!(block).unwrap();
1096 }
1097 if !data.authors.is_empty() {
1098 let label = if data.authors.len() == 1 { "Author" } else { "Authors" };
1099 writeln!(block, "**{label}:** {}", data.authors.join(", ")).unwrap();
1100 writeln!(block).unwrap();
1101 }
1102 for desc in &data.descriptions {
1106 match desc.kind {
1107 DescKind::Notice => writeln!(block, "{}", desc.content).unwrap(),
1108 DescKind::Dev => writeln!(block, "{}", italicize_dev(&desc.content)).unwrap(),
1109 }
1110 writeln!(block).unwrap();
1111 }
1112 if !data.customs.is_empty() {
1113 let label = if data.customs.len() == 1 { "Note" } else { "Notes" };
1114 writeln!(block, "**{label}:**").unwrap();
1115 writeln!(block).unwrap();
1116 for (tag, content) in &data.customs {
1117 writeln!(block, "- **{tag}:** {content}").unwrap();
1118 }
1119 writeln!(block).unwrap();
1120 }
1121 out.push_str(&neutralize_esm(&block));
1125}
1126
1127fn write_code_block(out: &mut String, snippet: &str) {
1128 writeln!(out, "```solidity").unwrap();
1129 writeln!(out, "{}", snippet.trim_end()).unwrap();
1130 writeln!(out, "```").unwrap();
1131 writeln!(out).unwrap();
1132}
1133
1134fn write_page_header(title: &str, description: Option<&str>, git_url: Option<&str>) -> String {
1135 let mut out = String::new();
1136 write_frontmatter(&mut out, title, description);
1137 writeln!(out, "# {title}").unwrap();
1138 writeln!(out).unwrap();
1139 write_git_source(&mut out, git_url);
1140 out
1141}
1142
1143fn write_git_source(out: &mut String, git_url: Option<&str>) {
1145 if let Some(url) = git_url {
1146 writeln!(out, "[Git Source]({url})").unwrap();
1147 writeln!(out).unwrap();
1148 }
1149}
1150
1151fn escape_table_cell(s: &str) -> String {
1156 s.replace('\\', "\\\\")
1157 .replace('|', "\\|")
1158 .replace("\r\n", "<br/>")
1159 .replace(['\n', '\r'], "<br/>")
1160}
1161
1162fn write_deployments_table(out: &mut String, deployments: &[Deployment]) {
1164 if deployments.is_empty() {
1165 return;
1166 }
1167 writeln!(out, "**Deployments**").unwrap();
1168 writeln!(out).unwrap();
1169 writeln!(out, "| Network | Address |").unwrap();
1170 writeln!(out, "| ------- | ------- |").unwrap();
1171 for d in deployments {
1172 let network = escape_table_cell(d.network.as_deref().unwrap_or("-"));
1173 writeln!(out, "| {network} | `{:#x}` |", d.address).unwrap();
1174 }
1175 writeln!(out).unwrap();
1176}
1177
1178fn write_param_table(
1179 out: &mut String,
1180 heading: &str,
1181 params: &ParameterList<'_>,
1182 comments: &CommentData,
1183 inherited_params: Option<&[String]>,
1184 ctx: &Ctx<'_>,
1185) {
1186 if params.is_empty() {
1187 return;
1188 }
1189 writeln!(out, "**{heading}**").unwrap();
1190 writeln!(out).unwrap();
1191 writeln!(out, "| Name | Type | Description |").unwrap();
1192 writeln!(out, "| ---- | ---- | ----------- |").unwrap();
1193 let is_return = heading == "Returns";
1194 let mut unnamed_iter = comments.unnamed_param_names.iter();
1197 for (index, var) in params.iter().enumerate() {
1198 let name = match var.name {
1199 Some(n) => n.as_str().to_string(),
1200 None if !is_return => unnamed_iter.next().cloned().unwrap_or_else(|| "_".to_string()),
1201 None => "<none>".to_string(),
1202 };
1203 let ty = format!("`{}`", ctx.snippet(var.ty.span).trim());
1204 let desc = if is_return {
1205 return_description(comments, index, var.name.map(|_| name.as_str()))
1206 } else {
1207 let named = comments.params.iter().find(|(n, _)| n == &name).map(|(_, d)| d.as_str());
1208 if var.name.is_none() {
1209 inherited_params
1210 .and_then(|params| params.get(index))
1211 .map(String::as_str)
1212 .or(named)
1213 .unwrap_or("")
1214 } else {
1215 named.unwrap_or("")
1216 }
1217 };
1218 let name = escape_table_cell(&name);
1219 let desc = escape_table_cell(desc);
1220 writeln!(out, "| {name} | {ty} | {desc} |").unwrap();
1221 }
1222 writeln!(out).unwrap();
1223}
1224
1225fn return_description<'a>(
1226 comments: &'a CommentData,
1227 index: usize,
1228 return_name: Option<&str>,
1229) -> &'a str {
1230 if let Some(return_name) = return_name
1231 && let Some((_, desc)) = comments.returns.iter().find(|(n, _)| n == return_name)
1232 {
1233 return desc;
1234 }
1235
1236 let Some((doc_name, desc)) = comments.returns.get(index) else {
1237 return "";
1238 };
1239
1240 if !doc_name.is_empty() {
1241 return desc;
1242 }
1243
1244 match return_name {
1245 Some(return_name) => desc.strip_prefix(return_name).and_then(strip_one_ws).unwrap_or(desc),
1246 None => desc,
1247 }
1248}
1249
1250fn strip_one_ws(s: &str) -> Option<&str> {
1251 let mut chars = s.char_indices();
1252 let (_, first) = chars.next()?;
1253 first.is_whitespace().then(|| chars.next().map(|(idx, _)| &s[idx..]).unwrap_or(""))
1254}
1255
1256fn write_struct_properties_table(
1257 out: &mut String,
1258 fields: &[VariableDefinition<'_>],
1259 comments: &CommentData,
1260 ctx: &Ctx<'_>,
1261) {
1262 if fields.is_empty() {
1263 return;
1264 }
1265 writeln!(out, "**Properties**").unwrap();
1266 writeln!(out).unwrap();
1267 writeln!(out, "| Name | Type | Description |").unwrap();
1268 writeln!(out, "| ---- | ---- | ----------- |").unwrap();
1269 for field in fields {
1270 let name = field.name.map(|n| n.as_str().to_string()).unwrap_or_else(|| "_".to_string());
1271 let ty = format!("`{}`", ctx.snippet(field.ty.span).trim());
1272 let desc =
1273 comments.params.iter().find(|(n, _)| n == &name).map(|(_, d)| d.as_str()).unwrap_or("");
1274 let name = escape_table_cell(&name);
1275 let desc = escape_table_cell(desc);
1276 writeln!(out, "| {name} | {ty} | {desc} |").unwrap();
1277 }
1278 writeln!(out).unwrap();
1279}
1280
1281fn write_enum_variants_table(out: &mut String, variants: &[Ident], comments: &CommentData) {
1282 if variants.is_empty() {
1283 return;
1284 }
1285 writeln!(out, "**Variants**").unwrap();
1286 writeln!(out).unwrap();
1287 writeln!(out, "| Name | Description |").unwrap();
1288 writeln!(out, "| ---- | ----------- |").unwrap();
1289 for variant in variants {
1290 let name = variant.as_str();
1291 let desc =
1292 comments.params.iter().find(|(n, _)| n == name).map(|(_, d)| d.as_str()).unwrap_or("");
1293 let name = escape_table_cell(name);
1294 let desc = escape_table_cell(desc);
1295 writeln!(out, "| {name} | {desc} |").unwrap();
1296 }
1297 writeln!(out).unwrap();
1298}
1299
1300const fn contract_kind_str(kind: ContractKind) -> &'static str {
1301 match kind {
1302 ContractKind::Contract => "contract",
1303 ContractKind::AbstractContract => "abstract",
1304 ContractKind::Interface => "interface",
1305 ContractKind::Library => "library",
1306 }
1307}
1308
1309fn find_contract_id<'gcx>(
1313 gcx: Gcx<'gcx>,
1314 name: &str,
1315 abs_sol_path: &Path,
1316) -> Option<hir::ContractId> {
1317 gcx.hir.contract_ids().find(|&id| {
1318 let c = gcx.hir.contract(id);
1319 if c.name.as_str() != name {
1320 return false;
1321 }
1322 match &gcx.hir.source(c.source).file.name {
1323 FileName::Real(p) => p == abs_sol_path,
1324 _ => false,
1325 }
1326 })
1327}
1328
1329fn dedent(s: &str) -> String {
1331 let lines: Vec<&str> = s.lines().collect();
1332 if lines.is_empty() {
1333 return s.to_string();
1334 }
1335 let indent = lines
1336 .iter()
1337 .filter(|l| !l.trim().is_empty())
1338 .map(|l| l.len() - l.trim_start().len())
1339 .min()
1340 .unwrap_or(0);
1341 lines
1342 .iter()
1343 .map(|l| if l.len() >= indent { &l[indent..] } else { l.trim() })
1344 .collect::<Vec<_>>()
1345 .join("\n")
1346}
1347
1348#[allow(clippy::too_many_arguments)]
1352pub fn source<'ast, 'gcx>(
1353 ast: &'ast SourceUnit<'ast>,
1354 file: &Arc<SourceFile>,
1355 _sm: &SourceMap,
1356 rel_sol_path: &Path,
1357 abs_sol_path: &Path,
1358 _root: &Path,
1359 gcx: Gcx<'gcx>,
1360 name_to_page: &NameToPage,
1361 git_url: Option<&str>,
1362 deployments: &HashMap<String, Vec<Deployment>>,
1363) -> Vec<(PathBuf, String)> {
1364 let out_dir = rel_sol_path.parent().unwrap_or(Path::new(""));
1365 let stem = rel_sol_path.file_stem().and_then(|s| s.to_str()).unwrap_or("constants");
1366
1367 let src_text = file.src.as_str();
1368 let src_start = file.start_pos.to_usize();
1369 let ctx = Ctx { src_text, src_start };
1370
1371 let mut pages: Vec<(PathBuf, String)> = Vec::new();
1372 let mut const_vars: Vec<(Span, &VariableDefinition<'_>, &DocComments<'_>)> = Vec::new();
1373 let mut free_fns: std::collections::BTreeMap<
1374 String,
1375 Vec<(Span, &ItemFunction<'_>, &DocComments<'_>)>,
1376 > = Default::default();
1377
1378 for item in ast.items.iter() {
1379 let span = item.span;
1380 match &item.kind {
1381 ItemKind::Pragma(_) | ItemKind::Import(_) | ItemKind::Using(_) => (),
1382 ItemKind::Contract(c) => {
1383 let kind_str = contract_kind_str(c.kind);
1384 let fname = format!("{kind_str}.{}.mdx", c.name.as_str());
1385 let page_path = out_dir.join(&fname);
1386 let hir_id = find_contract_id(gcx, c.name.as_str(), abs_sol_path);
1388 let contract_deployments = if matches!(c.kind, ContractKind::Contract) {
1390 deployments.get(c.name.as_str()).map(Vec::as_slice).unwrap_or(&[])
1391 } else {
1392 &[]
1393 };
1394 let content = render_contract(
1395 span,
1396 c,
1397 &item.docs,
1398 &ctx,
1399 gcx,
1400 hir_id,
1401 name_to_page,
1402 &page_path,
1403 git_url,
1404 contract_deployments,
1405 );
1406 pages.push((page_path, content));
1407 }
1408
1409 ItemKind::Function(f) => {
1410 let name = f.header.name.map(|n| n.as_str().to_string()).unwrap_or_default();
1411 free_fns.entry(name).or_default().push((span, f, &item.docs));
1412 }
1413
1414 ItemKind::Variable(v) => {
1415 const_vars.push((span, v, &item.docs));
1416 }
1417
1418 ItemKind::Struct(s) => {
1419 let fname = format!("struct.{}.mdx", s.name.as_str());
1420 let page_path = out_dir.join(&fname);
1421 pages.push((
1422 page_path.clone(),
1423 render_struct(span, s, &item.docs, &ctx, name_to_page, &page_path, git_url),
1424 ));
1425 }
1426
1427 ItemKind::Enum(e) => {
1428 let fname = format!("enum.{}.mdx", e.name.as_str());
1429 let page_path = out_dir.join(&fname);
1430 pages.push((
1431 page_path.clone(),
1432 render_enum(span, e, &item.docs, &ctx, name_to_page, &page_path, git_url),
1433 ));
1434 }
1435
1436 ItemKind::Udvt(u) => {
1437 let fname = format!("type.{}.mdx", u.name.as_str());
1438 let page_path = out_dir.join(&fname);
1439 pages.push((
1440 page_path.clone(),
1441 render_udvt(span, u, &item.docs, &ctx, name_to_page, &page_path, git_url),
1442 ));
1443 }
1444
1445 ItemKind::Error(e) => {
1446 let fname = format!("error.{}.mdx", e.name.as_str());
1447 let page_path = out_dir.join(&fname);
1448 pages.push((
1449 page_path.clone(),
1450 render_error(span, e, &item.docs, &ctx, name_to_page, &page_path, git_url),
1451 ));
1452 }
1453
1454 ItemKind::Event(e) => {
1455 let fname = format!("event.{}.mdx", e.name.as_str());
1456 let page_path = out_dir.join(&fname);
1457 pages.push((
1458 page_path.clone(),
1459 render_event(span, e, &item.docs, &ctx, name_to_page, &page_path, git_url),
1460 ));
1461 }
1462 }
1463 }
1464
1465 for (name, overloads) in &free_fns {
1466 let fname = format!("function.{name}.mdx");
1467 let page_path = out_dir.join(&fname);
1468 let content =
1469 render_free_functions(name, overloads, &ctx, name_to_page, &page_path, git_url);
1470 pages.push((page_path, content));
1471 }
1472
1473 if !const_vars.is_empty() {
1474 let fname = format!("constants.{stem}.mdx");
1475 let page_path = out_dir.join(&fname);
1476 let content = render_constants(stem, &const_vars, &ctx, name_to_page, &page_path, git_url);
1477 pages.push((page_path, content));
1478 }
1479
1480 pages
1481}
1482
1483#[cfg(test)]
1484mod tests {
1485 use super::{neutralize_esm, replace_description_links, sanitize_description_prose};
1486 use crate::hir_ext::NameToPage;
1487 use markdown::{MdxSignal, ParseOptions, mdast::Node, to_mdast};
1488 use std::path::Path;
1489
1490 fn parse_mdx(text: &str) -> Node {
1491 let mut options = ParseOptions::mdx();
1492 options.mdx_esm_parse = Some(Box::new(|_| MdxSignal::Ok));
1493 to_mdast(text, &options).unwrap()
1494 }
1495
1496 fn contains_mdx_esm(node: &Node) -> bool {
1497 matches!(node, Node::MdxjsEsm(_))
1498 || node.children().is_some_and(|children| children.iter().any(contains_mdx_esm))
1499 }
1500
1501 fn contains_mdx_expression(node: &Node) -> bool {
1502 matches!(node, Node::MdxFlowExpression(_) | Node::MdxTextExpression(_))
1503 || node.children().is_some_and(|children| children.iter().any(contains_mdx_expression))
1504 }
1505
1506 #[test]
1507 fn preserves_complete_top_level_description_fences() {
1508 let input = "Before < and {\n~~~solidity\nif (a < b) { revert(); }\n~~~\nAfter < and {";
1509 let output = replace_description_links(
1510 input,
1511 &NameToPage::new(),
1512 Path::new("src/contract.Foo.mdx"),
1513 None,
1514 );
1515
1516 assert_eq!(
1517 output,
1518 "Before < and {\n~~~solidity\nif (a < b) { revert(); }\n~~~\nAfter < and {"
1519 );
1520 }
1521
1522 #[test]
1523 fn conservatively_escapes_non_standalone_fences() {
1524 for (input, expected) in [
1525 (
1526 "- ~~~\n example <\n ~~~\nOutside < and {",
1527 "- ~~~\n example <\n ~~~\nOutside < and {",
1528 ),
1529 ("~~~\nexample < and {", "~~~\nexample < and {"),
1530 ] {
1531 assert_eq!(
1532 replace_description_links(
1533 input,
1534 &NameToPage::new(),
1535 Path::new("src/contract.Foo.mdx"),
1536 None,
1537 ),
1538 expected
1539 );
1540 }
1541 }
1542
1543 #[test]
1544 fn rejected_fences_cannot_change_later_mdx_context() {
1545 let name_to_page = NameToPage::new();
1546 let path = Path::new("src/contract.Foo.mdx");
1547 let first = replace_description_links("~~~", &name_to_page, path, None);
1548 let second = replace_description_links("~~~\n{1+1}\n~~~", &name_to_page, path, None);
1549 let output = format!("{first}\n\n{second}");
1550 assert_eq!(output, "~~~\n\n~~~\n{1+1}\n~~~");
1551 assert!(!contains_mdx_expression(&parse_mdx(&output)));
1552
1553 let output =
1554 replace_description_links("~~~\n ~~~\n{1+1}\n~~~", &name_to_page, path, None);
1555 assert_eq!(output, "~~~\n ~~~\n`1+1`\n~~~");
1556 assert!(!contains_mdx_expression(&parse_mdx(&output)));
1557
1558 let notice = replace_description_links("~~~\n{1+1}\n~~~", &name_to_page, path, None);
1559 for prefix in ["**Title:**", "**Author:**", "- **note:**"] {
1560 let metadata = sanitize_description_prose("metadata\n~~~", &name_to_page, path, None);
1561 let output = format!("{prefix} {metadata}\n\n{notice}");
1562 assert!(!contains_mdx_expression(&parse_mdx(&output)), "{output}");
1563 }
1564 }
1565
1566 #[test]
1567 fn preserves_line_endings_while_neutralizing_esm() {
1568 assert_eq!(
1569 neutralize_esm("Intro.\r\rexport const afterCarriageReturn = 1"),
1570 "Intro.\r\rexport const afterCarriageReturn = 1"
1571 );
1572 for (input, expected) in [
1573 (
1574 "```\rimport inside\r```\rexport outside",
1575 "```\rimport inside\r```\rexport outside",
1576 ),
1577 (
1578 "```\r\nimport inside\r\n```\r\nexport outside",
1579 "```\r\nimport inside\r\n```\r\nexport outside",
1580 ),
1581 (
1582 "`example:\rimport inside`\rexport outside",
1583 "`example:\rimport inside`\rexport outside",
1584 ),
1585 (
1586 "`example:\r\nimport inside`\r\nexport outside",
1587 "`example:\r\nimport inside`\r\nexport outside",
1588 ),
1589 (
1590 " ```\r import inside\r ```\rexport outside",
1591 " ```\r import inside\r ```\rexport outside",
1592 ),
1593 ("```\rinside\r ```\rexport outside", "```\rinside\r ```\rexport outside"),
1594 ] {
1595 assert_eq!(neutralize_esm(input), expected);
1596 }
1597 assert_eq!(
1598 neutralize_esm("Intro.\r\nimport outside"),
1599 "Intro.\r\nimport outside"
1600 );
1601 assert_eq!(
1602 neutralize_esm("export first\r\npréface `span:\rimport inside`\r\nimport last"),
1603 "export first\r\npréface `span:\rimport inside`\r\nimport last"
1604 );
1605 }
1606
1607 #[test]
1608 fn traverses_sorted_code_regions() {
1609 let input = "`first`\n ```\n import inside fence\n ```\n`last`\nexport outside";
1610 let expected =
1611 "`first`\n ```\n import inside fence\n ```\n`last`\nexport outside";
1612 assert_eq!(neutralize_esm(input), expected);
1613 }
1614
1615 #[test]
1616 fn preserves_code_across_mdx_edge_cases() {
1617 for (input, expected) in [
1618 (
1619 "```\nimport inside\n```\nexport outside",
1620 "```\nimport inside\n```\nexport outside",
1621 ),
1622 (
1623 "```\n~~~\nimport inside\n```\nexport outside",
1624 "```\n~~~\nimport inside\n```\nexport outside",
1625 ),
1626 (
1627 "````\n```\nimport inside\n```\n````\nexport outside",
1628 "````\n```\nimport inside\n```\n````\nexport outside",
1629 ),
1630 (
1631 "```\nimport inside\n```suffix\nexport inside\n```\nimport outside",
1632 "```\nimport inside\n```suffix\nexport inside\n```\nimport outside",
1633 ),
1634 (
1635 "`example:\nimport inside`\nexport outside",
1636 "`example:\nimport inside`\nexport outside",
1637 ),
1638 (
1639 "```\ninside\n ```\n```\nimport inside second\n```\nexport outside",
1640 "```\ninside\n ```\n```\nimport inside second\n```\nexport outside",
1641 ),
1642 (
1643 "- ```\n import inside list\n ```\n\nexport outside",
1644 "- ```\n import inside list\n ```\n\nexport outside",
1645 ),
1646 (
1647 " ```\n import inside indented\n ```\nexport outside",
1648 " ```\n import inside indented\n ```\nexport outside",
1649 ),
1650 (" ```\n import inside unclosed", " ```\n import inside unclosed"),
1651 ] {
1652 assert_eq!(neutralize_esm(input), expected, "input:\n{input}");
1653 }
1654 }
1655
1656 #[test]
1657 fn neutralized_output_contains_no_mdx_esm() {
1658 let input = "import injected from \"x\"\n\n```js\nexport const example = 1\n```";
1659 assert!(contains_mdx_esm(&parse_mdx(input)));
1660
1661 let output = neutralize_esm(input);
1662 assert_eq!(
1663 output,
1664 "import injected from \"x\"\n\n```js\nexport const example = 1\n```"
1665 );
1666
1667 let tree = parse_mdx(&output);
1668 assert!(!contains_mdx_esm(&tree), "{tree:#?}");
1669 assert!(tree.children().is_some_and(|children| {
1670 children.iter().any(
1671 |node| matches!(node, Node::Code(code) if code.value == "export const example = 1"),
1672 )
1673 }));
1674 }
1675
1676 #[test]
1677 fn leaves_non_esm_prefixes_unchanged() {
1678 let input = "important\nexporter\nimport_\nexport$\nImport value\nExport value\n import value\n\t export value\nimport(value)\nexport: value\nimport\tvalue";
1679 assert_eq!(neutralize_esm(input), input);
1680 }
1681
1682 #[test]
1683 fn neutralizes_only_exact_mdx_esm_prefixes() {
1684 assert_eq!(
1685 neutralize_esm("import value\nexport value\nimport value\nexport value"),
1686 "import value\nexport value\nimport value\nexport value"
1687 );
1688 }
1689
1690 #[test]
1691 fn neutralizes_many_candidates_across_many_code_regions() {
1692 let input = "`code`\nexport outside\n".repeat(10_000);
1693 let output = neutralize_esm(&input);
1694 assert_eq!(output.matches("`code`").count(), 10_000);
1695 assert_eq!(output.matches("export outside").count(), 10_000);
1696 }
1697}