1use crate::health::HealthScore;
2use crate::{Finding, Severity};
3use std::collections::BTreeMap;
4use std::fmt::Write;
5
6pub fn render_html(
8 findings: &[Finding],
9 scores: &[HealthScore],
10 file_contents: &[(String, String)],
11) -> String {
12 let mut html = String::with_capacity(32_000);
13 let _ = write!(
14 html,
15 "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">\
16 <title>Cha Report</title><style>{CSS}</style></head><body>"
17 );
18 render_summary(&mut html, findings, scores);
19 render_scores_table(&mut html, scores);
20 let contents: BTreeMap<&str, &str> = file_contents
21 .iter()
22 .map(|(p, c)| (p.as_str(), c.as_str()))
23 .collect();
24 render_findings_section(&mut html, findings, &contents);
25 let _ = write!(html, "</body></html>");
26 html
27}
28
29fn render_summary(html: &mut String, findings: &[Finding], scores: &[HealthScore]) {
30 let (errors, warnings, hints) = count_severities(findings);
31 let total_debt: u32 = scores.iter().map(|s| s.debt_minutes).sum();
32 let _ = write!(
33 html,
34 "<header><h1>察 Cha Report</h1>\
35 <div class=\"summary\">\
36 <span class=\"badge error\">{errors} error</span>\
37 <span class=\"badge warning\">{warnings} warning</span>\
38 <span class=\"badge hint\">{hints} hint</span>\
39 <span class=\"badge debt\">~{} debt</span>\
40 </div></header>",
41 format_duration(total_debt)
42 );
43}
44
45fn render_scores_table(html: &mut String, scores: &[HealthScore]) {
46 let _ = write!(
47 html,
48 "<section><h2>Health Scores</h2><table><tr>\
49 <th>Grade</th><th>File</th><th>Debt</th><th>Lines</th></tr>"
50 );
51 for s in scores {
52 let _ = write!(
53 html,
54 "<tr class=\"grade-{g}\"><td class=\"grade\">{g}</td>\
55 <td><a href=\"#f-{id}\">{path}</a></td>\
56 <td>~{d}min</td><td>{l}</td></tr>",
57 g = s.grade,
58 id = path_id(&s.path),
59 path = esc(&s.path),
60 d = s.debt_minutes,
61 l = s.lines,
62 );
63 }
64 let _ = write!(html, "</table></section>");
65}
66
67fn render_findings_section(
68 html: &mut String,
69 findings: &[Finding],
70 contents: &BTreeMap<&str, &str>,
71) {
72 let grouped = group_by_file(findings);
73 let _ = write!(html, "<section><h2>Findings</h2>");
74 for (path, file_findings) in &grouped {
75 let _ = write!(
76 html,
77 "<details id=\"f-{id}\"><summary><strong>{path}</strong> \
78 <span class=\"count\">({n})</span></summary>",
79 id = path_id(path),
80 path = esc(path),
81 n = file_findings.len(),
82 );
83 if let Some(src) = contents.get(path.as_str()) {
84 render_source_block(html, path, src, file_findings);
85 }
86 for f in file_findings {
87 let _ = write!(
88 html,
89 "<div class=\"finding {sev}\"><span class=\"sev\">{icon}</span> \
90 <a href=\"#{id}-L{line}\">[{name}] L{start}-{end}</a> {msg}</div>",
91 sev = sev_class(f.severity),
92 icon = sev_icon(f.severity),
93 id = path_id(path),
94 line = f.location.start_line,
95 name = esc(&f.smell_name),
96 start = f.location.start_line,
97 end = f.location.end_line,
98 msg = esc(&f.message),
99 );
100 }
101 let _ = write!(html, "</details>");
102 }
103 let _ = write!(html, "</section>");
104}
105
106fn render_source_block(html: &mut String, path: &str, src: &str, file_findings: &[&Finding]) {
107 let context = 5;
108 let highlight_lines = finding_lines(file_findings);
109 let mut visible: std::collections::BTreeSet<usize> = std::collections::BTreeSet::new();
110 for &ln in &highlight_lines {
111 let start = ln.saturating_sub(context).max(1);
112 for l in start..=ln + context {
113 visible.insert(l);
114 }
115 }
116 let lines: Vec<&str> = src.lines().collect();
117 let total = lines.len();
118 let _ = write!(html, "<div class=\"source\"><table class=\"code\">");
119 let mut prev = 0usize;
120 for &ln in &visible {
121 if ln > total {
122 break;
123 }
124 if ln > prev + 1 {
125 let _ = write!(
126 html,
127 "<tr class=\"sep\"><td class=\"ln\">⋮</td><td class=\"src\"></td></tr>"
128 );
129 }
130 let cls = if highlight_lines.contains(&ln) {
131 " class=\"hl\""
132 } else {
133 ""
134 };
135 let _ = write!(
136 html,
137 "<tr{cls} id=\"{id}-L{ln}\"><td class=\"ln\">{ln}</td>\
138 <td class=\"src\">{code}</td></tr>",
139 id = path_id(path),
140 code = esc(lines[ln - 1]),
141 );
142 prev = ln;
143 }
144 let _ = write!(html, "</table></div>");
145}
146
147fn count_severities(findings: &[Finding]) -> (usize, usize, usize) {
148 let mut e = 0;
149 let mut w = 0;
150 let mut h = 0;
151 for f in findings {
152 match f.severity {
153 Severity::Error => e += 1,
154 Severity::Warning => w += 1,
155 Severity::Hint => h += 1,
156 }
157 }
158 (e, w, h)
159}
160
161fn group_by_file(findings: &[Finding]) -> BTreeMap<String, Vec<&Finding>> {
162 let mut map: BTreeMap<String, Vec<&Finding>> = BTreeMap::new();
163 for f in findings {
164 map.entry(f.location.path.to_string_lossy().to_string())
165 .or_default()
166 .push(f);
167 }
168 map
169}
170
171fn finding_lines(findings: &[&Finding]) -> std::collections::HashSet<usize> {
172 let mut set = std::collections::HashSet::new();
173 for f in findings {
174 for l in f.location.start_line..=f.location.end_line {
175 set.insert(l);
176 }
177 }
178 set
179}
180
181fn path_id(path: &str) -> String {
182 path.replace(['/', '\\', '.'], "-")
183}
184
185fn esc(s: &str) -> String {
186 s.replace('&', "&")
187 .replace('<', "<")
188 .replace('>', ">")
189 .replace('"', """)
190}
191
192fn sev_class(s: Severity) -> &'static str {
193 match s {
194 Severity::Error => "error",
195 Severity::Warning => "warning",
196 Severity::Hint => "hint",
197 }
198}
199
200fn sev_icon(s: Severity) -> &'static str {
201 match s {
202 Severity::Error => "🔴",
203 Severity::Warning => "⚠️",
204 Severity::Hint => "ℹ️",
205 }
206}
207
208fn format_duration(minutes: u32) -> String {
209 if minutes < 60 {
210 format!("{minutes}min")
211 } else {
212 let h = minutes / 60;
213 let m = minutes % 60;
214 if m == 0 {
215 format!("{h}h")
216 } else {
217 format!("{h}h {m}min")
218 }
219 }
220}
221
222const CSS: &str = r#"
223*{margin:0;padding:0;box-sizing:border-box}
224body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,monospace;background:#0d1117;color:#c9d1d9;padding:2rem;max-width:1200px;margin:0 auto}
225header{margin-bottom:2rem}
226h1{font-size:1.8rem;margin-bottom:.5rem;color:#58a6ff}
227h2{font-size:1.3rem;margin:1.5rem 0 .8rem;color:#8b949e;border-bottom:1px solid #21262d;padding-bottom:.3rem}
228.summary{display:flex;gap:.5rem;flex-wrap:wrap}
229.badge{padding:.2rem .6rem;border-radius:4px;font-size:.85rem;font-weight:600}
230.badge.error{background:#da3633;color:#fff}
231.badge.warning{background:#d29922;color:#fff}
232.badge.hint{background:#388bfd;color:#fff}
233.badge.debt{background:#21262d;color:#8b949e}
234table{width:100%;border-collapse:collapse;margin:.5rem 0}
235th,td{text-align:left;padding:.3rem .6rem;border-bottom:1px solid #21262d}
236th{color:#8b949e;font-size:.8rem;text-transform:uppercase}
237.grade{font-weight:700;font-size:1.1rem}
238.grade-A .grade{color:#3fb950}.grade-B .grade{color:#58a6ff}.grade-C .grade{color:#d29922}.grade-D .grade{color:#f85149}.grade-F .grade{color:#da3633}
239details{margin:.8rem 0;background:#161b22;border:1px solid #21262d;border-radius:6px;overflow:hidden}
240summary{padding:.6rem 1rem;cursor:pointer;background:#161b22}
241summary:hover{background:#1c2128}
242.count{color:#8b949e;font-weight:400}
243.source{overflow-x:auto;max-height:400px;overflow-y:auto}
244.code{font-size:.8rem;width:100%}
245.code td{border:none;padding:0 .5rem;white-space:pre}
246.code .ln{color:#484f58;text-align:right;user-select:none;width:3rem;min-width:3rem}
247.code .src{color:#c9d1d9}
248.code tr.hl{background:#2d1b00}
249.code tr.hl .src{color:#f0c674}
250.code tr.sep{color:#484f58}
251.code tr.sep td{border:none;padding:.1rem .5rem}
252.finding{padding:.4rem 1rem;font-size:.85rem;border-left:3px solid}
253.finding.error{border-color:#da3633;background:#1a0000}.finding.warning{border-color:#d29922;background:#1a1500}.finding.hint{border-color:#388bfd;background:#0a1929}
254.finding a{color:#58a6ff;text-decoration:none}
255.finding a:hover{text-decoration:underline}
256.sev{margin-right:.3rem}
257"#;