1use 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
28struct 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#[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#[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 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 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 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 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 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 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 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, "| <none> | {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 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
276fn 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
294fn 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
316fn 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
339fn 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 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, ¶ms))
430}
431
432#[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 descriptions: Vec<Description>,
453 params: Vec<(String, String)>,
454 returns: Vec<(String, String)>,
455 customs: Vec<(String, String)>,
456 unnamed_param_names: Vec<String>,
458}
459
460impl CommentData {
461 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
487fn collect_comments(docs: &DocComments<'_>, links: Links<'_>) -> CommentData {
493 let mut data = CommentData::default();
494
495 const FILTERED_CUSTOM: &[&str] = &["solidity", "src", "use-src", "ast-id"];
497 const KNOWN_CUSTOM: &[&str] = &["name"];
499
500 let mut prev_doc_was_blank = false;
503 #[derive(Clone, Copy)]
504 enum LastSection {
505 Desc, 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 let raw: &str =
521 if doc.kind == CommentKind::Block { &clean_block_doc_content(raw) } else { raw };
522
523 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 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 { .. } => {} NatSpecKind::Custom { name } => {
579 let tag = name.as_str();
580 if FILTERED_CUSTOM.contains(&tag) {
581 } else if tag == "name" {
583 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 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
619fn is_known_custom_tag(tag: &str) -> bool {
624 !tag.is_empty() && tag.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
625}
626
627fn 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
638fn 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(|| "<none>".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
675fn 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 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
689fn 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
713fn 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
721fn 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
765fn 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'`' { "`" } else { "~" });
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
788fn 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 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 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
888fn 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
896fn escape_table_cell(s: &str) -> String {
901 s.replace('\\', "\\\\")
902 .replace('|', "\\|")
903 .replace("\r\n", "<br/>")
904 .replace(['\n', '\r'], "<br/>")
905}
906
907fn 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 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 => "<none>".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
1039fn write_signature_table_header(out: &mut String, heading: &str) {
1041 writeln!(out, "**{heading}**\n\n| Name | Type | Description |\n| ---- | ---- | ----------- |")
1042 .unwrap();
1043}
1044
1045fn 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
1065fn 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#[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 let hir_id = find_contract_id(gcx, c.name.as_str(), abs_sol_path);
1120 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 < and {\n~~~solidity\nif (a < b) { revert(); }\n~~~\nAfter < and {"
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 "- ~~~\n example <\n ~~~\nOutside < and {",
1248 ),
1249 ("~~~\nexample < and {", "~~~\nexample < and {"),
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, "~~~\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, "~~~\n ~~~\n`1+1`\n~~~");
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\rexport const afterCarriageReturn = 1"
1291 );
1292 for (input, expected) in [
1293 (
1294 "```\rimport inside\r```\rexport outside",
1295 "```\rimport inside\r```\rexport outside",
1296 ),
1297 (
1298 "```\r\nimport inside\r\n```\r\nexport outside",
1299 "```\r\nimport inside\r\n```\r\nexport outside",
1300 ),
1301 (
1302 "`example:\rimport inside`\rexport outside",
1303 "`example:\rimport inside`\rexport outside",
1304 ),
1305 (
1306 "`example:\r\nimport inside`\r\nexport outside",
1307 "`example:\r\nimport inside`\r\nexport outside",
1308 ),
1309 (
1310 " ```\r import inside\r ```\rexport outside",
1311 " ```\r import inside\r ```\rexport outside",
1312 ),
1313 ("```\rinside\r ```\rexport outside", "```\rinside\r ```\rexport outside"),
1314 ] {
1315 assert_eq!(neutralize_esm(input), expected);
1316 }
1317 assert_eq!(
1318 neutralize_esm("Intro.\r\nimport outside"),
1319 "Intro.\r\nimport outside"
1320 );
1321 assert_eq!(
1322 neutralize_esm("export first\r\npréface `span:\rimport inside`\r\nimport last"),
1323 "export first\r\npréface `span:\rimport inside`\r\nimport 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`\nexport 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```\nexport outside",
1341 ),
1342 (
1343 "```\n~~~\nimport inside\n```\nexport outside",
1344 "```\n~~~\nimport inside\n```\nexport outside",
1345 ),
1346 (
1347 "````\n```\nimport inside\n```\n````\nexport outside",
1348 "````\n```\nimport inside\n```\n````\nexport outside",
1349 ),
1350 (
1351 "```\nimport inside\n```suffix\nexport inside\n```\nimport outside",
1352 "```\nimport inside\n```suffix\nexport inside\n```\nimport outside",
1353 ),
1354 (
1355 "`example:\nimport inside`\nexport outside",
1356 "`example:\nimport inside`\nexport outside",
1357 ),
1358 (
1359 "```\ninside\n ```\n```\nimport inside second\n```\nexport outside",
1360 "```\ninside\n ```\n```\nimport inside second\n```\nexport outside",
1361 ),
1362 (
1363 "- ```\n import inside list\n ```\n\nexport outside",
1364 "- ```\n import inside list\n ```\n\nexport outside",
1365 ),
1366 (
1367 " ```\n import inside indented\n ```\nexport outside",
1368 " ```\n import inside indented\n ```\nexport 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 "import 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 "import value\nexport value\nimport value\nexport 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("export outside").count(), 10_000);
1416 }
1417}