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
29#[allow(clippy::too_many_arguments)]
33pub fn source<'ast, 'gcx>(
34 ast: &'ast SourceUnit<'ast>,
35 file: &Arc<SourceFile>,
36 _sm: &SourceMap,
37 rel_sol_path: &Path,
38 abs_sol_path: &Path,
39 _root: &Path,
40 gcx: Gcx<'gcx>,
41 name_to_page: &NameToPage,
42 git_url: Option<&str>,
43 deployments: &HashMap<String, Vec<Deployment>>,
44) -> Vec<(PathBuf, String)> {
45 let out_dir = rel_sol_path.parent().unwrap_or(Path::new(""));
46 let stem = rel_sol_path.file_stem().and_then(|s| s.to_str()).unwrap_or("constants");
47
48 let src_text = file.src.as_str();
49 let src_start = file.start_pos.to_usize();
50 let ctx = Ctx { src_text, src_start };
51
52 let mut pages: Vec<(PathBuf, String)> = Vec::new();
53 let mut const_vars: Vec<(Span, &VariableDefinition<'_>, &DocComments<'_>)> = Vec::new();
54 let mut free_fns: std::collections::BTreeMap<
55 String,
56 Vec<(Span, &ItemFunction<'_>, &DocComments<'_>)>,
57 > = Default::default();
58
59 for item in ast.items.iter() {
60 let span = item.span;
61 match &item.kind {
62 ItemKind::Pragma(_) | ItemKind::Import(_) | ItemKind::Using(_) => (),
63 ItemKind::Contract(c) => {
64 let kind_str = contract_kind_str(c.kind);
65 let fname = format!("{kind_str}.{}.mdx", c.name.as_str());
66 let page_path = out_dir.join(&fname);
67 let hir_id = find_contract_id(gcx, c.name.as_str(), abs_sol_path);
69 let contract_deployments = if matches!(c.kind, ContractKind::Contract) {
71 deployments.get(c.name.as_str()).map(Vec::as_slice).unwrap_or(&[])
72 } else {
73 &[]
74 };
75 let content = render_contract(
76 span,
77 c,
78 &item.docs,
79 &ctx,
80 gcx,
81 hir_id,
82 name_to_page,
83 &page_path,
84 git_url,
85 contract_deployments,
86 );
87 pages.push((page_path, content));
88 }
89
90 ItemKind::Function(f) => {
91 let name = f.header.name.map(|n| n.as_str().to_string()).unwrap_or_default();
92 free_fns.entry(name).or_default().push((span, f, &item.docs));
93 }
94
95 ItemKind::Variable(v) => {
96 const_vars.push((span, v, &item.docs));
97 }
98
99 ItemKind::Struct(s) => {
100 let fname = format!("struct.{}.mdx", s.name.as_str());
101 let page_path = out_dir.join(&fname);
102 pages.push((
103 page_path.clone(),
104 render_struct(span, s, &item.docs, &ctx, name_to_page, &page_path, git_url),
105 ));
106 }
107
108 ItemKind::Enum(e) => {
109 let fname = format!("enum.{}.mdx", e.name.as_str());
110 let page_path = out_dir.join(&fname);
111 pages.push((
112 page_path.clone(),
113 render_enum(span, e, &item.docs, &ctx, name_to_page, &page_path, git_url),
114 ));
115 }
116
117 ItemKind::Udvt(u) => {
118 let fname = format!("type.{}.mdx", u.name.as_str());
119 let page_path = out_dir.join(&fname);
120 pages.push((
121 page_path.clone(),
122 render_udvt(span, u, &item.docs, &ctx, name_to_page, &page_path, git_url),
123 ));
124 }
125
126 ItemKind::Error(e) => {
127 let fname = format!("error.{}.mdx", e.name.as_str());
128 let page_path = out_dir.join(&fname);
129 pages.push((
130 page_path.clone(),
131 render_error(span, e, &item.docs, &ctx, name_to_page, &page_path, git_url),
132 ));
133 }
134
135 ItemKind::Event(e) => {
136 let fname = format!("event.{}.mdx", e.name.as_str());
137 let page_path = out_dir.join(&fname);
138 pages.push((
139 page_path.clone(),
140 render_event(span, e, &item.docs, &ctx, name_to_page, &page_path, git_url),
141 ));
142 }
143 }
144 }
145
146 for (name, overloads) in &free_fns {
147 let fname = format!("function.{name}.mdx");
148 let page_path = out_dir.join(&fname);
149 let content =
150 render_free_functions(name, overloads, &ctx, name_to_page, &page_path, git_url);
151 pages.push((page_path, content));
152 }
153
154 if !const_vars.is_empty() {
155 let fname = format!("constants.{stem}.mdx");
156 let page_path = out_dir.join(&fname);
157 let content = render_constants(stem, &const_vars, &ctx, name_to_page, &page_path, git_url);
158 pages.push((page_path, content));
159 }
160
161 pages
162}
163
164struct Ctx<'a> {
167 src_text: &'a str,
168 src_start: usize,
169}
170
171impl<'a> Ctx<'a> {
172 fn snippet(&self, span: Span) -> &'a str {
173 let lo = span.lo().to_usize().saturating_sub(self.src_start);
174 let hi = span.hi().to_usize().saturating_sub(self.src_start);
175 let lo = lo.min(self.src_text.len());
176 let hi = hi.min(self.src_text.len());
177 &self.src_text[lo..hi]
178 }
179
180 fn dedented_snippet(&self, span: Span) -> String {
181 dedent(self.snippet(span))
182 }
183}
184
185#[allow(clippy::too_many_arguments)]
188fn render_contract<'ast, 'gcx>(
189 _span: Span,
190 c: &'ast ItemContract<'ast>,
191 docs: &'ast DocComments<'ast>,
192 ctx: &Ctx<'_>,
193 gcx: Gcx<'gcx>,
194 hir_id: Option<hir::ContractId>,
195 name_to_page: &NameToPage,
196 page_path: &Path,
197 git_url: Option<&str>,
198 deployments: &[Deployment],
199) -> String {
200 let name = c.name.as_str();
201
202 let mut local = hir_id.map_or_else(
205 || hir_ext::LocalMembers::new(name),
206 |id| hir_ext::LocalMembers::for_contract(gcx, id, name_to_page),
207 );
208 for member in c.body.iter() {
209 match &member.kind {
210 ItemKind::Variable(v) => {
211 if let Some(n) = v.name {
212 local.insert(n.as_str());
213 }
214 }
215 ItemKind::Function(f) => {
216 local.insert(&function_heading(f));
217 if let Some(anchor) = function_signature_anchor(f, ctx) {
218 local.insert_anchor(anchor);
219 }
220 }
221 ItemKind::Event(e) => local.insert(e.name.as_str()),
222 ItemKind::Error(e) => local.insert(e.name.as_str()),
223 ItemKind::Struct(s) => local.insert(s.name.as_str()),
224 ItemKind::Enum(e) => local.insert(e.name.as_str()),
225 ItemKind::Udvt(u) => local.insert(u.name.as_str()),
226 _ => {}
227 }
228 }
229 let local = Some(&local);
230
231 let comments = collect_comments(docs, name_to_page, page_path, local);
232 let mut out = String::new();
233 write_frontmatter(&mut out, name, first_notice(&comments).as_deref());
234 writeln!(out, "# {name}").unwrap();
235 writeln!(out).unwrap();
236 write_git_source(&mut out, git_url);
237 write_deployments_table(&mut out, deployments);
238
239 if let Some(id) = hir_id
241 && let Some(inherits) = hir_ext::inheritance_links(gcx, id, name_to_page, page_path)
242 {
243 writeln!(out, "{inherits}").unwrap();
244 writeln!(out).unwrap();
245 }
246
247 write_comment_block(&mut out, &comments);
248
249 let mut constants: Vec<(Span, &VariableDefinition<'_>, &DocComments<'_>)> = Vec::new();
251 let mut state_vars: Vec<(Span, &VariableDefinition<'_>, &DocComments<'_>)> = Vec::new();
252 let mut functions: Vec<(Span, &ItemFunction<'_>, &DocComments<'_>)> = Vec::new();
253 let mut events: Vec<(Span, &ItemEvent<'_>, &DocComments<'_>)> = Vec::new();
254 let mut errors: Vec<(Span, &ItemError<'_>, &DocComments<'_>)> = Vec::new();
255 let mut structs: Vec<(Span, &ItemStruct<'_>, &DocComments<'_>)> = Vec::new();
256 let mut enums: Vec<(Span, &ItemEnum<'_>, &DocComments<'_>)> = Vec::new();
257 let mut udvts: Vec<(Span, &ItemUdvt<'_>, &DocComments<'_>)> = Vec::new();
258
259 for member in c.body.iter() {
260 let s = member.span;
261 match &member.kind {
262 ItemKind::Variable(v) => {
263 if v.mutability.is_some_and(|m| m.is_constant() || m.is_immutable()) {
265 constants.push((s, v, &member.docs));
266 } else {
267 state_vars.push((s, v, &member.docs));
268 }
269 }
270 ItemKind::Function(f) => functions.push((s, f, &member.docs)),
271 ItemKind::Event(e) => events.push((s, e, &member.docs)),
272 ItemKind::Error(e) => errors.push((s, e, &member.docs)),
273 ItemKind::Struct(st) => structs.push((s, st, &member.docs)),
274 ItemKind::Enum(e) => enums.push((s, e, &member.docs)),
275 ItemKind::Udvt(u) => udvts.push((s, u, &member.docs)),
276 _ => {}
277 }
278 }
279
280 let write_vars =
281 |out: &mut String, vars: &[(Span, &VariableDefinition<'_>, &DocComments<'_>)]| {
282 for (span, v, docs) in vars {
283 let vname = v.name.map(|n| n.as_str().to_string()).unwrap_or_default();
284 writeln!(out, "### {vname}").unwrap();
285 writeln!(out).unwrap();
286 let mut c = collect_comments(docs, name_to_page, page_path, local);
287 let vid = hir_id.and_then(|cid| {
290 gcx.hir.contract(cid).items.iter().find_map(|&item| match item {
291 hir::ItemId::Variable(id) if gcx.hir.variable(id).span == *span => Some(id),
292 _ => None,
293 })
294 });
295 let inherited = vid.and_then(|id| {
296 let explicit = inheritdoc_base(docs).is_some();
297 (explicit || !has_local_natspec(docs))
298 .then(|| hir_ext::natspec_doc(gcx, id.into(), !explicit))
299 .flatten()
300 });
301 let sanitize =
302 |s: &str| hir_ext::replace_inline_links(s, name_to_page, page_path, local);
303 if let Some(ref base_doc) = inherited {
304 if c.notices.is_empty() {
305 let inherited_notices: Vec<String> =
306 base_doc.notices.iter().map(|s| sanitize(s)).collect();
307 let mut new_desc: Vec<Description> = inherited_notices
308 .iter()
309 .map(|s| Description { kind: DescKind::Notice, content: s.clone() })
310 .collect();
311 new_desc.append(&mut c.descriptions);
312 c.descriptions = new_desc;
313 c.notices.extend(inherited_notices);
314 }
315 if c.devs.is_empty() {
316 let inherited_devs: Vec<String> =
317 base_doc.devs.iter().map(|s| sanitize(s)).collect();
318 c.descriptions.extend(
319 inherited_devs
320 .iter()
321 .map(|s| Description { kind: DescKind::Dev, content: s.clone() }),
322 );
323 c.devs.extend(inherited_devs);
324 }
325 }
326 write_comment_block(out, &c);
327 write_code_block(out, &ctx.dedented_snippet(*span));
328 let getter_doc = inherited.as_ref().filter(|d| !d.getter_returns.is_empty());
332 if let Some(base_doc) = getter_doc {
333 write_getter_table(out, "Parameters", &base_doc.getter_params, &sanitize);
334 write_getter_table(out, "Returns", &base_doc.getter_returns, &sanitize);
335 } else if !c.returns.is_empty() {
336 let ty = format!("`{}`", ctx.snippet(v.ty.span).trim());
337 writeln!(out, "**Returns**").unwrap();
338 writeln!(out).unwrap();
339 writeln!(out, "| Name | Type | Description |").unwrap();
340 writeln!(out, "| ---- | ---- | ----------- |").unwrap();
341 for (name, desc) in &c.returns {
342 let full_desc =
346 if desc.is_empty() { name.clone() } else { format!("{name} {desc}") };
347 let desc_cell = escape_table_cell(&full_desc);
348 writeln!(out, "| <none> | {ty} | {desc_cell} |").unwrap();
349 }
350 writeln!(out).unwrap();
351 }
352 }
353 };
354
355 if !constants.is_empty() {
356 writeln!(out, "## Constants").unwrap();
357 writeln!(out).unwrap();
358 write_vars(&mut out, &constants);
359 }
360
361 if !state_vars.is_empty() {
362 writeln!(out, "## State Variables").unwrap();
363 writeln!(out).unwrap();
364 write_vars(&mut out, &state_vars);
365 }
366
367 if !functions.is_empty() {
368 writeln!(out, "## Functions").unwrap();
369 writeln!(out).unwrap();
370 for (span, f, docs) in &functions {
371 let fn_name = match f.kind {
372 FunctionKind::Constructor => None,
373 FunctionKind::Fallback => Some("fallback".to_string()),
374 FunctionKind::Receive => Some("receive".to_string()),
375 FunctionKind::Function | FunctionKind::Modifier => {
376 f.header.name.map(|name| name.as_str().to_string())
377 }
378 };
379 let inherited = fn_name.as_deref().and_then(|fname| {
382 let fid = hir_id.and_then(|cid| {
383 gcx.hir.contract(cid).items.iter().find_map(|&item| match item {
384 hir::ItemId::Function(id) if gcx.hir.function(id).span == *span => Some(id),
385 _ => None,
386 })
387 });
388 match inheritdoc_base(docs) {
389 Some(_) => {
390 if hir_id.is_some() && fid.is_none() {
391 let _ = sh_warn!(
392 "forge doc: failed to find HIR function for `{}.{fname}` while resolving @inheritdoc",
393 c.name
394 );
395 }
396 fid.and_then(|id| hir_ext::natspec_doc(gcx, id.into(), false))
397 }
398 None if !has_local_natspec(docs) =>
399 fid.and_then(|id| hir_ext::natspec_doc(gcx, id.into(), true)),
400 None => None,
401 }
402 });
403 render_function_section(
404 &mut out,
405 *span,
406 f,
407 docs,
408 ctx,
409 name_to_page,
410 page_path,
411 local,
412 inherited.as_ref(),
413 );
414 }
415 }
416
417 if !events.is_empty() {
418 writeln!(out, "## Events").unwrap();
419 writeln!(out).unwrap();
420 for (span, e, docs) in &events {
421 writeln!(out, "### {}", e.name.as_str()).unwrap();
422 writeln!(out).unwrap();
423 let c = collect_comments(docs, name_to_page, page_path, local);
424 write_comment_block(&mut out, &c);
425 write_code_block(&mut out, &ctx.dedented_snippet(*span));
426 write_param_table(&mut out, "Parameters", &e.parameters, &c, None, ctx);
427 }
428 }
429
430 if !errors.is_empty() {
431 writeln!(out, "## Errors").unwrap();
432 writeln!(out).unwrap();
433 for (span, e, docs) in &errors {
434 writeln!(out, "### {}", e.name.as_str()).unwrap();
435 writeln!(out).unwrap();
436 let c = collect_comments(docs, name_to_page, page_path, local);
437 write_comment_block(&mut out, &c);
438 write_code_block(&mut out, &ctx.dedented_snippet(*span));
439 write_param_table(&mut out, "Parameters", &e.parameters, &c, None, ctx);
440 }
441 }
442
443 if !structs.is_empty() {
444 writeln!(out, "## Structs").unwrap();
445 writeln!(out).unwrap();
446 for (span, s, docs) in &structs {
447 writeln!(out, "### {}", s.name.as_str()).unwrap();
448 writeln!(out).unwrap();
449 let c = collect_comments(docs, name_to_page, page_path, local);
450 write_comment_block(&mut out, &c);
451 write_code_block(&mut out, &ctx.dedented_snippet(*span));
452 write_struct_properties_table(&mut out, s.fields, &c, ctx);
453 }
454 }
455
456 if !enums.is_empty() {
457 writeln!(out, "## Enums").unwrap();
458 writeln!(out).unwrap();
459 for (span, e, docs) in &enums {
460 writeln!(out, "### {}", e.name.as_str()).unwrap();
461 writeln!(out).unwrap();
462 let c = collect_comments(docs, name_to_page, page_path, local);
463 write_comment_block(&mut out, &c);
464 write_code_block(&mut out, &ctx.dedented_snippet(*span));
465 write_enum_variants_table(&mut out, e.variants, &c);
466 }
467 }
468
469 if !udvts.is_empty() {
470 writeln!(out, "## Custom Types").unwrap();
471 writeln!(out).unwrap();
472 for (span, u, docs) in &udvts {
473 writeln!(out, "### {}", u.name.as_str()).unwrap();
474 writeln!(out).unwrap();
475 let c = collect_comments(docs, name_to_page, page_path, local);
476 write_comment_block(&mut out, &c);
477 write_code_block(&mut out, &format!("{};", ctx.dedented_snippet(*span)));
478 }
479 }
480
481 out
482}
483
484fn render_free_functions(
487 name: &str,
488 overloads: &[(Span, &ItemFunction<'_>, &DocComments<'_>)],
489 ctx: &Ctx<'_>,
490 name_to_page: &NameToPage,
491 page_path: &Path,
492 git_url: Option<&str>,
493) -> String {
494 let title = if name.is_empty() { "function" } else { name };
495 let first_comments = collect_comments(overloads[0].2, name_to_page, page_path, None);
496 let mut out = String::new();
497 write_frontmatter(&mut out, title, first_notice(&first_comments).as_deref());
498 writeln!(out, "# {title}").unwrap();
499 writeln!(out).unwrap();
500 write_git_source(&mut out, git_url);
501 for (span, f, docs) in overloads {
502 render_function_section(&mut out, *span, f, docs, ctx, name_to_page, page_path, None, None);
503 }
504 out
505}
506
507fn render_constants(
510 stem: &str,
511 vars: &[(Span, &VariableDefinition<'_>, &DocComments<'_>)],
512 ctx: &Ctx<'_>,
513 name_to_page: &NameToPage,
514 page_path: &Path,
515 git_url: Option<&str>,
516) -> String {
517 let title = format!("{stem} Constants");
518 let mut out = String::new();
519 write_frontmatter(&mut out, &title, None);
520 writeln!(out, "# {title}").unwrap();
521 writeln!(out).unwrap();
522 write_git_source(&mut out, git_url);
523 for (span, v, docs) in vars {
524 let name = v.name.map(|n| n.as_str().to_string()).unwrap_or_else(|| "_".to_string());
525 writeln!(out, "## {name}").unwrap();
526 writeln!(out).unwrap();
527 let c = collect_comments(docs, name_to_page, page_path, None);
528 write_comment_block(&mut out, &c);
529 write_code_block(&mut out, &ctx.dedented_snippet(*span));
530 }
531 out
532}
533
534fn render_struct<'ast>(
537 span: Span,
538 s: &'ast ItemStruct<'ast>,
539 docs: &'ast DocComments<'ast>,
540 ctx: &Ctx<'_>,
541 name_to_page: &NameToPage,
542 page_path: &Path,
543 git_url: Option<&str>,
544) -> String {
545 let name = s.name.as_str();
546 let c = collect_comments(docs, name_to_page, page_path, None);
547 let mut out = String::new();
548 write_frontmatter(&mut out, name, first_notice(&c).as_deref());
549 writeln!(out, "# {name}").unwrap();
550 writeln!(out).unwrap();
551 write_git_source(&mut out, git_url);
552 write_comment_block(&mut out, &c);
553 write_code_block(&mut out, &ctx.dedented_snippet(span));
554 write_struct_properties_table(&mut out, s.fields, &c, ctx);
555 out
556}
557
558fn render_enum<'ast>(
559 span: Span,
560 e: &'ast ItemEnum<'ast>,
561 docs: &'ast DocComments<'ast>,
562 ctx: &Ctx<'_>,
563 name_to_page: &NameToPage,
564 page_path: &Path,
565 git_url: Option<&str>,
566) -> String {
567 let name = e.name.as_str();
568 let c = collect_comments(docs, name_to_page, page_path, None);
569 let mut out = String::new();
570 write_frontmatter(&mut out, name, first_notice(&c).as_deref());
571 writeln!(out, "# {name}").unwrap();
572 writeln!(out).unwrap();
573 write_git_source(&mut out, git_url);
574 write_comment_block(&mut out, &c);
575 write_code_block(&mut out, &ctx.dedented_snippet(span));
576 write_enum_variants_table(&mut out, e.variants, &c);
577 out
578}
579
580fn render_udvt<'ast>(
581 span: Span,
582 u: &'ast ItemUdvt<'ast>,
583 docs: &'ast DocComments<'ast>,
584 ctx: &Ctx<'_>,
585 name_to_page: &NameToPage,
586 page_path: &Path,
587 git_url: Option<&str>,
588) -> String {
589 let name = u.name.as_str();
590 let c = collect_comments(docs, name_to_page, page_path, None);
591 let mut out = String::new();
592 write_frontmatter(&mut out, name, first_notice(&c).as_deref());
593 writeln!(out, "# {name}").unwrap();
594 writeln!(out).unwrap();
595 write_git_source(&mut out, git_url);
596 write_comment_block(&mut out, &c);
597 write_code_block(&mut out, &format!("{};", ctx.dedented_snippet(span)));
598 out
599}
600
601fn render_error<'ast>(
602 span: Span,
603 e: &'ast ItemError<'ast>,
604 docs: &'ast DocComments<'ast>,
605 ctx: &Ctx<'_>,
606 name_to_page: &NameToPage,
607 page_path: &Path,
608 git_url: Option<&str>,
609) -> String {
610 let name = e.name.as_str();
611 let c = collect_comments(docs, name_to_page, page_path, None);
612 let mut out = String::new();
613 write_frontmatter(&mut out, name, first_notice(&c).as_deref());
614 writeln!(out, "# {name}").unwrap();
615 writeln!(out).unwrap();
616 write_git_source(&mut out, git_url);
617 write_comment_block(&mut out, &c);
618 write_code_block(&mut out, &ctx.dedented_snippet(span));
619 write_param_table(&mut out, "Parameters", &e.parameters, &c, None, ctx);
620 out
621}
622
623fn render_event<'ast>(
624 span: Span,
625 e: &'ast ItemEvent<'ast>,
626 docs: &'ast DocComments<'ast>,
627 ctx: &Ctx<'_>,
628 name_to_page: &NameToPage,
629 page_path: &Path,
630 git_url: Option<&str>,
631) -> String {
632 let name = e.name.as_str();
633 let c = collect_comments(docs, name_to_page, page_path, None);
634 let mut out = String::new();
635 write_frontmatter(&mut out, name, first_notice(&c).as_deref());
636 writeln!(out, "# {name}").unwrap();
637 writeln!(out).unwrap();
638 write_git_source(&mut out, git_url);
639 write_comment_block(&mut out, &c);
640 write_code_block(&mut out, &ctx.dedented_snippet(span));
641 write_param_table(&mut out, "Parameters", &e.parameters, &c, None, ctx);
642 out
643}
644
645#[allow(clippy::too_many_arguments)]
647fn render_function_section(
648 out: &mut String,
649 span: Span,
650 f: &ItemFunction<'_>,
651 docs: &DocComments<'_>,
652 ctx: &Ctx<'_>,
653 name_to_page: &NameToPage,
654 page_path: &Path,
655 local: Option<&hir_ext::LocalMembers>,
656 inherited: Option<&hir_ext::NatSpecDoc>,
657) {
658 let heading = function_heading(f);
659 if let Some(anchor) = function_signature_anchor(f, ctx) {
660 writeln!(out, "<a id=\"{anchor}\"></a>").unwrap();
661 writeln!(out).unwrap();
662 }
663 writeln!(out, "### {heading}").unwrap();
664 writeln!(out).unwrap();
665 let mut c = collect_comments(docs, name_to_page, page_path, local);
666 let mut inherited_params = None;
667 if let Some(inherited) = inherited {
669 let sanitize = |s: &str| hir_ext::replace_inline_links(s, name_to_page, page_path, local);
670 let inherited_notices: Vec<String> =
671 inherited.notices.iter().map(|s| sanitize(s)).collect();
672 let inherited_devs: Vec<String> = inherited.devs.iter().map(|s| sanitize(s)).collect();
673 if c.notices.is_empty() {
674 let mut new_desc: Vec<Description> = inherited_notices
675 .iter()
676 .map(|s| Description { kind: DescKind::Notice, content: s.clone() })
677 .collect();
678 new_desc.append(&mut c.descriptions);
679 c.descriptions = new_desc;
680 c.notices.extend_from_slice(&inherited_notices);
681 }
682 if c.devs.is_empty() {
683 c.devs.extend_from_slice(&inherited_devs);
684 c.descriptions.extend(
685 inherited_devs
686 .iter()
687 .map(|s| Description { kind: DescKind::Dev, content: s.clone() }),
688 );
689 }
690 if c.params.is_empty() {
691 let params = inherited.params.iter().map(|desc| sanitize(desc)).collect::<Vec<_>>();
692 for (index, desc) in params.iter().enumerate() {
693 if let Some(name) = f.header.parameters.get(index).and_then(|param| param.name) {
694 c.params.push((name.as_str().to_string(), desc.clone()));
695 }
696 }
697 inherited_params = Some(params);
698 }
699 if c.returns.is_empty() {
700 for (index, desc) in inherited.returns.iter().enumerate() {
701 let name = f
702 .header
703 .returns
704 .as_ref()
705 .and_then(|returns| returns.get(index))
706 .and_then(|return_| return_.name)
707 .map(|name| name.as_str().to_string())
708 .unwrap_or_default();
709 c.returns.push((name, sanitize(desc)));
710 }
711 }
712 }
713 write_comment_block(out, &c);
714 let hspan = if f.header.span.lo() == f.header.span.hi() { span } else { f.header.span };
715 let snippet = ctx.dedented_snippet(hspan);
716 write_code_block(out, &format!("{snippet};"));
717 write_param_table(
718 out,
719 "Parameters",
720 &f.header.parameters,
721 &c,
722 inherited_params.as_deref(),
723 ctx,
724 );
725 if let Some(returns) = &f.header.returns {
726 write_param_table(out, "Returns", returns, &c, None, ctx);
727 }
728}
729
730fn function_heading(f: &ItemFunction<'_>) -> String {
731 match f.kind {
732 FunctionKind::Constructor => "constructor".to_string(),
733 FunctionKind::Fallback => "fallback".to_string(),
734 FunctionKind::Receive => "receive".to_string(),
735 FunctionKind::Function | FunctionKind::Modifier => {
736 f.header.name.map(|n| n.as_str().to_string()).unwrap_or_else(|| "function".to_string())
737 }
738 }
739}
740
741fn function_signature_anchor(f: &ItemFunction<'_>, ctx: &Ctx<'_>) -> Option<String> {
742 let name = match f.kind {
743 FunctionKind::Constructor => "constructor".to_string(),
744 FunctionKind::Fallback => "fallback".to_string(),
745 FunctionKind::Receive => "receive".to_string(),
746 FunctionKind::Function | FunctionKind::Modifier => f.header.name?.as_str().to_string(),
747 };
748 let params = f
749 .header
750 .parameters
751 .vars
752 .iter()
753 .map(|v| ctx.snippet(v.ty.span).trim().to_string())
754 .collect::<Vec<_>>();
755
756 Some(hir_ext::function_signature_anchor(&name, ¶ms))
757}
758
759#[derive(Clone, Copy, PartialEq, Eq)]
762enum DescKind {
763 Notice,
764 Dev,
765}
766
767struct Description {
768 kind: DescKind,
769 content: String,
770}
771
772struct CommentData {
773 titles: Vec<String>,
774 authors: Vec<String>,
775 notices: Vec<String>,
777 devs: Vec<String>,
779 descriptions: Vec<Description>,
783 params: Vec<(String, String)>,
784 returns: Vec<(String, String)>,
785 customs: Vec<(String, String)>,
786 unnamed_param_names: Vec<String>,
788}
789
790fn collect_comments(
797 docs: &DocComments<'_>,
798 name_to_page: &NameToPage,
799 page_path: &Path,
800 local: Option<&hir_ext::LocalMembers>,
801) -> CommentData {
802 let mut data = CommentData {
803 titles: Vec::new(),
804 authors: Vec::new(),
805 notices: Vec::new(),
806 devs: Vec::new(),
807 descriptions: Vec::new(),
808 params: Vec::new(),
809 returns: Vec::new(),
810 customs: Vec::new(),
811 unnamed_param_names: Vec::new(),
812 };
813
814 const FILTERED_CUSTOM: &[&str] = &["solidity", "src", "use-src", "ast-id"];
816 const KNOWN_CUSTOM: &[&str] = &["name"];
818
819 let mut prev_doc_was_blank = false;
822 #[derive(Clone, Copy)]
823 enum LastSection {
824 Desc, Param,
826 Return,
827 }
828 let mut last_section: Option<LastSection> = None;
829
830 for doc in docs.iter() {
831 if doc.natspec.is_empty() {
832 prev_doc_was_blank = true;
833 continue;
834 }
835
836 for item in doc.natspec.iter() {
837 let raw = doc.natspec_content(item);
838 let raw: &str =
841 if doc.kind == CommentKind::Block { &clean_block_doc_content(raw) } else { raw };
842
843 let is_continuation = matches!(item.kind, NatSpecKind::Notice)
847 && raw.starts_with(|c: char| c.is_whitespace());
848
849 let trimmed = raw.trim();
850 if trimmed.is_empty() {
851 prev_doc_was_blank = true;
852 continue;
853 }
854
855 let content = hir_ext::replace_inline_links(trimmed, name_to_page, page_path, local);
857
858 if is_continuation && !prev_doc_was_blank {
859 let appended = match last_section {
860 Some(LastSection::Desc) => data.descriptions.last_mut().map(|d| &mut d.content),
861 Some(LastSection::Param) => data.params.last_mut().map(|(_, d)| d),
862 Some(LastSection::Return) => data.returns.last_mut().map(|(_, d)| d),
863 None => None,
864 };
865 if let Some(last) = appended {
866 last.push('\n');
867 last.push_str(&content);
868 prev_doc_was_blank = false;
869 continue;
870 }
871 }
872
873 prev_doc_was_blank = false;
874
875 match item.kind {
876 NatSpecKind::Title => data.titles.push(content),
877 NatSpecKind::Author => data.authors.push(content),
878 NatSpecKind::Notice => {
879 data.notices.push(content.clone());
880 data.descriptions.push(Description { kind: DescKind::Notice, content });
881 last_section = Some(LastSection::Desc);
882 }
883 NatSpecKind::Dev => {
884 data.devs.push(content.clone());
885 data.descriptions.push(Description { kind: DescKind::Dev, content });
886 last_section = Some(LastSection::Desc);
887 }
888 NatSpecKind::Param { name } => {
889 data.params.push((name.as_str().to_string(), content));
890 last_section = Some(LastSection::Param);
891 }
892 NatSpecKind::Return { name } => {
893 data.returns.push((
894 name.map(|name| name.as_str().to_string()).unwrap_or_default(),
895 content,
896 ));
897 last_section = Some(LastSection::Return);
898 }
899 NatSpecKind::Inheritdoc { .. } => {} NatSpecKind::Custom { name } => {
901 let tag = name.as_str();
902 if FILTERED_CUSTOM.contains(&tag) {
903 } else if tag == "name" {
905 if let Some(first) = content.split_whitespace().next() {
907 data.unnamed_param_names.push(first.to_string());
908 }
909 } else {
910 if !KNOWN_CUSTOM.contains(&tag) && !is_known_custom_tag(tag) {
912 warn!("unknown natspec custom tag: @custom:{tag}");
913 }
914 data.customs.push((tag.to_string(), content));
915 }
916 }
917 NatSpecKind::Internal { .. } => {}
918 }
919 }
920 }
921
922 data
923}
924
925fn is_known_custom_tag(tag: &str) -> bool {
930 !tag.is_empty() && tag.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
931}
932
933fn has_local_natspec(docs: &DocComments<'_>) -> bool {
939 docs.iter().any(|doc| {
940 doc.natspec.iter().any(|item| !matches!(item.kind, NatSpecKind::Inheritdoc { .. }))
941 })
942}
943
944fn write_getter_table(
946 out: &mut String,
947 heading: &str,
948 fields: &[hir_ext::GetterField],
949 sanitize: &impl Fn(&str) -> String,
950) {
951 if fields.is_empty() {
952 return;
953 }
954 writeln!(out, "**{heading}**").unwrap();
955 writeln!(out).unwrap();
956 writeln!(out, "| Name | Type | Description |").unwrap();
957 writeln!(out, "| ---- | ---- | ----------- |").unwrap();
958 for field in fields {
959 let name = field
960 .name
961 .as_deref()
962 .map(escape_table_cell)
963 .unwrap_or_else(|| "<none>".to_string());
964 let ty = escape_table_cell(&field.ty);
965 let desc = escape_table_cell(&sanitize(&field.description));
966 writeln!(out, "| {name} | `{ty}` | {desc} |").unwrap();
967 }
968 writeln!(out).unwrap();
969}
970
971fn inheritdoc_base(docs: &DocComments<'_>) -> Option<String> {
972 for doc in docs.iter() {
973 for item in doc.natspec.iter() {
974 if let NatSpecKind::Inheritdoc { contract } = item.kind {
975 return Some(contract.as_str().to_string());
976 }
977 }
978 }
979 None
980}
981
982fn first_notice(data: &CommentData) -> Option<String> {
983 data.descriptions
984 .iter()
985 .find(|description| description.kind == DescKind::Notice)
986 .map(|description| description.content.clone())
987}
988
989fn write_frontmatter(out: &mut String, title: &str, description: Option<&str>) {
992 writeln!(out, "---").unwrap();
993 writeln!(out, "title: \"{}\"", yaml_escape_double_quoted(title)).unwrap();
994 if let Some(desc) = description {
995 let collapsed: String = desc.split_whitespace().collect::<Vec<_>>().join(" ");
997 writeln!(out, "description: \"{}\"", yaml_escape_double_quoted(&collapsed)).unwrap();
998 }
999 writeln!(out, "---").unwrap();
1000 writeln!(out).unwrap();
1001}
1002
1003fn yaml_escape_double_quoted(s: &str) -> String {
1009 let mut out = String::with_capacity(s.len());
1010 for c in s.chars() {
1011 match c {
1012 '\\' => out.push_str("\\\\"),
1013 '"' => out.push_str("\\\""),
1014 '\n' => out.push_str("\\n"),
1015 '\r' => out.push_str("\\r"),
1016 '\t' => out.push_str("\\t"),
1017 '\u{0}' => out.push_str("\\0"),
1018 c if (c as u32) < 0x20 => {
1019 out.push_str(&format!("\\x{:02x}", c as u32));
1020 }
1021 c => out.push(c),
1022 }
1023 }
1024 out
1025}
1026
1027fn italicize_dev(content: &str) -> String {
1031 let trimmed = content.trim_matches('\n');
1032 if trimmed.is_empty() { String::new() } else { format!("<i>\n\n{trimmed}\n\n</i>") }
1033}
1034
1035fn code_regions(text: &str) -> Vec<Range<usize>> {
1039 let Ok(tree) = to_mdast(text, &ParseOptions::mdx()) else { return Vec::new() };
1040 let mut regions = Vec::new();
1041 collect_code_regions(&tree, &mut regions);
1042 regions
1043}
1044
1045fn collect_code_regions(node: &Node, regions: &mut Vec<Range<usize>>) {
1047 if matches!(node, Node::Code(_) | Node::InlineCode(_))
1048 && let Some(position) = node.position()
1049 {
1050 regions.push(position.start.offset..position.end.offset);
1051 }
1052 if let Some(children) = node.children() {
1053 for child in children {
1054 collect_code_regions(child, regions);
1055 }
1056 }
1057}
1058
1059fn logical_lines(text: &str) -> impl Iterator<Item = (usize, &str)> {
1063 let bytes = text.as_bytes();
1064 let mut offset = 0;
1065 std::iter::from_fn(move || {
1066 if offset >= bytes.len() {
1067 return None;
1068 }
1069 let start = offset;
1070 let end = bytes[start..]
1071 .iter()
1072 .position(|&byte| byte == b'\n' || byte == b'\r')
1073 .map_or(bytes.len(), |position| start + position);
1074 offset = if end == bytes.len() {
1075 end
1076 } else if bytes[end] == b'\r' && bytes.get(end + 1) == Some(&b'\n') {
1077 end + 2
1078 } else {
1079 end + 1
1080 };
1081 Some((start, &text[start..end]))
1082 })
1083}
1084
1085fn region_contains(regions: &[Range<usize>], cursor: &mut usize, position: usize) -> bool {
1087 while regions.get(*cursor).is_some_and(|region| region.end <= position) {
1088 *cursor += 1;
1089 }
1090 regions.get(*cursor).is_some_and(|region| region.start <= position)
1091}
1092
1093fn neutralize_esm(text: &str) -> String {
1100 let regions = code_regions(text);
1101 let mut region_cursor = 0;
1102 let mut copied = 0;
1103 let mut out = String::with_capacity(text.len());
1104
1105 for (line_start, line) in logical_lines(text) {
1106 let replacement = if line.starts_with("import ") {
1107 Some(("im", 2))
1108 } else if line.starts_with("export ") {
1109 Some(("e", 1))
1110 } else {
1111 None
1112 };
1113 let Some((entity, prefix_len)) = replacement else { continue };
1114 if region_contains(®ions, &mut region_cursor, line_start) {
1115 continue;
1116 }
1117 out.push_str(&text[copied..line_start]);
1118 out.push_str(entity);
1119 copied = line_start + prefix_len;
1120 }
1121
1122 out.push_str(&text[copied..]);
1123 out
1124}
1125
1126fn write_comment_block(out: &mut String, data: &CommentData) {
1127 let mut block = String::new();
1128 if !data.titles.is_empty() {
1129 let label = if data.titles.len() == 1 { "Title" } else { "Titles" };
1130 writeln!(block, "**{label}:** {}", data.titles.join(", ")).unwrap();
1131 writeln!(block).unwrap();
1132 }
1133 if !data.authors.is_empty() {
1134 let label = if data.authors.len() == 1 { "Author" } else { "Authors" };
1135 writeln!(block, "**{label}:** {}", data.authors.join(", ")).unwrap();
1136 writeln!(block).unwrap();
1137 }
1138 for desc in &data.descriptions {
1142 match desc.kind {
1143 DescKind::Notice => writeln!(block, "{}", desc.content).unwrap(),
1144 DescKind::Dev => writeln!(block, "{}", italicize_dev(&desc.content)).unwrap(),
1145 }
1146 writeln!(block).unwrap();
1147 }
1148 if !data.customs.is_empty() {
1149 let label = if data.customs.len() == 1 { "Note" } else { "Notes" };
1150 writeln!(block, "**{label}:**").unwrap();
1151 writeln!(block).unwrap();
1152 for (tag, content) in &data.customs {
1153 writeln!(block, "- **{tag}:** {content}").unwrap();
1154 }
1155 writeln!(block).unwrap();
1156 }
1157 out.push_str(&neutralize_esm(&block));
1161}
1162
1163fn write_code_block(out: &mut String, snippet: &str) {
1164 writeln!(out, "```solidity").unwrap();
1165 writeln!(out, "{}", snippet.trim_end()).unwrap();
1166 writeln!(out, "```").unwrap();
1167 writeln!(out).unwrap();
1168}
1169
1170fn write_git_source(out: &mut String, git_url: Option<&str>) {
1172 if let Some(url) = git_url {
1173 writeln!(out, "[Git Source]({url})").unwrap();
1174 writeln!(out).unwrap();
1175 }
1176}
1177
1178fn escape_table_cell(s: &str) -> String {
1183 s.replace('\\', "\\\\")
1184 .replace('|', "\\|")
1185 .replace("\r\n", "<br/>")
1186 .replace(['\n', '\r'], "<br/>")
1187}
1188
1189fn write_deployments_table(out: &mut String, deployments: &[Deployment]) {
1191 if deployments.is_empty() {
1192 return;
1193 }
1194 writeln!(out, "**Deployments**").unwrap();
1195 writeln!(out).unwrap();
1196 writeln!(out, "| Network | Address |").unwrap();
1197 writeln!(out, "| ------- | ------- |").unwrap();
1198 for d in deployments {
1199 let network = escape_table_cell(d.network.as_deref().unwrap_or("-"));
1200 writeln!(out, "| {network} | `{:#x}` |", d.address).unwrap();
1201 }
1202 writeln!(out).unwrap();
1203}
1204
1205fn write_param_table(
1206 out: &mut String,
1207 heading: &str,
1208 params: &ParameterList<'_>,
1209 comments: &CommentData,
1210 inherited_params: Option<&[String]>,
1211 ctx: &Ctx<'_>,
1212) {
1213 if params.is_empty() {
1214 return;
1215 }
1216 writeln!(out, "**{heading}**").unwrap();
1217 writeln!(out).unwrap();
1218 writeln!(out, "| Name | Type | Description |").unwrap();
1219 writeln!(out, "| ---- | ---- | ----------- |").unwrap();
1220 let is_return = heading == "Returns";
1221 let mut unnamed_iter = comments.unnamed_param_names.iter();
1224 for (index, var) in params.iter().enumerate() {
1225 let name = match var.name {
1226 Some(n) => n.as_str().to_string(),
1227 None if !is_return => unnamed_iter.next().cloned().unwrap_or_else(|| "_".to_string()),
1228 None => "<none>".to_string(),
1229 };
1230 let ty = format!("`{}`", ctx.snippet(var.ty.span).trim());
1231 let desc = if is_return {
1232 return_description(comments, index, var.name.map(|_| name.as_str()))
1233 } else {
1234 let named = comments.params.iter().find(|(n, _)| n == &name).map(|(_, d)| d.as_str());
1235 if var.name.is_none() {
1236 inherited_params
1237 .and_then(|params| params.get(index))
1238 .map(String::as_str)
1239 .or(named)
1240 .unwrap_or("")
1241 } else {
1242 named.unwrap_or("")
1243 }
1244 };
1245 let name = escape_table_cell(&name);
1246 let desc = escape_table_cell(desc);
1247 writeln!(out, "| {name} | {ty} | {desc} |").unwrap();
1248 }
1249 writeln!(out).unwrap();
1250}
1251
1252fn return_description<'a>(
1253 comments: &'a CommentData,
1254 index: usize,
1255 return_name: Option<&str>,
1256) -> &'a str {
1257 if let Some(return_name) = return_name
1258 && let Some((_, desc)) = comments.returns.iter().find(|(n, _)| n == return_name)
1259 {
1260 return desc;
1261 }
1262
1263 let Some((doc_name, desc)) = comments.returns.get(index) else {
1264 return "";
1265 };
1266
1267 if !doc_name.is_empty() {
1268 return desc;
1269 }
1270
1271 match return_name {
1272 Some(return_name) => desc.strip_prefix(return_name).and_then(strip_one_ws).unwrap_or(desc),
1273 None => desc,
1274 }
1275}
1276
1277fn strip_one_ws(s: &str) -> Option<&str> {
1278 let mut chars = s.char_indices();
1279 let (_, first) = chars.next()?;
1280 first.is_whitespace().then(|| chars.next().map(|(idx, _)| &s[idx..]).unwrap_or(""))
1281}
1282
1283fn write_struct_properties_table(
1284 out: &mut String,
1285 fields: &[VariableDefinition<'_>],
1286 comments: &CommentData,
1287 ctx: &Ctx<'_>,
1288) {
1289 if fields.is_empty() {
1290 return;
1291 }
1292 writeln!(out, "**Properties**").unwrap();
1293 writeln!(out).unwrap();
1294 writeln!(out, "| Name | Type | Description |").unwrap();
1295 writeln!(out, "| ---- | ---- | ----------- |").unwrap();
1296 for field in fields {
1297 let name = field.name.map(|n| n.as_str().to_string()).unwrap_or_else(|| "_".to_string());
1298 let ty = format!("`{}`", ctx.snippet(field.ty.span).trim());
1299 let desc =
1300 comments.params.iter().find(|(n, _)| n == &name).map(|(_, d)| d.as_str()).unwrap_or("");
1301 let name = escape_table_cell(&name);
1302 let desc = escape_table_cell(desc);
1303 writeln!(out, "| {name} | {ty} | {desc} |").unwrap();
1304 }
1305 writeln!(out).unwrap();
1306}
1307
1308fn write_enum_variants_table(out: &mut String, variants: &[Ident], comments: &CommentData) {
1309 if variants.is_empty() {
1310 return;
1311 }
1312 writeln!(out, "**Variants**").unwrap();
1313 writeln!(out).unwrap();
1314 writeln!(out, "| Name | Description |").unwrap();
1315 writeln!(out, "| ---- | ----------- |").unwrap();
1316 for variant in variants {
1317 let name = variant.as_str();
1318 let desc =
1319 comments.params.iter().find(|(n, _)| n == name).map(|(_, d)| d.as_str()).unwrap_or("");
1320 let name = escape_table_cell(name);
1321 let desc = escape_table_cell(desc);
1322 writeln!(out, "| {name} | {desc} |").unwrap();
1323 }
1324 writeln!(out).unwrap();
1325}
1326
1327const fn contract_kind_str(kind: ContractKind) -> &'static str {
1328 match kind {
1329 ContractKind::Contract => "contract",
1330 ContractKind::AbstractContract => "abstract",
1331 ContractKind::Interface => "interface",
1332 ContractKind::Library => "library",
1333 }
1334}
1335
1336fn find_contract_id<'gcx>(
1340 gcx: Gcx<'gcx>,
1341 name: &str,
1342 abs_sol_path: &Path,
1343) -> Option<hir::ContractId> {
1344 gcx.hir.contract_ids().find(|&id| {
1345 let c = gcx.hir.contract(id);
1346 if c.name.as_str() != name {
1347 return false;
1348 }
1349 match &gcx.hir.source(c.source).file.name {
1350 FileName::Real(p) => p == abs_sol_path,
1351 _ => false,
1352 }
1353 })
1354}
1355
1356fn dedent(s: &str) -> String {
1358 let lines: Vec<&str> = s.lines().collect();
1359 if lines.is_empty() {
1360 return s.to_string();
1361 }
1362 let indent = lines
1363 .iter()
1364 .filter(|l| !l.trim().is_empty())
1365 .map(|l| l.len() - l.trim_start().len())
1366 .min()
1367 .unwrap_or(0);
1368 lines
1369 .iter()
1370 .map(|l| if l.len() >= indent { &l[indent..] } else { l.trim() })
1371 .collect::<Vec<_>>()
1372 .join("\n")
1373}
1374
1375#[cfg(test)]
1376mod tests {
1377 use super::neutralize_esm;
1378 use markdown::{MdxSignal, ParseOptions, mdast::Node, to_mdast};
1379
1380 fn parse_mdx(text: &str) -> Node {
1381 let mut options = ParseOptions::mdx();
1382 options.mdx_esm_parse = Some(Box::new(|_| MdxSignal::Ok));
1383 to_mdast(text, &options).unwrap()
1384 }
1385
1386 fn contains_mdx_esm(node: &Node) -> bool {
1387 matches!(node, Node::MdxjsEsm(_))
1388 || node.children().is_some_and(|children| children.iter().any(contains_mdx_esm))
1389 }
1390
1391 #[test]
1392 fn preserves_line_endings_while_neutralizing_esm() {
1393 assert_eq!(
1394 neutralize_esm("Intro.\r\rexport const afterCarriageReturn = 1"),
1395 "Intro.\r\rexport const afterCarriageReturn = 1"
1396 );
1397 for (input, expected) in [
1398 (
1399 "```\rimport inside\r```\rexport outside",
1400 "```\rimport inside\r```\rexport outside",
1401 ),
1402 (
1403 "```\r\nimport inside\r\n```\r\nexport outside",
1404 "```\r\nimport inside\r\n```\r\nexport outside",
1405 ),
1406 (
1407 "`example:\rimport inside`\rexport outside",
1408 "`example:\rimport inside`\rexport outside",
1409 ),
1410 (
1411 "`example:\r\nimport inside`\r\nexport outside",
1412 "`example:\r\nimport inside`\r\nexport outside",
1413 ),
1414 (
1415 " ```\r import inside\r ```\rexport outside",
1416 " ```\r import inside\r ```\rexport outside",
1417 ),
1418 ("```\rinside\r ```\rexport outside", "```\rinside\r ```\rexport outside"),
1419 ] {
1420 assert_eq!(neutralize_esm(input), expected);
1421 }
1422 assert_eq!(
1423 neutralize_esm("Intro.\r\nimport outside"),
1424 "Intro.\r\nimport outside"
1425 );
1426 assert_eq!(
1427 neutralize_esm("export first\r\npréface `span:\rimport inside`\r\nimport last"),
1428 "export first\r\npréface `span:\rimport inside`\r\nimport last"
1429 );
1430 }
1431
1432 #[test]
1433 fn traverses_sorted_code_regions() {
1434 let input = "`first`\n ```\n import inside fence\n ```\n`last`\nexport outside";
1435 let expected =
1436 "`first`\n ```\n import inside fence\n ```\n`last`\nexport outside";
1437 assert_eq!(neutralize_esm(input), expected);
1438 }
1439
1440 #[test]
1441 fn preserves_code_across_mdx_edge_cases() {
1442 for (input, expected) in [
1443 (
1444 "```\nimport inside\n```\nexport outside",
1445 "```\nimport inside\n```\nexport outside",
1446 ),
1447 (
1448 "```\n~~~\nimport inside\n```\nexport outside",
1449 "```\n~~~\nimport inside\n```\nexport outside",
1450 ),
1451 (
1452 "````\n```\nimport inside\n```\n````\nexport outside",
1453 "````\n```\nimport inside\n```\n````\nexport outside",
1454 ),
1455 (
1456 "```\nimport inside\n```suffix\nexport inside\n```\nimport outside",
1457 "```\nimport inside\n```suffix\nexport inside\n```\nimport outside",
1458 ),
1459 (
1460 "`example:\nimport inside`\nexport outside",
1461 "`example:\nimport inside`\nexport outside",
1462 ),
1463 (
1464 "```\ninside\n ```\n```\nimport inside second\n```\nexport outside",
1465 "```\ninside\n ```\n```\nimport inside second\n```\nexport outside",
1466 ),
1467 (
1468 "- ```\n import inside list\n ```\n\nexport outside",
1469 "- ```\n import inside list\n ```\n\nexport outside",
1470 ),
1471 (
1472 " ```\n import inside indented\n ```\nexport outside",
1473 " ```\n import inside indented\n ```\nexport outside",
1474 ),
1475 (" ```\n import inside unclosed", " ```\n import inside unclosed"),
1476 ] {
1477 assert_eq!(neutralize_esm(input), expected, "input:\n{input}");
1478 }
1479 }
1480
1481 #[test]
1482 fn neutralized_output_contains_no_mdx_esm() {
1483 let input = "import injected from \"x\"\n\n```js\nexport const example = 1\n```";
1484 assert!(contains_mdx_esm(&parse_mdx(input)));
1485
1486 let output = neutralize_esm(input);
1487 assert_eq!(
1488 output,
1489 "import injected from \"x\"\n\n```js\nexport const example = 1\n```"
1490 );
1491
1492 let tree = parse_mdx(&output);
1493 assert!(!contains_mdx_esm(&tree), "{tree:#?}");
1494 assert!(tree.children().is_some_and(|children| {
1495 children.iter().any(
1496 |node| matches!(node, Node::Code(code) if code.value == "export const example = 1"),
1497 )
1498 }));
1499 }
1500
1501 #[test]
1502 fn leaves_non_esm_prefixes_unchanged() {
1503 let input = "important\nexporter\nimport_\nexport$\nImport value\nExport value\n import value\n\t export value\nimport(value)\nexport: value\nimport\tvalue";
1504 assert_eq!(neutralize_esm(input), input);
1505 }
1506
1507 #[test]
1508 fn neutralizes_only_exact_mdx_esm_prefixes() {
1509 assert_eq!(
1510 neutralize_esm("import value\nexport value\nimport value\nexport value"),
1511 "import value\nexport value\nimport value\nexport value"
1512 );
1513 }
1514
1515 #[test]
1516 fn neutralizes_many_candidates_across_many_code_regions() {
1517 let input = "`code`\nexport outside\n".repeat(10_000);
1518 let output = neutralize_esm(&input);
1519 assert_eq!(output.matches("`code`").count(), 10_000);
1520 assert_eq!(output.matches("export outside").count(), 10_000);
1521 }
1522}