1use std::fmt::Write;
2use std::path::Path;
3
4use fallow_core::duplicates::DuplicationReport;
5use fallow_core::results::{AnalysisResults, UnusedExport, UnusedMember};
6
7use super::{normalize_uri, relative_path};
8
9fn escape_backticks(s: &str) -> String {
11 s.replace('`', "\\`")
12}
13
14pub(super) fn print_markdown(results: &AnalysisResults, root: &Path) {
15 println!("{}", build_markdown(results, root));
16}
17
18pub fn build_markdown(results: &AnalysisResults, root: &Path) -> String {
20 let rel = |p: &Path| {
21 escape_backticks(&normalize_uri(
22 &relative_path(p, root).display().to_string(),
23 ))
24 };
25
26 let total = results.total_issues();
27 let mut out = String::new();
28
29 if total == 0 {
30 out.push_str("## Fallow: no issues found\n");
31 return out;
32 }
33
34 let _ = write!(
35 out,
36 "## Fallow: {total} issue{} found\n\n",
37 if total == 1 { "" } else { "s" }
38 );
39
40 markdown_section(&mut out, &results.unused_files, "Unused files", |file| {
42 vec![format!("- `{}`", rel(&file.path))]
43 });
44
45 markdown_grouped_section(
47 &mut out,
48 &results.unused_exports,
49 "Unused exports",
50 root,
51 |e| e.path.as_path(),
52 format_export,
53 );
54
55 markdown_grouped_section(
57 &mut out,
58 &results.unused_types,
59 "Unused type exports",
60 root,
61 |e| e.path.as_path(),
62 format_export,
63 );
64
65 markdown_section(
67 &mut out,
68 &results.unused_dependencies,
69 "Unused dependencies",
70 |dep| {
71 let name = escape_backticks(&dep.package_name);
72 let pkg_label = relative_path(&dep.path, root).display().to_string();
73 if pkg_label == "package.json" {
74 vec![format!("- `{name}`")]
75 } else {
76 let label = escape_backticks(&pkg_label);
77 vec![format!("- `{name}` ({label})")]
78 }
79 },
80 );
81
82 markdown_section(
84 &mut out,
85 &results.unused_dev_dependencies,
86 "Unused devDependencies",
87 |dep| {
88 let name = escape_backticks(&dep.package_name);
89 let pkg_label = relative_path(&dep.path, root).display().to_string();
90 if pkg_label == "package.json" {
91 vec![format!("- `{name}`")]
92 } else {
93 let label = escape_backticks(&pkg_label);
94 vec![format!("- `{name}` ({label})")]
95 }
96 },
97 );
98
99 markdown_grouped_section(
101 &mut out,
102 &results.unused_enum_members,
103 "Unused enum members",
104 root,
105 |m| m.path.as_path(),
106 format_member,
107 );
108
109 markdown_grouped_section(
111 &mut out,
112 &results.unused_class_members,
113 "Unused class members",
114 root,
115 |m| m.path.as_path(),
116 format_member,
117 );
118
119 markdown_grouped_section(
121 &mut out,
122 &results.unresolved_imports,
123 "Unresolved imports",
124 root,
125 |i| i.path.as_path(),
126 |i| format!(":{} `{}`", i.line, escape_backticks(&i.specifier)),
127 );
128
129 markdown_section(
131 &mut out,
132 &results.unlisted_dependencies,
133 "Unlisted dependencies",
134 |dep| vec![format!("- `{}`", escape_backticks(&dep.package_name))],
135 );
136
137 markdown_section(
139 &mut out,
140 &results.duplicate_exports,
141 "Duplicate exports",
142 |dup| {
143 let locations: Vec<String> = dup
144 .locations
145 .iter()
146 .map(|p| format!("`{}`", rel(p)))
147 .collect();
148 vec![format!(
149 "- `{}` in {}",
150 escape_backticks(&dup.export_name),
151 locations.join(", ")
152 )]
153 },
154 );
155
156 markdown_section(
158 &mut out,
159 &results.type_only_dependencies,
160 "Type-only dependencies (consider moving to devDependencies)",
161 |dep| {
162 let name = escape_backticks(&dep.package_name);
163 let pkg_label = relative_path(&dep.path, root).display().to_string();
164 if pkg_label == "package.json" {
165 vec![format!("- `{name}`")]
166 } else {
167 let label = escape_backticks(&pkg_label);
168 vec![format!("- `{name}` ({label})")]
169 }
170 },
171 );
172
173 markdown_section(
175 &mut out,
176 &results.circular_dependencies,
177 "Circular dependencies",
178 |cycle| {
179 let chain: Vec<String> = cycle.files.iter().map(|p| rel(p)).collect();
180 let mut display_chain = chain.clone();
181 if let Some(first) = chain.first() {
182 display_chain.push(first.clone());
183 }
184 vec![format!(
185 "- {}",
186 display_chain
187 .iter()
188 .map(|s| format!("`{s}`"))
189 .collect::<Vec<_>>()
190 .join(" \u{2192} ")
191 )]
192 },
193 );
194
195 out
196}
197
198fn format_export(e: &UnusedExport) -> String {
199 let re = if e.is_re_export { " (re-export)" } else { "" };
200 format!(":{} `{}`{re}", e.line, escape_backticks(&e.export_name))
201}
202
203fn format_member(m: &UnusedMember) -> String {
204 format!(
205 ":{} `{}.{}`",
206 m.line,
207 escape_backticks(&m.parent_name),
208 escape_backticks(&m.member_name)
209 )
210}
211
212fn markdown_section<T>(
214 out: &mut String,
215 items: &[T],
216 title: &str,
217 format_lines: impl Fn(&T) -> Vec<String>,
218) {
219 if items.is_empty() {
220 return;
221 }
222 let _ = write!(out, "### {title} ({})\n\n", items.len());
223 for item in items {
224 for line in format_lines(item) {
225 out.push_str(&line);
226 out.push('\n');
227 }
228 }
229 out.push('\n');
230}
231
232fn markdown_grouped_section<'a, T>(
234 out: &mut String,
235 items: &'a [T],
236 title: &str,
237 root: &Path,
238 get_path: impl Fn(&'a T) -> &'a Path,
239 format_detail: impl Fn(&T) -> String,
240) {
241 if items.is_empty() {
242 return;
243 }
244 let _ = write!(out, "### {title} ({})\n\n", items.len());
245
246 let mut indices: Vec<usize> = (0..items.len()).collect();
247 indices.sort_by(|&a, &b| get_path(&items[a]).cmp(get_path(&items[b])));
248
249 let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
250 let mut last_file = String::new();
251 for &i in &indices {
252 let item = &items[i];
253 let file_str = rel(get_path(item));
254 if file_str != last_file {
255 let _ = writeln!(out, "- `{file_str}`");
256 last_file = file_str;
257 }
258 let _ = writeln!(out, " - {}", format_detail(item));
259 }
260 out.push('\n');
261}
262
263pub(super) fn print_duplication_markdown(report: &DuplicationReport, root: &Path) {
266 println!("{}", build_duplication_markdown(report, root));
267}
268
269pub fn build_duplication_markdown(report: &DuplicationReport, root: &Path) -> String {
271 let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
272
273 let mut out = String::new();
274
275 if report.clone_groups.is_empty() {
276 out.push_str("## Fallow: no code duplication found\n");
277 return out;
278 }
279
280 let stats = &report.stats;
281 let _ = write!(
282 out,
283 "## Fallow: {} clone group{} found ({:.1}% duplication)\n\n",
284 stats.clone_groups,
285 if stats.clone_groups == 1 { "" } else { "s" },
286 stats.duplication_percentage,
287 );
288
289 out.push_str("### Duplicates\n\n");
290 for (i, group) in report.clone_groups.iter().enumerate() {
291 let instance_count = group.instances.len();
292 let _ = write!(
293 out,
294 "**Clone group {}** ({} lines, {instance_count} instance{})\n\n",
295 i + 1,
296 group.line_count,
297 if instance_count == 1 { "" } else { "s" }
298 );
299 for instance in &group.instances {
300 let relative = rel(&instance.file);
301 let _ = writeln!(
302 out,
303 "- `{relative}:{}-{}`",
304 instance.start_line, instance.end_line
305 );
306 }
307 out.push('\n');
308 }
309
310 if !report.clone_families.is_empty() {
312 out.push_str("### Clone Families\n\n");
313 for (i, family) in report.clone_families.iter().enumerate() {
314 let file_names: Vec<_> = family.files.iter().map(|f| rel(f)).collect();
315 let _ = write!(
316 out,
317 "**Family {}** ({} group{}, {} lines across {})\n\n",
318 i + 1,
319 family.groups.len(),
320 if family.groups.len() == 1 { "" } else { "s" },
321 family.total_duplicated_lines,
322 file_names
323 .iter()
324 .map(|s| format!("`{s}`"))
325 .collect::<Vec<_>>()
326 .join(", "),
327 );
328 for suggestion in &family.suggestions {
329 let savings = if suggestion.estimated_savings > 0 {
330 format!(" (~{} lines saved)", suggestion.estimated_savings)
331 } else {
332 String::new()
333 };
334 let _ = writeln!(out, "- {}{savings}", suggestion.description);
335 }
336 out.push('\n');
337 }
338 }
339
340 let _ = writeln!(
342 out,
343 "**Summary:** {} duplicated lines ({:.1}%) across {} file{}",
344 stats.duplicated_lines,
345 stats.duplication_percentage,
346 stats.files_with_clones,
347 if stats.files_with_clones == 1 {
348 ""
349 } else {
350 "s"
351 },
352 );
353
354 out
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use fallow_core::duplicates::{
361 CloneFamily, CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
362 RefactoringKind, RefactoringSuggestion,
363 };
364 use fallow_core::extract::MemberKind;
365 use fallow_core::results::*;
366 use std::path::PathBuf;
367
368 fn sample_results(root: &Path) -> AnalysisResults {
370 let mut r = AnalysisResults::default();
371
372 r.unused_files.push(UnusedFile {
373 path: root.join("src/dead.ts"),
374 });
375 r.unused_exports.push(UnusedExport {
376 path: root.join("src/utils.ts"),
377 export_name: "helperFn".to_string(),
378 is_type_only: false,
379 line: 10,
380 col: 4,
381 span_start: 120,
382 is_re_export: false,
383 });
384 r.unused_types.push(UnusedExport {
385 path: root.join("src/types.ts"),
386 export_name: "OldType".to_string(),
387 is_type_only: true,
388 line: 5,
389 col: 0,
390 span_start: 60,
391 is_re_export: false,
392 });
393 r.unused_dependencies.push(UnusedDependency {
394 package_name: "lodash".to_string(),
395 location: DependencyLocation::Dependencies,
396 path: root.join("package.json"),
397 });
398 r.unused_dev_dependencies.push(UnusedDependency {
399 package_name: "jest".to_string(),
400 location: DependencyLocation::DevDependencies,
401 path: root.join("package.json"),
402 });
403 r.unused_enum_members.push(UnusedMember {
404 path: root.join("src/enums.ts"),
405 parent_name: "Status".to_string(),
406 member_name: "Deprecated".to_string(),
407 kind: MemberKind::EnumMember,
408 line: 8,
409 col: 2,
410 });
411 r.unused_class_members.push(UnusedMember {
412 path: root.join("src/service.ts"),
413 parent_name: "UserService".to_string(),
414 member_name: "legacyMethod".to_string(),
415 kind: MemberKind::ClassMethod,
416 line: 42,
417 col: 4,
418 });
419 r.unresolved_imports.push(UnresolvedImport {
420 path: root.join("src/app.ts"),
421 specifier: "./missing-module".to_string(),
422 line: 3,
423 col: 0,
424 });
425 r.unlisted_dependencies.push(UnlistedDependency {
426 package_name: "chalk".to_string(),
427 imported_from: vec![root.join("src/cli.ts")],
428 });
429 r.duplicate_exports.push(DuplicateExport {
430 export_name: "Config".to_string(),
431 locations: vec![root.join("src/config.ts"), root.join("src/types.ts")],
432 });
433 r.type_only_dependencies.push(TypeOnlyDependency {
434 package_name: "zod".to_string(),
435 path: root.join("package.json"),
436 });
437 r.circular_dependencies.push(CircularDependency {
438 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
439 length: 2,
440 });
441
442 r
443 }
444
445 #[test]
446 fn markdown_empty_results_no_issues() {
447 let root = PathBuf::from("/project");
448 let results = AnalysisResults::default();
449 let md = build_markdown(&results, &root);
450 assert_eq!(md, "## Fallow: no issues found\n");
451 }
452
453 #[test]
454 fn markdown_contains_header_with_count() {
455 let root = PathBuf::from("/project");
456 let results = sample_results(&root);
457 let md = build_markdown(&results, &root);
458 assert!(md.starts_with(&format!(
459 "## Fallow: {} issues found\n",
460 results.total_issues()
461 )));
462 }
463
464 #[test]
465 fn markdown_contains_all_sections() {
466 let root = PathBuf::from("/project");
467 let results = sample_results(&root);
468 let md = build_markdown(&results, &root);
469
470 assert!(md.contains("### Unused files (1)"));
471 assert!(md.contains("### Unused exports (1)"));
472 assert!(md.contains("### Unused type exports (1)"));
473 assert!(md.contains("### Unused dependencies (1)"));
474 assert!(md.contains("### Unused devDependencies (1)"));
475 assert!(md.contains("### Unused enum members (1)"));
476 assert!(md.contains("### Unused class members (1)"));
477 assert!(md.contains("### Unresolved imports (1)"));
478 assert!(md.contains("### Unlisted dependencies (1)"));
479 assert!(md.contains("### Duplicate exports (1)"));
480 assert!(md.contains("### Type-only dependencies"));
481 assert!(md.contains("### Circular dependencies (1)"));
482 }
483
484 #[test]
485 fn markdown_unused_file_format() {
486 let root = PathBuf::from("/project");
487 let mut results = AnalysisResults::default();
488 results.unused_files.push(UnusedFile {
489 path: root.join("src/dead.ts"),
490 });
491 let md = build_markdown(&results, &root);
492 assert!(md.contains("- `src/dead.ts`"));
493 }
494
495 #[test]
496 fn markdown_unused_export_grouped_by_file() {
497 let root = PathBuf::from("/project");
498 let mut results = AnalysisResults::default();
499 results.unused_exports.push(UnusedExport {
500 path: root.join("src/utils.ts"),
501 export_name: "helperFn".to_string(),
502 is_type_only: false,
503 line: 10,
504 col: 4,
505 span_start: 120,
506 is_re_export: false,
507 });
508 let md = build_markdown(&results, &root);
509 assert!(md.contains("- `src/utils.ts`"));
510 assert!(md.contains(":10 `helperFn`"));
511 }
512
513 #[test]
514 fn markdown_re_export_tagged() {
515 let root = PathBuf::from("/project");
516 let mut results = AnalysisResults::default();
517 results.unused_exports.push(UnusedExport {
518 path: root.join("src/index.ts"),
519 export_name: "reExported".to_string(),
520 is_type_only: false,
521 line: 1,
522 col: 0,
523 span_start: 0,
524 is_re_export: true,
525 });
526 let md = build_markdown(&results, &root);
527 assert!(md.contains("(re-export)"));
528 }
529
530 #[test]
531 fn markdown_unused_dep_format() {
532 let root = PathBuf::from("/project");
533 let mut results = AnalysisResults::default();
534 results.unused_dependencies.push(UnusedDependency {
535 package_name: "lodash".to_string(),
536 location: DependencyLocation::Dependencies,
537 path: root.join("package.json"),
538 });
539 let md = build_markdown(&results, &root);
540 assert!(md.contains("- `lodash`"));
541 }
542
543 #[test]
544 fn markdown_circular_dep_format() {
545 let root = PathBuf::from("/project");
546 let mut results = AnalysisResults::default();
547 results.circular_dependencies.push(CircularDependency {
548 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
549 length: 2,
550 });
551 let md = build_markdown(&results, &root);
552 assert!(md.contains("`src/a.ts`"));
553 assert!(md.contains("`src/b.ts`"));
554 assert!(md.contains("\u{2192}"));
555 }
556
557 #[test]
558 fn markdown_strips_root_prefix() {
559 let root = PathBuf::from("/project");
560 let mut results = AnalysisResults::default();
561 results.unused_files.push(UnusedFile {
562 path: PathBuf::from("/project/src/deep/nested/file.ts"),
563 });
564 let md = build_markdown(&results, &root);
565 assert!(md.contains("`src/deep/nested/file.ts`"));
566 assert!(!md.contains("/project/"));
567 }
568
569 #[test]
570 fn markdown_single_issue_no_plural() {
571 let root = PathBuf::from("/project");
572 let mut results = AnalysisResults::default();
573 results.unused_files.push(UnusedFile {
574 path: root.join("src/dead.ts"),
575 });
576 let md = build_markdown(&results, &root);
577 assert!(md.starts_with("## Fallow: 1 issue found\n"));
578 }
579
580 #[test]
581 fn markdown_type_only_dep_format() {
582 let root = PathBuf::from("/project");
583 let mut results = AnalysisResults::default();
584 results.type_only_dependencies.push(TypeOnlyDependency {
585 package_name: "zod".to_string(),
586 path: root.join("package.json"),
587 });
588 let md = build_markdown(&results, &root);
589 assert!(md.contains("### Type-only dependencies"));
590 assert!(md.contains("- `zod`"));
591 }
592
593 #[test]
594 fn markdown_escapes_backticks_in_export_names() {
595 let root = PathBuf::from("/project");
596 let mut results = AnalysisResults::default();
597 results.unused_exports.push(UnusedExport {
598 path: root.join("src/utils.ts"),
599 export_name: "foo`bar".to_string(),
600 is_type_only: false,
601 line: 1,
602 col: 0,
603 span_start: 0,
604 is_re_export: false,
605 });
606 let md = build_markdown(&results, &root);
607 assert!(md.contains("foo\\`bar"));
608 assert!(!md.contains("foo`bar`"));
609 }
610
611 #[test]
612 fn markdown_escapes_backticks_in_package_names() {
613 let root = PathBuf::from("/project");
614 let mut results = AnalysisResults::default();
615 results.unused_dependencies.push(UnusedDependency {
616 package_name: "pkg`name".to_string(),
617 location: DependencyLocation::Dependencies,
618 path: root.join("package.json"),
619 });
620 let md = build_markdown(&results, &root);
621 assert!(md.contains("pkg\\`name"));
622 }
623
624 #[test]
627 fn duplication_markdown_empty() {
628 let report = DuplicationReport::default();
629 let root = PathBuf::from("/project");
630 let md = build_duplication_markdown(&report, &root);
631 assert_eq!(md, "## Fallow: no code duplication found\n");
632 }
633
634 #[test]
635 fn duplication_markdown_contains_groups() {
636 let root = PathBuf::from("/project");
637 let report = DuplicationReport {
638 clone_groups: vec![CloneGroup {
639 instances: vec![
640 CloneInstance {
641 file: root.join("src/a.ts"),
642 start_line: 1,
643 end_line: 10,
644 start_col: 0,
645 end_col: 0,
646 fragment: String::new(),
647 },
648 CloneInstance {
649 file: root.join("src/b.ts"),
650 start_line: 5,
651 end_line: 14,
652 start_col: 0,
653 end_col: 0,
654 fragment: String::new(),
655 },
656 ],
657 token_count: 50,
658 line_count: 10,
659 }],
660 clone_families: vec![],
661 stats: DuplicationStats {
662 total_files: 10,
663 files_with_clones: 2,
664 total_lines: 500,
665 duplicated_lines: 20,
666 total_tokens: 2500,
667 duplicated_tokens: 100,
668 clone_groups: 1,
669 clone_instances: 2,
670 duplication_percentage: 4.0,
671 },
672 };
673 let md = build_duplication_markdown(&report, &root);
674 assert!(md.contains("**Clone group 1**"));
675 assert!(md.contains("`src/a.ts:1-10`"));
676 assert!(md.contains("`src/b.ts:5-14`"));
677 assert!(md.contains("4.0% duplication"));
678 }
679
680 #[test]
681 fn duplication_markdown_contains_families() {
682 let root = PathBuf::from("/project");
683 let report = DuplicationReport {
684 clone_groups: vec![CloneGroup {
685 instances: vec![CloneInstance {
686 file: root.join("src/a.ts"),
687 start_line: 1,
688 end_line: 5,
689 start_col: 0,
690 end_col: 0,
691 fragment: String::new(),
692 }],
693 token_count: 30,
694 line_count: 5,
695 }],
696 clone_families: vec![CloneFamily {
697 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
698 groups: vec![],
699 total_duplicated_lines: 20,
700 total_duplicated_tokens: 100,
701 suggestions: vec![RefactoringSuggestion {
702 kind: RefactoringKind::ExtractFunction,
703 description: "Extract shared utility function".to_string(),
704 estimated_savings: 15,
705 }],
706 }],
707 stats: DuplicationStats {
708 clone_groups: 1,
709 clone_instances: 1,
710 duplication_percentage: 2.0,
711 ..Default::default()
712 },
713 };
714 let md = build_duplication_markdown(&report, &root);
715 assert!(md.contains("### Clone Families"));
716 assert!(md.contains("**Family 1**"));
717 assert!(md.contains("Extract shared utility function"));
718 assert!(md.contains("~15 lines saved"));
719 }
720}