1#![allow(clippy::multiple_crate_versions)]
4
5mod pdf_compat;
6
7use std::collections::BTreeMap;
8use std::fmt::Write as FmtWrite;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use anyhow::{Context, Result};
13use askama::Template;
14use chrono::{DateTime, FixedOffset, Utc};
15use sloc_core::{AnalysisRun, CocomoMode, FileRecord, StyleSummary, SummaryTotals};
16
17static LOGO_TEXT_PNG: &[u8] = include_bytes!("../assets/logo/logo-text.png");
21static SMALL_LOGO_PNG: &[u8] = include_bytes!("../assets/logo/small-logo.png");
22static CHART_JS: &str = include_str!("../assets/chart.min.js");
23
24fn png_data_uri(bytes: &[u8]) -> String {
25 format!("data:image/png;base64,{}", base64_encode(bytes))
26}
27
28fn base64_encode(data: &[u8]) -> String {
29 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
30 let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
31 for chunk in data.chunks(3) {
32 let b0 = u32::from(chunk[0]);
33 let b1 = if chunk.len() > 1 {
34 u32::from(chunk[1])
35 } else {
36 0
37 };
38 let b2 = if chunk.len() > 2 {
39 u32::from(chunk[2])
40 } else {
41 0
42 };
43 let n = (b0 << 16) | (b1 << 8) | b2;
44 out.push(CHARS[((n >> 18) & 63) as usize] as char);
45 out.push(CHARS[((n >> 12) & 63) as usize] as char);
46 out.push(if chunk.len() > 1 {
47 CHARS[((n >> 6) & 63) as usize] as char
48 } else {
49 '='
50 });
51 out.push(if chunk.len() > 2 {
52 CHARS[(n & 63) as usize] as char
53 } else {
54 '='
55 });
56 }
57 out
58}
59
60fn normalize_remote_url(remote_url: &str) -> Option<String> {
64 let url = remote_url.trim();
65 if let Some(rest) = url.strip_prefix("git@") {
66 let (host, path) = rest.split_once(':')?;
67 let path = path.trim_end_matches(".git");
68 return Some(format!("https://{host}/{path}"));
69 }
70 if url.starts_with("https://") || url.starts_with("http://") {
71 return Some(url.trim_end_matches(".git").to_string());
72 }
73 None
74}
75
76pub(crate) fn derive_commit_url(remote_url: &str, sha: &str) -> Option<String> {
78 let base = normalize_remote_url(remote_url)?;
79 let lower = base.to_lowercase();
80 if lower.contains("bitbucket.org") {
81 Some(format!("{base}/commits/{sha}"))
82 } else if lower.contains("gitlab.") {
83 Some(format!("{base}/-/commit/{sha}"))
84 } else {
85 Some(format!("{base}/commit/{sha}"))
86 }
87}
88
89pub(crate) fn derive_branch_url(remote_url: &str, branch: &str) -> Option<String> {
91 let base = normalize_remote_url(remote_url)?;
92 let lower = base.to_lowercase();
93 if lower.contains("bitbucket.org") {
94 Some(format!("{base}/branch/{branch}"))
95 } else if lower.contains("gitlab.") {
96 Some(format!("{base}/-/tree/{branch}"))
97 } else {
98 Some(format!("{base}/tree/{branch}"))
99 }
100}
101
102pub struct ReportDeltaContext {
105 pub delta_code_added: i64,
107 pub delta_code_removed: i64,
109 pub delta_unmodified_lines: i64,
111 pub delta_files_added: usize,
113 pub delta_files_removed: usize,
115 pub delta_files_modified: usize,
117 pub delta_files_unchanged: usize,
119 pub prev_code_lines: u64,
121 pub prev_scan_count: usize,
123 pub prev_scan_label: String,
125 pub prev_run_id: Option<String>,
127 pub current_run_id: Option<String>,
129}
130
131pub fn render_html(run: &AnalysisRun) -> Result<String> {
137 render_html_inner(run, false, None, None)
138}
139
140pub fn render_html_with_delta(
150 run: &AnalysisRun,
151 delta: Option<&ReportDeltaContext>,
152) -> Result<String> {
153 render_html_inner(run, false, None, delta)
154}
155
156pub fn render_sub_report_html(run: &AnalysisRun, pdf_url: Option<&str>) -> Result<String> {
162 render_html_inner(run, true, pdf_url, None)
163}
164
165fn load_custom_logo(path: &std::path::Path) -> Option<String> {
166 let bytes = std::fs::read(path).ok()?;
167 let ext = path
168 .extension()
169 .and_then(|e| e.to_str())
170 .unwrap_or("")
171 .to_ascii_lowercase();
172 let mime = if ext == "svg" {
173 "image/svg+xml"
174 } else {
175 "image/png"
176 };
177 Some(format!("data:{mime};base64,{}", base64_encode(&bytes)))
178}
179
180fn json_escape(s: &str) -> String {
184 s.replace('\\', "\\\\").replace('"', "\\\"")
185}
186
187fn build_lang_chart_json(run: &AnalysisRun) -> String {
188 let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
189 langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
190 let entries: Vec<String> = langs
191 .into_iter()
192 .take(12)
193 .map(|l| {
194 format!(
195 r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"tests":{},"files":{}}}"#,
196 json_escape(l.language.display_name()),
197 l.code_lines, l.comment_lines, l.blank_lines, l.total_physical_lines,
198 l.functions, l.classes, l.variables, l.imports,
199 l.test_count, l.files,
200 )
201 })
202 .collect();
203 format!("[{}]", entries.join(","))
204}
205
206fn build_submodule_chart_json(run: &AnalysisRun) -> String {
207 let entries: Vec<String> = run
208 .submodule_summaries
209 .iter()
210 .map(|s| {
211 format!(
212 r#"{{"name":"{}","path":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
213 json_escape(&s.name), json_escape(&s.relative_path),
214 s.code_lines, s.comment_lines, s.blank_lines,
215 s.total_physical_lines, s.files_analyzed,
216 )
217 })
218 .collect();
219 format!("[{}]", entries.join(","))
220}
221
222fn build_scatter_chart_json(run: &AnalysisRun) -> String {
223 let entries: Vec<String> = run
224 .totals_by_language
225 .iter()
226 .map(|l| {
227 format!(
228 r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
229 json_escape(l.language.display_name()),
230 l.files,
231 l.code_lines,
232 l.total_physical_lines,
233 )
234 })
235 .collect();
236 format!("[{}]", entries.join(","))
237}
238
239fn build_semantic_chart_json(run: &AnalysisRun) -> String {
240 let entries: Vec<String> = run
241 .totals_by_language
242 .iter()
243 .filter(|l| l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0 || l.test_count > 0)
244 .map(|l| {
245 format!(
246 r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
247 json_escape(l.language.display_name()),
248 l.functions, l.classes, l.variables, l.imports, l.test_count,
249 )
250 })
251 .collect();
252 format!("[{}]", entries.join(","))
253}
254
255fn build_file_size_histogram_json(run: &AnalysisRun) -> String {
256 let labels = [
258 ("Tiny (<50)", 0u64, 49u64),
259 ("Small (50-199)", 50, 199),
260 ("Medium (200-499)", 200, 499),
261 ("Large (500-999)", 500, 999),
262 ("Huge (>=1000)", 1000, u64::MAX),
263 ];
264 let mut counts = [0u64; 5];
265 for f in &run.per_file_records {
266 let cl = f.effective_counts.code_lines;
267 for (i, &(_, lo, hi)) in labels.iter().enumerate() {
268 if cl >= lo && cl <= hi {
269 counts[i] += 1;
270 break;
271 }
272 }
273 }
274 let entries: Vec<String> = labels
275 .iter()
276 .zip(counts.iter())
277 .map(|((label, _, _), count)| {
278 format!(r#"{{"label":"{}","count":{}}}"#, json_escape(label), count)
279 })
280 .collect();
281 format!("[{}]", entries.join(","))
282}
283
284fn build_style_chart_json(summary: &StyleSummary) -> String {
287 let groups: Vec<String> = summary
288 .by_language
289 .iter()
290 .map(|grp| {
291 let guides: Vec<String> = grp
292 .guide_avg_scores
293 .iter()
294 .map(|(name, score)| {
295 format!(r#"{{"guide":"{}","score":{}}}"#, json_escape(name), score)
296 })
297 .collect();
298 format!(
299 r#"{{"family":"{}","files":{},"indent":"{}","dominant":"{}","score":{},"guides":[{}]}}"#,
300 json_escape(&grp.language_family),
301 grp.files_count,
302 json_escape(&grp.common_indent_style),
303 json_escape(&grp.dominant_guide),
304 grp.dominant_score_pct,
305 guides.join(","),
306 )
307 })
308 .collect();
309 format!("[{}]", groups.join(","))
310}
311
312fn build_style_file_json(run: &AnalysisRun) -> String {
314 let entries: Vec<String> = run
315 .per_file_records
316 .iter()
317 .filter_map(|f| {
318 let s = f.style_analysis.as_ref()?;
319 let sigs: Vec<String> = s
321 .signals
322 .iter()
323 .take(3)
324 .map(|sig| {
325 format!(
326 r#"{{"k":"{}","v":"{}"}}"#,
327 json_escape(&sig.name),
328 json_escape(&sig.value),
329 )
330 })
331 .collect();
332 Some(format!(
333 r#"{{"path":"{}","lang":"{}","family":"{}","indent":"{}","guide":"{}","score":{},"signals":[{}]}}"#,
334 json_escape(&f.relative_path),
335 json_escape(f.language.map_or("\u{2014}", |l| l.display_name())),
336 json_escape(&s.language_family),
337 json_escape(s.indent_style.display()),
338 json_escape(&s.dominant_guide),
339 s.dominant_score_pct,
340 sigs.join(","),
341 ))
342 })
343 .take(500)
344 .collect();
345 format!("[{}]", entries.join(","))
346}
347
348#[allow(clippy::cast_precision_loss)]
352fn coverage_pct_str(hit: u64, found: u64) -> String {
353 if found > 0 {
354 format!("{:.1}", hit as f64 / found as f64 * 100.0)
355 } else {
356 String::new()
357 }
358}
359
360#[allow(clippy::cast_precision_loss)]
362fn coverage_class(hit: u64, found: u64) -> String {
363 if found > 0 {
364 let pct = hit as f64 / found as f64 * 100.0;
365 if pct >= 80.0 {
366 "good"
367 } else if pct >= 60.0 {
368 "warn"
369 } else {
370 "danger"
371 }
372 } else {
373 "muted"
374 }
375 .to_string()
376}
377
378#[allow(clippy::cast_precision_loss)]
380fn format_test_density(code_lines: u64, test_count: u64) -> String {
381 if code_lines > 0 && test_count > 0 {
382 format!("{:.1}", test_count as f64 / code_lines as f64 * 1000.0)
383 } else {
384 String::from("0.0")
385 }
386}
387
388fn group_thousands(s: &str) -> String {
394 let (sign, rest) = match s.as_bytes().first() {
395 Some(b'-') => ("-", &s[1..]),
396 Some(b'+') => ("+", &s[1..]),
397 _ => ("", s),
398 };
399 let (int_part, frac_part) = match rest.split_once('.') {
400 Some((i, f)) => (i, Some(f)),
401 None => (rest, None),
402 };
403 if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
404 return s.to_string();
405 }
406 let bytes = int_part.as_bytes();
407 let len = bytes.len();
408 let mut grouped = String::with_capacity(len + len / 3);
409 for (i, &b) in bytes.iter().enumerate() {
410 if i > 0 && (len - i).is_multiple_of(3) {
411 grouped.push(',');
412 }
413 grouped.push(b as char);
414 }
415 frac_part.map_or_else(
416 || format!("{sign}{grouped}"),
417 |f| format!("{sign}{grouped}.{f}"),
418 )
419}
420
421mod filters {
423 #![allow(clippy::inline_always, clippy::unused_self, clippy::unnecessary_wraps)]
426 use askama::{Result, Values};
427
428 #[askama::filter_fn]
430 pub fn commas<T: core::fmt::Display>(value: T, _: &dyn Values) -> Result<String> {
431 Ok(super::group_thousands(&value.to_string()))
432 }
433}
434
435#[allow(clippy::too_many_lines)] fn render_html_inner(
439 run: &AnalysisRun,
440 is_sub_report: bool,
441 pdf_url: Option<&str>,
442 delta_ctx: Option<&ReportDeltaContext>,
443) -> Result<String> {
444 let config_json = serde_json::to_string_pretty(&run.effective_configuration)
445 .context("failed to serialize effective configuration")?;
446
447 let warning_summary_rows = summarize_warnings(&run.warnings);
448 let warning_opportunity_rows = build_support_opportunities(&run.warnings);
449
450 let logo_text_uri = png_data_uri(LOGO_TEXT_PNG);
451 let small_logo_uri = png_data_uri(SMALL_LOGO_PNG);
452
453 let rep = &run.effective_configuration.reporting;
454 let custom_logo_uri = rep.logo_path.as_deref().and_then(load_custom_logo);
455 let company_name = rep.company_name.clone();
456 let accent_hex = rep.accent_color.clone();
457 let report_header_footer = rep.report_header_footer.clone();
458
459 let totals = &run.summary_totals;
460
461 let hotspot_rows = build_hotspot_rows(run, 200);
464
465 let template = ReportTemplate {
466 nonce: String::new(),
469 title: rep.report_title.clone(),
470 browser_title: format!("Oxide-SLOC | {}", rep.report_title),
471 scan_performed_by: run.environment.ci_name.clone().unwrap_or_else(|| {
472 format!(
473 "{} / {}",
474 run.environment.initiator_username, run.environment.initiator_hostname
475 )
476 }),
477 scan_time_pst: to_pst_display(run.tool.timestamp_utc),
478 tool_version: run.tool.version.clone(),
479 is_sub_report,
480 run,
481 language_rows: run
482 .totals_by_language
483 .iter()
484 .map(|row| LanguageRow {
485 language: row.language.display_name().to_string(),
486 files: row.files,
487 total_physical_lines: row.total_physical_lines,
488 code_lines: row.code_lines,
489 comment_lines: row.comment_lines,
490 blank_lines: row.blank_lines,
491 mixed_lines_separate: row.mixed_lines_separate,
492 functions: row.functions,
493 classes: row.classes,
494 variables: row.variables,
495 imports: row.imports,
496 test_count: row.test_count,
497 test_assertion_count: row.test_assertion_count,
498 test_suite_count: row.test_suite_count,
499 test_density_str: if row.code_lines > 0 {
500 #[allow(clippy::cast_precision_loss)]
502 let density = row.test_count as f64 / row.code_lines as f64 * 1000.0;
503 format!("{density:.1}")
504 } else {
505 "—".to_string()
506 },
507 })
508 .collect(),
509 file_rows: run.per_file_records.iter().map(file_row_view).collect(),
510 skipped_rows: run.skipped_file_records.iter().map(file_row_view).collect(),
511 config_json,
512 lang_chart_json: build_lang_chart_json(run),
513 submodule_chart_json: build_submodule_chart_json(run),
514 scatter_chart_json: build_scatter_chart_json(run),
515 semantic_chart_json: build_semantic_chart_json(run),
516 file_size_histogram_json: build_file_size_histogram_json(run),
517 has_submodule_data: !run.submodule_summaries.is_empty(),
518 has_semantic_data: run
519 .totals_by_language
520 .iter()
521 .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
522 has_coverage_data: run.per_file_records.iter().any(|f| f.coverage.is_some()),
523 has_fn_coverage: totals.coverage_functions_found > 0,
524 has_branch_coverage: totals.coverage_branches_found > 0,
525 test_files_count: run
526 .per_file_records
527 .iter()
528 .filter(|f| f.raw_line_categories.test_count > 0)
529 .count() as u64,
530 test_assertion_count: totals.test_assertion_count,
531 test_suite_count: totals.test_suite_count,
532 test_density: format_test_density(totals.code_lines, totals.test_count),
533 most_tested_lang: run
534 .totals_by_language
535 .iter()
536 .filter(|l| l.test_count > 0)
537 .max_by_key(|l| l.test_count)
538 .map_or_else(
539 || "\u{2014}".to_string(),
540 |l| l.language.display_name().to_string(),
541 ),
542 langs_with_tests: run
543 .totals_by_language
544 .iter()
545 .filter(|l| l.test_count > 0)
546 .count(),
547 cov_line_pct: coverage_pct_str(totals.coverage_lines_hit, totals.coverage_lines_found),
548 cov_fn_pct: coverage_pct_str(
549 totals.coverage_functions_hit,
550 totals.coverage_functions_found,
551 ),
552 cov_branch_pct: coverage_pct_str(
553 totals.coverage_branches_hit,
554 totals.coverage_branches_found,
555 ),
556 cov_line_class: coverage_class(totals.coverage_lines_hit, totals.coverage_lines_found),
557 cov_fn_class: coverage_class(
558 totals.coverage_functions_hit,
559 totals.coverage_functions_found,
560 ),
561 cov_branch_class: coverage_class(
562 totals.coverage_branches_hit,
563 totals.coverage_branches_found,
564 ),
565 has_run_warnings: !run.warnings.is_empty(),
566 warning_count: run.warnings.len(),
567 warning_summary_rows,
568 warning_opportunity_rows,
569 warning_console_full: build_warning_console(&run.warnings),
570 logo_text_uri,
571 small_logo_uri,
572 custom_logo_uri,
573 company_name,
574 accent_hex,
575 report_header_footer,
576 chart_js: CHART_JS,
577 run_id_short: run
578 .tool
579 .run_id
580 .split('-')
581 .next_back()
582 .unwrap_or(&run.tool.run_id)
583 .chars()
584 .take(7)
585 .collect(),
586 standalone_pdf_url: pdf_url.map(str::to_string),
587 has_style_data: run.style_summary.is_some(),
588 style_lang_count: run
589 .style_summary
590 .as_ref()
591 .map_or(0, |ss| ss.by_language.len()),
592 style_score_threshold: run.effective_configuration.analysis.style_score_threshold,
593 style_chart_json: run
594 .style_summary
595 .as_ref()
596 .map(build_style_chart_json)
597 .unwrap_or_default(),
598 style_file_json: if run.style_summary.is_some() {
599 build_style_file_json(run)
600 } else {
601 String::new()
602 },
603 style_summary: run.style_summary.clone(),
604 has_delta: delta_ctx.is_some(),
605 delta_code_added: delta_ctx.map_or(0, |d| d.delta_code_added),
606 delta_code_removed: delta_ctx.map_or(0, |d| d.delta_code_removed),
607 delta_unmodified_lines: delta_ctx.map_or(0, |d| d.delta_unmodified_lines),
608 delta_files_added: delta_ctx.map_or(0, |d| d.delta_files_added),
609 delta_files_removed: delta_ctx.map_or(0, |d| d.delta_files_removed),
610 delta_files_modified: delta_ctx.map_or(0, |d| d.delta_files_modified),
611 delta_files_unchanged: delta_ctx.map_or(0, |d| d.delta_files_unchanged),
612 delta_files_total: delta_ctx.map_or(0, |d| {
613 d.delta_files_added
614 + d.delta_files_removed
615 + d.delta_files_modified
616 + d.delta_files_unchanged
617 }),
618 prev_code_lines: delta_ctx.map_or(0, |d| d.prev_code_lines),
619 prev_scan_count: delta_ctx.map_or(0, |d| d.prev_scan_count),
620 prev_scan_label: delta_ctx
621 .map(|d| d.prev_scan_label.clone())
622 .unwrap_or_default(),
623 prev_run_id: delta_ctx
624 .and_then(|d| d.prev_run_id.clone())
625 .unwrap_or_default(),
626 git_commit_url: run
627 .git_remote_url
628 .as_deref()
629 .zip(run.git_commit_long.as_deref())
630 .and_then(|(remote, sha)| derive_commit_url(remote, sha)),
631 git_branch_url: run
632 .git_remote_url
633 .as_deref()
634 .zip(run.git_branch.as_deref())
635 .and_then(|(remote, branch)| derive_branch_url(remote, branch)),
636 has_cocomo: run.cocomo.is_some(),
637 cocomo_effort_str: run
638 .cocomo
639 .as_ref()
640 .map_or(String::new(), |c| format!("{:.2}", c.effort_person_months)),
641 cocomo_duration_str: run
642 .cocomo
643 .as_ref()
644 .map_or(String::new(), |c| format!("{:.2}", c.duration_months)),
645 cocomo_staff_str: run
646 .cocomo
647 .as_ref()
648 .map_or(String::new(), |c| format!("{:.2}", c.avg_staff)),
649 cocomo_ksloc_str: run
650 .cocomo
651 .as_ref()
652 .map_or(String::new(), |c| format!("{:.2}", c.ksloc)),
653 cocomo_mode_label: run
654 .cocomo
655 .as_ref()
656 .map_or_else(|| "Organic".to_string(), |c| {
657 match c.mode {
658 CocomoMode::Organic => "Organic",
659 CocomoMode::SemiDetached => "Semi-detached",
660 CocomoMode::Embedded => "Embedded",
661 }
662 .to_string()
663 }),
664 cocomo_mode_tooltip: run
665 .cocomo
666 .as_ref()
667 .map_or(String::new(), |c| match c.mode {
668 CocomoMode::Organic => "Organic: A small team working on a well-understood \
669 project in a familiar environment with minimal external constraints. \
670 Suited for internal tools, utilities, and projects with stable requirements. \
671 Effort = 2.4 \u{00D7} KSLOC^1.05.",
672 CocomoMode::SemiDetached => "Semi-detached: A mixed team with varying levels of \
673 experience tackling a project with moderate novelty and some rigid constraints. \
674 Typical for compilers, transaction systems, and batch processors. \
675 Effort = 3.0 \u{00D7} KSLOC^1.12.",
676 CocomoMode::Embedded => "Embedded: Tight hardware, software, or operational \
677 constraints requiring significant innovation and deep integration work. \
678 Typical for real-time control systems and safety-critical software. \
679 Effort = 3.6 \u{00D7} KSLOC^1.20.",
680 }.to_string()),
681 uloc: run.uloc,
682 dryness_pct_str: run
683 .dryness_pct
684 .map_or(String::new(), |d| format!("{d:.1}")),
685 duplicate_group_count: run.duplicate_groups.len(),
686 has_hotspots: !hotspot_rows.is_empty(),
687 hotspot_rows,
688 };
689
690 template.render().context("failed to render HTML report")
691}
692
693struct HotspotRow {
695 path: String,
696 code_lines: u64,
697 commit_count: u32,
698 last_commit_date: String,
699 score: u64,
700}
701
702fn build_hotspot_rows(run: &AnalysisRun, limit: usize) -> Vec<HotspotRow> {
708 let mut rows: Vec<HotspotRow> = run
709 .per_file_records
710 .iter()
711 .filter_map(|r| {
712 let commits = r.commit_count?;
713 let code = r.effective_counts.code_lines;
714 Some(HotspotRow {
715 path: r.relative_path.clone(),
716 code_lines: code,
717 commit_count: commits,
718 last_commit_date: r.last_commit_date.as_deref().map_or_else(String::new, |d| {
720 d.split('T').next().unwrap_or(d).to_string()
721 }),
722 score: code.saturating_mul(u64::from(commits)),
723 })
724 })
725 .collect();
726 rows.sort_by(|a, b| {
727 b.score
728 .cmp(&a.score)
729 .then(b.commit_count.cmp(&a.commit_count))
730 });
731 rows.truncate(limit);
732 rows
733}
734
735pub fn write_html(run: &AnalysisRun, output_path: &Path) -> Result<()> {
741 let html = render_html_inner(run, false, None, None)?;
742 fs::write(output_path, html)
743 .with_context(|| format!("failed to write HTML report to {}", output_path.display()))
744}
745
746pub fn write_html_with_pdf_link(
756 run: &AnalysisRun,
757 output_path: &Path,
758 pdf_path: Option<&Path>,
759) -> Result<()> {
760 let pdf_relative = pdf_path.and_then(|pdf| {
761 let html_dir = output_path.parent()?;
762 let pdf_dir = pdf.parent()?;
763 if html_dir == pdf_dir {
764 pdf.file_name().map(|n| n.to_string_lossy().into_owned())
765 } else {
766 None
767 }
768 });
769 let html = render_html_inner(run, false, pdf_relative.as_deref(), None)?;
770 fs::write(output_path, html)
771 .with_context(|| format!("failed to write HTML report to {}", output_path.display()))
772}
773
774fn launch_cdp_browser(
780 browser_path: std::path::PathBuf,
781 no_sandbox: bool,
782) -> Result<headless_chrome::Browser> {
783 use headless_chrome::{Browser, LaunchOptions};
784
785 if no_sandbox {
786 return Browser::new(LaunchOptions {
787 headless: true,
788 path: Some(browser_path),
789 window_size: Some((1122, 794)),
790 sandbox: false,
791 ..Default::default()
792 })
793 .context("failed to launch browser via CDP (no-sandbox)");
794 }
795
796 Browser::new(LaunchOptions {
799 headless: true,
800 path: Some(browser_path),
801 window_size: Some((1122, 794)),
802 sandbox: true,
803 ..Default::default()
804 })
805 .map_err(|e| {
806 anyhow::anyhow!(
807 "Browser launch failed with sandbox enabled: {e:#}\n\
808 If running in a container without user namespaces (e.g. Docker with cap_drop:ALL), \
809 set SLOC_BROWSER_NOSANDBOX=1 to opt into --no-sandbox mode."
810 )
811 })
812}
813
814fn report_chart_error_if_any(tab: &headless_chrome::Tab) {
816 let Ok(e) = tab.evaluate("window.oxSlocChartError||''", false) else {
817 return;
818 };
819 let Some(serde_json::Value::String(msg)) = e.value else {
820 return;
821 };
822 if !msg.is_empty() {
823 eprintln!("[oxide-sloc][pdf] chart JS error (charts may be missing): {msg}");
824 }
825}
826
827fn wait_for_charts_ready(tab: &headless_chrome::Tab) {
829 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
830 let mut last_cdp_err: Option<String> = None;
831 loop {
832 match tab.evaluate("!!window.oxSlocChartsReady", false) {
833 Ok(r) => {
834 last_cdp_err = None;
835 if matches!(r.value, Some(serde_json::Value::Bool(true))) {
836 report_chart_error_if_any(tab);
837 return;
838 }
839 }
840 Err(e) => {
841 let msg = format!("{e:#}");
842 if last_cdp_err.as_deref() != Some(&msg) {
843 eprintln!("[oxide-sloc][pdf] CDP evaluate error (will retry): {msg}");
844 last_cdp_err = Some(msg);
845 }
846 }
847 }
848 if std::time::Instant::now() >= deadline {
849 report_chart_error_if_any(tab);
850 break;
851 }
852 std::thread::sleep(std::time::Duration::from_millis(250));
853 }
854}
855
856fn extract_banner_text(tab: &headless_chrome::Tab) -> Option<String> {
858 let result = tab
859 .evaluate(
860 "(function(){\
861 var el=document.querySelector('.report-id-banner');\
862 return el?el.textContent.trim():null;\
863 })()",
864 false,
865 )
866 .ok()?;
867 match result.value? {
868 serde_json::Value::String(s) if !s.is_empty() => Some(s),
869 _ => None,
870 }
871}
872
873fn extract_pdf_template(tab: &headless_chrome::Tab, id: &str) -> Option<String> {
878 let js = format!(
880 "(function(){{var el=document.getElementById('{id}');return el?el.innerHTML:null;}})()"
881 );
882 let result = tab.evaluate(&js, false).ok()?;
883 match result.value? {
884 serde_json::Value::String(s) if !s.trim().is_empty() => Some(s),
885 _ => None,
886 }
887}
888
889fn write_pdf_via_cdp(html_path: &Path, output_path: &Path) -> Result<()> {
895 use headless_chrome::types::PrintToPdfOptions;
896
897 let browser_path = discover_browser().context(
898 "no supported Chromium-based browser found; \
899 set SLOC_BROWSER/BROWSER or install Chrome, Chromium, Edge, Brave, Vivaldi, or Opera",
900 )?;
901 eprintln!("[oxide-sloc][pdf] browser = {}", browser_path.display());
902
903 let no_sandbox = std::env::var("SLOC_BROWSER_NOSANDBOX").as_deref() == Ok("1");
904 if no_sandbox {
905 eprintln!("[oxide-sloc][pdf] --no-sandbox enabled via SLOC_BROWSER_NOSANDBOX=1");
906 }
907
908 let browser = launch_cdp_browser(browser_path, no_sandbox)?;
909 let tab = browser.new_tab().context("failed to open browser tab")?;
910 tab.set_default_timeout(std::time::Duration::from_secs(90));
915
916 let html_for_url = PathBuf::from(
917 html_path
918 .to_string_lossy()
919 .trim_start_matches(r"\\?\")
920 .to_string(),
921 );
922 let url = file_url(&html_for_url);
923 eprintln!("[oxide-sloc][pdf] url = {url}");
924
925 tab.navigate_to(&url)
926 .context("failed to navigate browser to HTML file")?;
927 tab.wait_until_navigated()
928 .context("browser navigation did not complete")?;
929
930 wait_for_charts_ready(&tab);
931
932 let chrome = build_pdf_chrome(&tab);
935
936 let pdf_bytes = tab
937 .print_to_pdf(Some(PrintToPdfOptions {
938 landscape: Some(true),
939 print_background: Some(true),
940 scale: Some(0.97),
941 paper_width: Some(11.69), paper_height: Some(8.27), margin_top: Some(chrome.margin_top),
944 margin_bottom: Some(chrome.margin_bottom),
945 margin_left: Some(0.0),
946 margin_right: Some(0.0),
947 prefer_css_page_size: Some(false),
948 display_header_footer: if chrome.display_header_footer {
949 Some(true)
950 } else {
951 None
952 },
953 header_template: chrome.header_template,
954 footer_template: chrome.footer_template,
955 ..Default::default()
956 }))
957 .context("browser failed to generate PDF")?;
958
959 fs::write(output_path, &pdf_bytes)
960 .with_context(|| format!("failed to write PDF to {}", output_path.display()))?;
961
962 eprintln!("[oxide-sloc][pdf] wrote {} bytes", pdf_bytes.len());
963 Ok(())
964}
965
966struct PdfChrome {
968 header_template: Option<String>,
969 footer_template: Option<String>,
970 display_header_footer: bool,
971 margin_top: f64,
972 margin_bottom: f64,
973}
974
975fn pdf_banner_template(text: &str) -> String {
978 let escaped = text
979 .replace('&', "&")
980 .replace('<', "<")
981 .replace('>', ">")
982 .replace('"', """);
983 format!(
984 r#"<div style="font-size:10px;width:100%;text-align:center;\
985color:#fff;background:#b35428;padding:5px 0;\
986font-family:sans-serif;font-weight:700;letter-spacing:0.05em;\
987-webkit-print-color-adjust:exact;print-color-adjust:exact;">{escaped}</div>"#
988 )
989}
990
991const fn pdf_margins(
994 has_banner: bool,
995 has_native_header: bool,
996 has_native_footer: bool,
997) -> (f64, f64) {
998 let top = if has_banner {
999 0.35
1000 } else if has_native_header {
1001 0.55
1002 } else {
1003 0.0
1004 };
1005 let bottom = if has_banner {
1006 0.25
1007 } else if has_native_footer {
1008 0.42
1009 } else {
1010 0.0
1011 };
1012 (top, bottom)
1013}
1014
1015fn pdf_header_footer_templates(
1019 banner_text: Option<&str>,
1020 native_header: Option<String>,
1021 native_footer: Option<String>,
1022) -> (Option<String>, Option<String>) {
1023 if let Some(t) = banner_text {
1024 let tmpl = pdf_banner_template(t);
1025 return (Some(tmpl.clone()), Some(tmpl));
1026 }
1027 if native_header.is_some() || native_footer.is_some() {
1028 let empty = || "<span></span>".to_string();
1029 return (
1030 Some(native_header.unwrap_or_else(empty)),
1031 Some(native_footer.unwrap_or_else(empty)),
1032 );
1033 }
1034 (None, None)
1035}
1036
1037fn build_pdf_chrome(tab: &headless_chrome::Tab) -> PdfChrome {
1044 let banner_text = extract_banner_text(tab);
1045 if let Some(ref t) = banner_text {
1046 eprintln!("[oxide-sloc][pdf] report banner detected: {t}");
1047 }
1048 let has_banner = banner_text.is_some();
1049
1050 let native_header = if has_banner {
1051 None
1052 } else {
1053 extract_pdf_template(tab, "pdf-native-header")
1054 };
1055 let native_footer = if has_banner {
1056 None
1057 } else {
1058 extract_pdf_template(tab, "pdf-native-footer")
1059 };
1060 let has_native_header = native_header.is_some();
1061 let has_native_footer = native_footer.is_some();
1062
1063 let (header_template, footer_template) =
1064 pdf_header_footer_templates(banner_text.as_deref(), native_header, native_footer);
1065 let (margin_top, margin_bottom) = pdf_margins(has_banner, has_native_header, has_native_footer);
1066
1067 PdfChrome {
1068 header_template,
1069 footer_template,
1070 display_header_footer: has_banner || has_native_header || has_native_footer,
1071 margin_top,
1072 margin_bottom,
1073 }
1074}
1075
1076fn discover_wkhtmltopdf() -> Option<PathBuf> {
1086 if let Some(p) = which_in_path("wkhtmltopdf") {
1087 return Some(p);
1088 }
1089
1090 #[cfg(windows)]
1091 {
1092 for var in ["ProgramFiles", "ProgramFiles(x86)"] {
1093 if let Ok(base) = std::env::var(var) {
1094 let candidate = PathBuf::from(base)
1095 .join("wkhtmltopdf")
1096 .join("bin")
1097 .join("wkhtmltopdf.exe");
1098 if candidate.is_file() {
1099 return Some(candidate);
1100 }
1101 }
1102 }
1103 }
1104
1105 #[cfg(not(windows))]
1106 for p in [
1107 "/usr/bin/wkhtmltopdf",
1108 "/usr/local/bin/wkhtmltopdf",
1109 "/opt/wkhtmltopdf/bin/wkhtmltopdf",
1110 "/snap/bin/wkhtmltopdf",
1111 ] {
1112 let candidate = PathBuf::from(p);
1113 if candidate.is_file() {
1114 return Some(candidate);
1115 }
1116 }
1117
1118 None
1119}
1120
1121fn write_pdf_via_wkhtmltopdf(html_path: &Path, pdf_path: &Path) -> Result<()> {
1128 eprintln!("[oxide-sloc][pdf] trying wkhtmltopdf fallback");
1129
1130 let exe = discover_wkhtmltopdf().context(
1131 "wkhtmltopdf not found. \
1132 Linux: install via 'dnf install wkhtmltopdf' or 'apt install wkhtmltopdf'. \
1133 Windows: install the MSI from https://wkhtmltopdf.org/downloads.html. \
1134 Alternatively, set SLOC_BROWSER to a Chromium-based browser executable.",
1135 )?;
1136 eprintln!("[oxide-sloc][pdf] wkhtmltopdf = {}", exe.display());
1137
1138 let html_normalized = PathBuf::from(
1140 html_path
1141 .to_string_lossy()
1142 .trim_start_matches(r"\\?\")
1143 .to_string(),
1144 );
1145 let html_url = file_url(&html_normalized);
1147 eprintln!("[oxide-sloc][pdf] wkhtmltopdf url = {html_url}");
1148
1149 let pdf_str = pdf_path
1150 .to_str()
1151 .context("PDF output path contains non-UTF-8 characters")?;
1152
1153 let output = std::process::Command::new(&exe)
1154 .args([
1155 "--enable-javascript",
1156 "--javascript-delay",
1157 "2000",
1158 "--quiet",
1159 "--orientation",
1160 "Landscape",
1161 "--page-size",
1162 "A4",
1163 "--margin-top",
1164 "9",
1165 "--margin-bottom",
1166 "9",
1167 "--margin-left",
1168 "13",
1169 "--margin-right",
1170 "13",
1171 "--print-media-type",
1172 &html_url,
1173 pdf_str,
1174 ])
1175 .output()
1176 .with_context(|| format!("failed to launch wkhtmltopdf at {}", exe.display()))?;
1177
1178 if !output.status.success() {
1179 let stderr = String::from_utf8_lossy(&output.stderr);
1180 anyhow::bail!("wkhtmltopdf exited with {}: {stderr}", output.status);
1181 }
1182
1183 if !pdf_path.exists() {
1184 anyhow::bail!(
1185 "wkhtmltopdf exited successfully but {} was not created",
1186 pdf_path.display()
1187 );
1188 }
1189
1190 eprintln!("[oxide-sloc][pdf] wkhtmltopdf wrote {}", pdf_path.display());
1191 Ok(())
1192}
1193
1194struct PdfCtx<'a> {
1195 layer: &'a crate::pdf_compat::PdfLayerReference,
1196 font_reg: crate::pdf_compat::IndirectFontRef,
1197 font_bold: crate::pdf_compat::IndirectFontRef,
1198 w: f32,
1199 margin: f32,
1200 row_h: f32,
1201 tbl_hdr_h: f32,
1202}
1203
1204#[derive(Clone, Copy)]
1207struct PdfPageDims {
1208 w: f32,
1209 h: f32,
1210 margin: f32,
1211 footer_h: f32,
1212 row_h: f32,
1213 tbl_hdr_h: f32,
1214}
1215
1216#[allow(
1217 clippy::cast_precision_loss,
1218 clippy::suboptimal_flops,
1219 clippy::too_many_lines
1220)]
1221fn runtime_mode_display(mode: &str) -> &str {
1222 match mode {
1223 "serve" => "Web UI",
1224 "analyze" => "CLI",
1225 "git-scan" => "Git Scan",
1226 "git-compare" => "Git Compare",
1227 "watch" => "Watch",
1228 other => other,
1229 }
1230}
1231
1232fn pdf_render_page1_header(
1233 ctx: &PdfCtx<'_>,
1234 run: &AnalysisRun,
1235 ts: &str,
1236 title: &str,
1237 h: f32,
1238 hdr_h: f32,
1239 banner: Option<&str>,
1240) -> f32 {
1241 use crate::pdf_compat::{Color, Mm, Rgb};
1242 let hdr_y = h - hdr_h;
1243 pdf_fill_rect(
1244 ctx.layer,
1245 0.0,
1246 hdr_y,
1247 ctx.w,
1248 hdr_h,
1249 Rgb::new(0.098, 0.11, 0.15, None),
1250 );
1251 ctx.layer
1252 .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
1253 ctx.layer.use_text(
1254 "oxide-sloc",
1255 13.0,
1256 Mm(ctx.margin),
1257 Mm(hdr_y + 4.5),
1258 ctx.font_bold,
1259 );
1260 ctx.layer
1261 .set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
1262 ctx.layer.use_text(
1263 "Code Metrics Report",
1264 9.5,
1265 Mm(54.0),
1266 Mm(hdr_y + 5.0),
1267 ctx.font_reg,
1268 );
1269 ctx.layer.use_text(
1270 pdf_safe_str(ts),
1271 8.0,
1272 Mm(ctx.w - 70.0),
1273 Mm(hdr_y + 5.0),
1274 ctx.font_reg,
1275 );
1276 if let Some(text) = banner {
1278 let safe = pdf_trunc(&pdf_safe_str(text), 40);
1279 #[allow(
1283 clippy::cast_precision_loss,
1284 reason = "small bounded char count; sub-mm layout offset"
1285 )]
1286 let text_x = (safe.len() as f32).mul_add(-0.97, ctx.w / 2.0).max(95.0);
1287 ctx.layer
1288 .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
1289 ctx.layer
1290 .use_text(safe, 9.0, Mm(text_x), Mm(hdr_y + 4.5), ctx.font_bold);
1291 }
1292 let title_text_y = hdr_y - 5.5;
1293 ctx.layer
1294 .set_fill_color(Color::Rgb(Rgb::new(0.098, 0.11, 0.15, None)));
1295 ctx.layer.use_text(
1296 pdf_trunc(&pdf_safe_str(title), 55),
1297 9.5,
1298 Mm(ctx.margin),
1299 Mm(title_text_y),
1300 ctx.font_bold,
1301 );
1302 let roots_text_y = title_text_y - 5.0;
1303 let roots: String = run
1305 .input_roots
1306 .iter()
1307 .map(|r| pdf_safe_str(r))
1308 .collect::<Vec<_>>()
1309 .join(" ");
1310 ctx.layer
1311 .set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)));
1312 ctx.layer.use_text(
1313 pdf_trunc(&roots, 85),
1314 6.5,
1315 Mm(ctx.margin),
1316 Mm(roots_text_y),
1317 ctx.font_reg,
1318 );
1319 pdf_render_page1_gitbox(ctx, run, title_text_y, roots_text_y);
1321 roots_text_y
1322}
1323
1324fn pdf_render_page1_gitbox(
1326 ctx: &PdfCtx<'_>,
1327 run: &AnalysisRun,
1328 title_text_y: f32,
1329 roots_text_y: f32,
1330) {
1331 use crate::pdf_compat::{Color, Mm, Rgb};
1332 let mut git_parts: Vec<String> = vec![];
1333 if let Some(ref b) = run.git_branch {
1334 git_parts.push(format!("Branch: {}", pdf_safe_str(b)));
1335 }
1336 if let Some(ref c) = run.git_commit_short {
1337 git_parts.push(format!("Commit: {}", pdf_safe_str(c)));
1338 }
1339 if let Some(ref t) = run.git_nearest_tag {
1340 git_parts.push(format!("Tag: {}", pdf_safe_str(t)));
1341 }
1342 let git_str = pdf_trunc(&git_parts.join(" \u{00B7} "), 70);
1343
1344 let initiator = run
1345 .environment
1346 .ci_name
1347 .as_deref()
1348 .unwrap_or(run.environment.initiator_username.as_str());
1349 let mode_label = runtime_mode_display(&run.environment.runtime_mode);
1350 let env_str = format!(
1351 "OS: {} / {} \u{00B7} User: {} \u{00B7} Host: {} \u{00B7} Source: {}",
1352 pdf_safe_str(&run.environment.operating_system),
1353 pdf_safe_str(&run.environment.architecture),
1354 pdf_safe_str(initiator),
1355 pdf_safe_str(&run.environment.initiator_hostname),
1356 mode_label,
1357 );
1358 let env_trunc = pdf_trunc(&env_str, 100);
1359
1360 let right_anchor = ctx.w - ctx.margin - 6.0;
1362 let git_w = helvetica_width_mm(&git_str, 7.5, true);
1365 let env_w = helvetica_width_mm(&env_trunc, 6.5, false);
1366 let max_w = git_w.max(env_w);
1367
1368 let pad_h: f32 = 3.5;
1370 let pad_v: f32 = 1.8;
1371 let box_left = (right_anchor - max_w - pad_h).max(ctx.w / 2.0 - pad_h);
1372 let box_right = right_anchor + pad_h;
1373 let box_width = box_right - box_left;
1374 let box_bot = roots_text_y - pad_v;
1375 let box_top = title_text_y + pad_v + 1.5;
1376 let box_height = box_top - box_bot;
1377 pdf_fill_rect(
1378 ctx.layer,
1379 box_left - 0.6,
1380 box_bot - 0.6,
1381 box_width + 1.2,
1382 box_height + 1.2,
1383 Rgb::new(0.80, 0.75, 0.68, None),
1384 );
1385 pdf_fill_rect(
1386 ctx.layer,
1387 box_left,
1388 box_bot,
1389 box_width,
1390 box_height,
1391 Rgb::new(0.97, 0.95, 0.92, None),
1392 );
1393
1394 if !git_str.is_empty() {
1396 let git_x = (right_anchor - git_w).max(box_left + 2.0);
1397 ctx.layer
1398 .set_fill_color(Color::Rgb(Rgb::new(0.25, 0.42, 0.25, None)));
1399 ctx.layer.use_text(
1400 git_str.as_str(),
1401 7.5,
1402 Mm(git_x),
1403 Mm(title_text_y),
1404 ctx.font_bold,
1405 );
1406 }
1407 let env_x = (right_anchor - env_w).max(box_left + 2.0);
1409 ctx.layer
1410 .set_fill_color(Color::Rgb(Rgb::new(0.38, 0.38, 0.38, None)));
1411 ctx.layer.use_text(
1412 env_trunc.as_str(),
1413 6.5,
1414 Mm(env_x),
1415 Mm(roots_text_y),
1416 ctx.font_reg,
1417 );
1418}
1419
1420#[allow(clippy::cast_precision_loss)]
1421fn pdf_render_summary_chips(ctx: &PdfCtx<'_>, run: &AnalysisRun, roots_text_y: f32) -> f32 {
1422 use crate::pdf_compat::{Color, Mm, Rgb};
1423 let tot = &run.summary_totals;
1424 let chip_gap: f32 = 5.0;
1425 let chip_w = 3.0f32.mul_add(-chip_gap, 2.0f32.mul_add(-ctx.margin, ctx.w)) / 4.0;
1426 let chip_h: f32 = 17.0;
1427 let row1_bot = roots_text_y - 4.0 - chip_h;
1428 let row1: [(&str, u64); 4] = [
1429 ("Code Lines", tot.code_lines),
1430 ("Comment Lines", tot.comment_lines),
1431 ("Blank Lines", tot.blank_lines),
1432 ("Physical Lines", tot.total_physical_lines),
1433 ];
1434 for (i, (label, value)) in row1.iter().enumerate() {
1435 let cx = (i as f32).mul_add(chip_w + chip_gap, ctx.margin);
1436 pdf_fill_rect(
1437 ctx.layer,
1438 cx,
1439 row1_bot,
1440 chip_w,
1441 chip_h,
1442 Rgb::new(0.945, 0.925, 0.90, None),
1443 );
1444 ctx.layer
1445 .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
1446 ctx.layer.use_text(
1448 pdf_fmt_full(*value),
1449 13.0,
1450 Mm(cx + 4.0),
1451 Mm(row1_bot + 9.0),
1452 ctx.font_bold,
1453 );
1454 ctx.layer
1455 .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
1456 ctx.layer.use_text(
1457 pdf_safe_str(label),
1458 6.5,
1459 Mm(cx + 4.0),
1460 Mm(row1_bot + 3.0),
1461 ctx.font_reg,
1462 );
1463 }
1464 let row2_bot = row1_bot - 3.0 - chip_h;
1465 let row2_4th = if tot.test_count > 0 {
1466 ("Test Methods", tot.test_count)
1467 } else if tot.classes > 0 {
1468 ("Classes", tot.classes)
1469 } else {
1470 ("Mixed Lines", tot.mixed_lines_separate)
1471 };
1472 let row2: [(&str, u64); 4] = [
1473 ("Files Analyzed", tot.files_analyzed),
1474 ("Files Skipped", tot.files_skipped),
1475 ("Functions", tot.functions),
1476 row2_4th,
1477 ];
1478 for (i, (label, value)) in row2.iter().enumerate() {
1479 let cx = (i as f32).mul_add(chip_w + chip_gap, ctx.margin);
1480 pdf_fill_rect(
1481 ctx.layer,
1482 cx,
1483 row2_bot,
1484 chip_w,
1485 chip_h,
1486 Rgb::new(0.91, 0.92, 0.96, None),
1487 );
1488 ctx.layer
1489 .set_fill_color(Color::Rgb(Rgb::new(0.15, 0.25, 0.55, None)));
1490 ctx.layer.use_text(
1491 pdf_fmt_full(*value),
1492 13.0,
1493 Mm(cx + 4.0),
1494 Mm(row2_bot + 9.0),
1495 ctx.font_bold,
1496 );
1497 ctx.layer
1498 .set_fill_color(Color::Rgb(Rgb::new(0.35, 0.35, 0.45, None)));
1499 ctx.layer.use_text(
1500 pdf_safe_str(label),
1501 6.5,
1502 Mm(cx + 4.0),
1503 Mm(row2_bot + 3.0),
1504 ctx.font_reg,
1505 );
1506 }
1507 row2_bot
1508}
1509
1510#[allow(clippy::cast_precision_loss)]
1511fn pdf_info_parts_stats(tot: &SummaryTotals) -> Vec<String> {
1512 let total = tot.total_physical_lines.max(1) as f64;
1513 let code_pct = tot.code_lines as f64 / total * 100.0;
1514 let cmt_pct = tot.comment_lines as f64 / total * 100.0;
1515 let blank_pct = tot.blank_lines as f64 / total * 100.0;
1516 let mixed_pct = tot.mixed_lines_separate as f64 / total * 100.0;
1517 let mut parts = vec![
1518 format!(
1519 "Code: {code_pct:.1}% ({} lines)",
1520 pdf_fmt_full(tot.code_lines)
1521 ),
1522 format!(
1523 "Comments: {cmt_pct:.1}% ({} lines)",
1524 pdf_fmt_full(tot.comment_lines)
1525 ),
1526 format!(
1527 "Blank: {blank_pct:.1}% ({} lines)",
1528 pdf_fmt_full(tot.blank_lines)
1529 ),
1530 ];
1531 if tot.functions > 0 {
1532 parts.push(format!("Functions: {}", pdf_fmt_full(tot.functions)));
1533 }
1534 if tot.mixed_lines_separate > 0 {
1535 parts.push(format!(
1536 "Mixed: {mixed_pct:.1}% ({} lines)",
1537 pdf_fmt_full(tot.mixed_lines_separate)
1538 ));
1539 }
1540 if tot.imports > 0 {
1541 parts.push(format!("Imports: {}", pdf_fmt_full(tot.imports)));
1542 }
1543 if tot.variables > 0 {
1544 parts.push(format!("Variables: {}", pdf_fmt_full(tot.variables)));
1545 }
1546 if tot.classes > 0 {
1547 parts.push(format!("Classes: {}", pdf_fmt_full(tot.classes)));
1548 }
1549 parts
1550}
1551
1552fn pdf_info_parts_git(run: &AnalysisRun) -> Vec<String> {
1553 let mut parts: Vec<String> = Vec::new();
1554 if let Some(ref b) = run.git_branch {
1555 parts.push(format!("Branch: {}", pdf_safe_str(b)));
1556 }
1557 if let Some(ref c) = run.git_commit_short {
1558 parts.push(format!("Commit: {}", pdf_safe_str(c)));
1559 }
1560 if let Some(ref t) = run.git_nearest_tag {
1561 parts.push(format!("Tag: {}", pdf_safe_str(t)));
1562 }
1563 if let Some(ref a) = run.git_commit_author {
1564 parts.push(format!("Author: {}", pdf_safe_str(a)));
1565 }
1566 if let Some(ref d) = run.git_commit_date {
1567 parts.push(format!("Commit Date: {}", fmt_commit_date_pt(d)));
1568 }
1569 parts
1570}
1571
1572#[allow(clippy::cast_precision_loss)]
1573fn pdf_info_parts_tests(tot: &SummaryTotals) -> Vec<String> {
1574 let mut tc: Vec<String> = Vec::new();
1575 if tot.test_count > 0 {
1576 tc.push(format!("Tests: {}", pdf_fmt_full(tot.test_count)));
1577 }
1578 if tot.test_assertion_count > 0 {
1579 tc.push(format!(
1580 "Assertions: {}",
1581 pdf_fmt_full(tot.test_assertion_count)
1582 ));
1583 }
1584 if tot.test_suite_count > 0 {
1585 tc.push(format!("Suites: {}", pdf_fmt_full(tot.test_suite_count)));
1586 }
1587 if tot.coverage_lines_found > 0 {
1588 tc.push(format!(
1589 "Line Cov: {:.1}% ({}/{})",
1590 tot.coverage_lines_hit as f64 / tot.coverage_lines_found as f64 * 100.0,
1591 pdf_fmt_full(tot.coverage_lines_hit),
1592 pdf_fmt_full(tot.coverage_lines_found)
1593 ));
1594 }
1595 if tot.coverage_functions_found > 0 {
1596 tc.push(format!(
1597 "Func Cov: {:.1}%",
1598 tot.coverage_functions_hit as f64 / tot.coverage_functions_found as f64 * 100.0
1599 ));
1600 }
1601 if tot.coverage_branches_found > 0 {
1602 tc.push(format!(
1603 "Branch Cov: {:.1}%",
1604 tot.coverage_branches_hit as f64 / tot.coverage_branches_found as f64 * 100.0
1605 ));
1606 }
1607 tc
1608}
1609
1610#[allow(clippy::many_single_char_names)]
1618fn pdf_info_emit_line(
1619 ctx: &PdfCtx<'_>,
1620 mut y: f32,
1621 r: f32,
1622 g: f32,
1623 b: f32,
1624 parts: &[String],
1625) -> f32 {
1626 use crate::pdf_compat::{Color, Mm, Rgb};
1627 const SIZE: f32 = 7.0;
1628 const LINE_GAP: f32 = 6.2;
1629 const SEP: &str = " | ";
1630 if parts.is_empty() {
1631 return y;
1632 }
1633 let usable = ctx.margin.mul_add(-2.0, ctx.w);
1634 let sep_w = helvetica_width_mm(SEP, SIZE, false);
1635 let group = Color::Rgb(Rgb::new(r, g, b, None));
1636 let sep_color = Color::Rgb(Rgb::new(0.66, 0.63, 0.60, None));
1637 let mut x = ctx.margin;
1638 let mut first_on_line = true;
1639 for part in parts {
1640 let (key, val) = match part.split_once(": ") {
1642 Some((k, v)) => (format!("{k}: "), v.to_string()),
1643 None => (part.clone(), String::new()),
1644 };
1645 let key_w = helvetica_width_mm(&key, SIZE, true);
1646 let val_w = helvetica_width_mm(&val, SIZE, false);
1647 let advance = if first_on_line {
1648 key_w + val_w
1649 } else {
1650 sep_w + key_w + val_w
1651 };
1652 if !first_on_line && x + advance > ctx.margin + usable {
1653 y -= LINE_GAP;
1654 x = ctx.margin;
1655 first_on_line = true;
1656 }
1657 if !first_on_line {
1658 ctx.layer.set_fill_color(sep_color.clone());
1659 ctx.layer.use_text(SEP, SIZE, Mm(x), Mm(y), ctx.font_reg);
1660 x += sep_w;
1661 }
1662 ctx.layer.set_fill_color(group.clone());
1663 ctx.layer
1664 .use_text(key.as_str(), SIZE, Mm(x), Mm(y), ctx.font_bold);
1665 x += key_w;
1666 if !val.is_empty() {
1667 ctx.layer
1668 .use_text(val.as_str(), SIZE, Mm(x), Mm(y), ctx.font_reg);
1669 x += val_w;
1670 }
1671 first_on_line = false;
1672 }
1673 y - LINE_GAP
1674}
1675
1676fn pdf_render_info_lines(ctx: &PdfCtx<'_>, run: &AnalysisRun, row2_bot: f32) -> f32 {
1677 let tot = &run.summary_totals;
1678 let mut y = row2_bot - 6.5;
1679 let stats = pdf_info_parts_stats(tot);
1680 y = pdf_info_emit_line(ctx, y, 0.15, 0.15, 0.15, &stats);
1681 let git = pdf_info_parts_git(run);
1683 if !git.is_empty() {
1684 y -= 1.6;
1685 y = pdf_info_emit_line(ctx, y, 0.10, 0.35, 0.15, &git);
1686 }
1687 let tests = pdf_info_parts_tests(tot);
1688 if !tests.is_empty() {
1689 y -= 1.6;
1690 y = pdf_info_emit_line(ctx, y, 0.15, 0.15, 0.50, &tests);
1691 }
1692 y
1693}
1694
1695#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
1696fn pdf_table_render_section(
1697 ctx: &PdfCtx<'_>,
1698 x: f32,
1699 top: f32,
1700 w: f32,
1701 lbl_frac: f32,
1702 title: &str,
1703 rows: &[(&str, String)],
1704) {
1705 use crate::pdf_compat::{Color, Mm, Rgb};
1706 pdf_fill_rect(
1707 ctx.layer,
1708 x,
1709 top - ctx.tbl_hdr_h,
1710 w,
1711 ctx.tbl_hdr_h,
1712 Rgb::new(0.098, 0.11, 0.15, None),
1713 );
1714 ctx.layer
1715 .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
1716 ctx.layer.use_text(
1717 title,
1718 7.0,
1719 Mm(x + 2.0),
1720 Mm(top - ctx.tbl_hdr_h + 1.5),
1721 ctx.font_bold,
1722 );
1723 let y = top - ctx.tbl_hdr_h;
1724 for (ri, (lbl, val)) in rows.iter().enumerate() {
1725 let ry = ((ri + 1) as f32).mul_add(-ctx.row_h, y);
1726 let bg = if ri % 2 == 0 {
1727 Rgb::new(0.975, 0.965, 0.95, None)
1728 } else {
1729 Rgb::new(1.0, 1.0, 1.0, None)
1730 };
1731 pdf_fill_rect(ctx.layer, x, ry, w, ctx.row_h, bg);
1732 ctx.layer
1733 .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
1734 ctx.layer
1735 .use_text(*lbl, 6.5, Mm(x + 2.0), Mm(ry + 1.5), ctx.font_reg);
1736 let is_dash = val == "--";
1737 let val_rgb = if is_dash {
1738 Rgb::new(0.55, 0.55, 0.55, None)
1739 } else {
1740 Rgb::new(0.12, 0.12, 0.12, None)
1741 };
1742 let val_font = if is_dash { ctx.font_reg } else { ctx.font_bold };
1743 ctx.layer.set_fill_color(Color::Rgb(val_rgb));
1744 ctx.layer.use_text(
1745 val.as_str(),
1746 6.5,
1747 Mm(x + w * lbl_frac + 2.0),
1748 Mm(ry + 1.5),
1749 val_font,
1750 );
1751 }
1752}
1753
1754#[allow(
1755 clippy::cast_precision_loss,
1756 clippy::suboptimal_flops,
1757 clippy::similar_names
1758)]
1759fn pdf_render_metric_tables(ctx: &PdfCtx<'_>, run: &AnalysisRun, tbl_top: f32) {
1760 let tot = &run.summary_totals;
1761 let half_w = (2.0f32.mul_add(-ctx.margin, ctx.w) - 4.0) / 2.0;
1762 let left_x = ctx.margin;
1763 let right_x = ctx.margin + half_w + 4.0;
1764 let lbl_frac: f32 = 0.68;
1765
1766 let files_rows: [(&str, String); 4] = [
1767 ("Files analyzed", pdf_fmt_full(tot.files_analyzed)),
1768 ("Files skipped", pdf_fmt_full(tot.files_skipped)),
1769 ("Files modified", "--".to_string()),
1770 ("Files unchanged", "--".to_string()),
1771 ];
1772 pdf_table_render_section(ctx, left_x, tbl_top, half_w, lbl_frac, "FILES", &files_rows);
1773
1774 let lc_rows: [(&str, String); 5] = [
1775 ("Physical lines", pdf_fmt_full(tot.total_physical_lines)),
1776 ("Code lines", pdf_fmt_full(tot.code_lines)),
1777 ("Comment lines", pdf_fmt_full(tot.comment_lines)),
1778 ("Blank lines", pdf_fmt_full(tot.blank_lines)),
1779 ("Mixed (separate)", pdf_fmt_full(tot.mixed_lines_separate)),
1780 ];
1781 let lc_top = tbl_top - ctx.tbl_hdr_h - (files_rows.len() as f32).mul_add(ctx.row_h, 3.0);
1782 pdf_table_render_section(
1783 ctx,
1784 left_x,
1785 lc_top,
1786 half_w,
1787 lbl_frac,
1788 "LINE COUNTS",
1789 &lc_rows,
1790 );
1791
1792 let cs_rows: [(&str, String); 4] = [
1793 ("Functions", pdf_fmt_full(tot.functions)),
1794 ("Classes / Types", pdf_fmt_full(tot.classes)),
1795 ("Variables", pdf_fmt_full(tot.variables)),
1796 ("Imports", pdf_fmt_full(tot.imports)),
1797 ];
1798 pdf_table_render_section(
1799 ctx,
1800 right_x,
1801 tbl_top,
1802 half_w,
1803 lbl_frac,
1804 "CODE STRUCTURE",
1805 &cs_rows,
1806 );
1807
1808 let lcs_rows: [(&str, String); 4] = [
1809 ("Lines added", "--".to_string()),
1810 ("Lines removed", "--".to_string()),
1811 ("Lines modified (net)", "--".to_string()),
1812 ("Lines unmodified", "--".to_string()),
1813 ];
1814 let lcs_top = tbl_top - ctx.tbl_hdr_h - (cs_rows.len() as f32).mul_add(ctx.row_h, 3.0);
1815 pdf_table_render_section(
1816 ctx,
1817 right_x,
1818 lcs_top,
1819 half_w,
1820 lbl_frac,
1821 "LINE CHANGE SUMMARY",
1822 &lcs_rows,
1823 );
1824}
1825
1826fn pdf_tc_title_bar(ctx: &PdfCtx<'_>, label: &str, y: f32) -> f32 {
1829 use crate::pdf_compat::{Color, Mm, Rgb};
1830 let tbl_w = 2.0_f32.mul_add(-ctx.margin, ctx.w);
1831 pdf_fill_rect(
1832 ctx.layer,
1833 ctx.margin,
1834 y - ctx.tbl_hdr_h,
1835 tbl_w,
1836 ctx.tbl_hdr_h,
1837 Rgb::new(0.098, 0.11, 0.15, None),
1838 );
1839 ctx.layer
1840 .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
1841 ctx.layer.use_text(
1842 label,
1843 7.0,
1844 Mm(ctx.margin + 2.0),
1845 Mm(y - ctx.tbl_hdr_h + 1.5),
1846 ctx.font_bold,
1847 );
1848 y - ctx.tbl_hdr_h
1849}
1850
1851fn pdf_row_bg(ri: usize) -> crate::pdf_compat::Rgb {
1853 use crate::pdf_compat::Rgb;
1854 if ri.is_multiple_of(2) {
1855 Rgb::new(0.975, 0.965, 0.95, None)
1856 } else {
1857 Rgb::new(1.0, 1.0, 1.0, None)
1858 }
1859}
1860
1861fn pdf_sub_sum(
1863 sub: &sloc_core::SubmoduleSummary,
1864 f: impl Fn(&sloc_core::LanguageSummary) -> u64,
1865) -> u64 {
1866 sub.language_summaries.iter().map(f).sum()
1867}
1868
1869#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
1871fn pdf_tc_stat_boxes(ctx: &PdfCtx<'_>, run: &AnalysisRun, has_cov: bool, mut y: f32) -> f32 {
1872 use crate::pdf_compat::{Color, Mm, Rgb};
1873 let gap: f32 = 4.0;
1874 let box_h: f32 = 15.0;
1875 let box_w = (ctx.w - 2.0 * ctx.margin - 3.0 * gap) / 4.0;
1876 let line_cov_str = if has_cov {
1877 let pct = run.summary_totals.coverage_lines_hit as f64
1878 / run.summary_totals.coverage_lines_found as f64
1879 * 100.0;
1880 format!("{pct:.1}%")
1881 } else {
1882 "\u{2014}".to_string()
1883 };
1884 let box_vals: [String; 4] = [
1885 pdf_fmt_full(run.summary_totals.test_count),
1886 pdf_fmt_full(run.summary_totals.test_assertion_count),
1887 pdf_fmt_full(run.summary_totals.test_suite_count),
1888 line_cov_str,
1889 ];
1890 let box_labels: [&str; 4] = [
1891 "Test Functions",
1892 "Test Assertions",
1893 "Test Suites",
1894 "Line Coverage",
1895 ];
1896 for (i, (label, val)) in box_labels.iter().zip(box_vals.iter()).enumerate() {
1897 let bx = ctx.margin + i as f32 * (box_w + gap);
1898 let by = y - box_h;
1899 pdf_fill_rect(
1900 ctx.layer,
1901 bx,
1902 by,
1903 box_w,
1904 box_h,
1905 Rgb::new(0.97, 0.96, 0.94, None),
1906 );
1907 ctx.layer
1908 .set_fill_color(Color::Rgb(Rgb::new(0.60, 0.40, 0.22, None)));
1909 ctx.layer
1910 .use_text(val.as_str(), 9.5, Mm(bx + 3.0), Mm(by + 7.5), ctx.font_bold);
1911 ctx.layer
1912 .set_fill_color(Color::Rgb(Rgb::new(0.50, 0.44, 0.40, None)));
1913 ctx.layer
1914 .use_text(*label, 5.5, Mm(bx + 3.0), Mm(by + 2.0), ctx.font_reg);
1915 }
1916 y -= box_h + 4.0;
1917 y
1918}
1919
1920#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
1922fn pdf_tc_submodules(ctx: &PdfCtx<'_>, run: &AnalysisRun, footer_h: f32, mut y: f32) -> f32 {
1923 use crate::pdf_compat::{Color, Mm, Rgb};
1924 let subs = &run.submodule_summaries;
1925 if subs.is_empty() {
1926 return y;
1927 }
1928 let margin = ctx.margin;
1929 let row_h = ctx.row_h;
1930 let tbl_w = ctx.w - 2.0 * margin;
1931
1932 let col_name = tbl_w * 0.40;
1933 let rem = tbl_w - col_name;
1934 let col_files = rem * 0.15;
1935 let col_code = rem * 0.20;
1936 let col_tests = rem * 0.20;
1937 let col_assert = rem * 0.20;
1938
1939 let cx_files = margin + col_name;
1940 let cx_code = cx_files + col_files;
1941 let cx_tests = cx_code + col_code;
1942 let cx_assert = cx_tests + col_tests;
1943 let cx_cov = cx_assert + col_assert;
1944
1945 y = pdf_tc_title_bar(ctx, "SUBMODULES", y);
1946
1947 pdf_fill_rect(
1948 ctx.layer,
1949 margin,
1950 y - row_h,
1951 tbl_w,
1952 row_h,
1953 Rgb::new(0.25, 0.27, 0.32, None),
1954 );
1955 ctx.layer
1956 .set_fill_color(Color::Rgb(Rgb::new(0.88, 0.88, 0.88, None)));
1957 for (lbl, x) in &[
1958 ("Submodule", margin + 2.0),
1959 ("Files", cx_files + 2.0),
1960 ("Code Lines", cx_code + 2.0),
1961 ("Test Functions", cx_tests + 2.0),
1962 ("Assertions", cx_assert + 2.0),
1963 ("Line Coverage %", cx_cov + 2.0),
1964 ] {
1965 ctx.layer
1966 .use_text(*lbl, 5.5, Mm(*x), Mm(y - row_h + 1.5), ctx.font_bold);
1967 }
1968 y -= row_h;
1969
1970 for (ri, sub) in subs.iter().enumerate() {
1971 if y < footer_h + row_h {
1972 break;
1973 }
1974 let sub_tests = pdf_sub_sum(sub, |l| l.test_count);
1975 let sub_assert = pdf_sub_sum(sub, |l| l.test_assertion_count);
1976 let sub_cov_hit = pdf_sub_sum(sub, |l| l.coverage_lines_hit);
1977 let sub_cov_found = pdf_sub_sum(sub, |l| l.coverage_lines_found);
1978 let sub_cov_str = if sub_cov_found > 0 {
1979 format!("{:.1}%", sub_cov_hit as f64 / sub_cov_found as f64 * 100.0)
1980 } else {
1981 "\u{2014}".to_string()
1982 };
1983
1984 let ry = y - row_h;
1985 pdf_fill_rect(ctx.layer, margin, ry, tbl_w, row_h, pdf_row_bg(ri));
1986 ctx.layer
1987 .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
1988 ctx.layer.use_text(
1989 pdf_trunc(&pdf_safe_str(&sub.name), 40),
1990 5.5,
1991 Mm(margin + 2.0),
1992 Mm(ry + 1.5),
1993 ctx.font_bold,
1994 );
1995 for (val, x) in &[
1996 (pdf_fmt_full(sub.files_analyzed), cx_files + 2.0),
1997 (pdf_fmt_full(sub.code_lines), cx_code + 2.0),
1998 (pdf_fmt_full(sub_tests), cx_tests + 2.0),
1999 (pdf_fmt_full(sub_assert), cx_assert + 2.0),
2000 (sub_cov_str, cx_cov + 2.0),
2001 ] {
2002 ctx.layer
2003 .use_text(val.as_str(), 5.5, Mm(*x), Mm(ry + 1.5), ctx.font_reg);
2004 }
2005 y -= row_h;
2006 }
2007 y - 3.0
2008}
2009
2010#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
2012fn pdf_tc_gauges(ctx: &PdfCtx<'_>, run: &AnalysisRun, mut y: f32) -> f32 {
2013 use crate::pdf_compat::{Color, Mm, Rgb};
2014 let margin = ctx.margin;
2015 let gap: f32 = 4.0;
2016 let gauges: &[(&str, u64, u64)] = &[
2017 (
2018 "Line Coverage",
2019 run.summary_totals.coverage_lines_hit,
2020 run.summary_totals.coverage_lines_found,
2021 ),
2022 (
2023 "Function Coverage",
2024 run.summary_totals.coverage_functions_hit,
2025 run.summary_totals.coverage_functions_found,
2026 ),
2027 (
2028 "Branch Coverage",
2029 run.summary_totals.coverage_branches_hit,
2030 run.summary_totals.coverage_branches_found,
2031 ),
2032 ];
2033 let visible: Vec<_> = gauges.iter().filter(|(_, _, found)| *found > 0).collect();
2034 if visible.is_empty() {
2035 return y;
2036 }
2037 let count = visible.len() as f32;
2038 let gauge_h: f32 = 16.0;
2039 let pad: f32 = 4.0;
2040 let bar_h: f32 = 3.0;
2041 let gauge_w = (ctx.w - 2.0 * margin - (count - 1.0) * gap) / count;
2042 let bar_w = gauge_w - 2.0 * pad;
2043 for (gi, (label, hit, found)) in visible.iter().enumerate() {
2044 let gx = margin + gi as f32 * (gauge_w + gap);
2045 let pct = *hit as f64 / *found as f64 * 100.0;
2046 let pct_str = format!("{pct:.1}%");
2047 #[allow(
2050 clippy::cast_possible_truncation,
2051 reason = "0..=100 percentage to f32 bar width"
2052 )]
2053 let bar_fill = bar_w * (pct as f32 / 100.0);
2054 let gy = y - gauge_h;
2055 pdf_fill_rect(
2057 ctx.layer,
2058 gx - 0.5,
2059 gy - 0.5,
2060 gauge_w + 1.0,
2061 gauge_h + 1.0,
2062 Rgb::new(0.80, 0.75, 0.68, None),
2063 );
2064 pdf_fill_rect(
2065 ctx.layer,
2066 gx,
2067 gy,
2068 gauge_w,
2069 gauge_h,
2070 Rgb::new(0.98, 0.97, 0.95, None),
2071 );
2072 ctx.layer
2074 .set_fill_color(Color::Rgb(Rgb::new(0.15, 0.15, 0.15, None)));
2075 ctx.layer.use_text(
2076 *label,
2077 6.0,
2078 Mm(gx + pad),
2079 Mm(gy + gauge_h - 4.5),
2080 ctx.font_bold,
2081 );
2082 ctx.layer
2083 .set_fill_color(Color::Rgb(Rgb::new(0.20, 0.55, 0.35, None)));
2084 ctx.layer.use_text(
2085 &pct_str,
2086 8.5,
2087 Mm(gx + pad),
2088 Mm(gy + bar_h + 3.0),
2089 ctx.font_bold,
2090 );
2091 pdf_fill_rect(
2092 ctx.layer,
2093 gx + pad,
2094 gy + pad * 0.5,
2095 bar_w,
2096 bar_h,
2097 Rgb::new(0.86, 0.84, 0.80, None),
2098 );
2099 if bar_fill > 0.0 {
2100 pdf_fill_rect(
2101 ctx.layer,
2102 gx + pad,
2103 gy + pad * 0.5,
2104 bar_fill,
2105 bar_h,
2106 Rgb::new(0.20, 0.55, 0.35, None),
2107 );
2108 }
2109 }
2110 y -= gauge_h + 5.0;
2111 y
2112}
2113
2114struct CovCols {
2116 has_fn_cov: bool,
2117 has_br_cov: bool,
2118 col_fn_w: f32,
2119 hdr_x2: f32,
2120}
2121
2122fn pdf_tc_per_file_header(
2124 ctx: &PdfCtx<'_>,
2125 has_fn_cov: bool,
2126 has_br_cov: bool,
2127 col_fn_w: f32,
2128 y: f32,
2129) -> (f32, CovCols) {
2130 use crate::pdf_compat::{Color, Mm, Rgb};
2131 let margin = ctx.margin;
2132 let col_br_w: f32 = if has_br_cov { 22.0 } else { 0.0 };
2133 let col_file_w = 2.0_f32.mul_add(-margin, ctx.w) - 22.0 - col_fn_w - col_br_w;
2134 let hdr_x2 = margin + col_file_w;
2135
2136 let y = pdf_tc_title_bar(ctx, "PER-FILE COVERAGE", y - 3.0);
2137 ctx.layer
2138 .set_fill_color(Color::Rgb(Rgb::new(0.55, 0.55, 0.55, None)));
2139 ctx.layer
2140 .use_text("Line%", 5.5, Mm(hdr_x2 + 2.0), Mm(y - 3.5), ctx.font_bold);
2141 if has_fn_cov {
2142 ctx.layer
2143 .use_text("Fn%", 5.5, Mm(hdr_x2 + 24.0), Mm(y - 3.5), ctx.font_bold);
2144 }
2145 if has_br_cov {
2146 ctx.layer.use_text(
2147 "Br%",
2148 5.5,
2149 Mm(hdr_x2 + 22.0 + col_fn_w + 2.0),
2150 Mm(y - 3.5),
2151 ctx.font_bold,
2152 );
2153 }
2154 (
2155 y - ctx.row_h,
2156 CovCols {
2157 has_fn_cov,
2158 has_br_cov,
2159 col_fn_w,
2160 hdr_x2,
2161 },
2162 )
2163}
2164
2165fn pdf_tc_per_file_row(ctx: &PdfCtx<'_>, file: &FileRecord, ri: usize, cols: &CovCols, ry: f32) {
2167 use crate::pdf_compat::{Color, Mm, Rgb};
2168 let (has_fn_cov, has_br_cov, col_fn_w, hdr_x2) =
2169 (cols.has_fn_cov, cols.has_br_cov, cols.col_fn_w, cols.hdr_x2);
2170 let Some(cov) = file.coverage.as_ref() else {
2171 return;
2172 };
2173 pdf_fill_rect(
2174 ctx.layer,
2175 ctx.margin,
2176 ry,
2177 2.0_f32.mul_add(-ctx.margin, ctx.w),
2178 ctx.row_h,
2179 pdf_row_bg(ri),
2180 );
2181 ctx.layer
2182 .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
2183 let fname = pdf_trunc(
2184 &pdf_safe_str(
2185 std::path::Path::new(&file.relative_path)
2186 .file_name()
2187 .and_then(|n| n.to_str())
2188 .unwrap_or(&file.relative_path),
2189 ),
2190 52,
2191 );
2192 ctx.layer.use_text(
2193 &fname,
2194 5.5,
2195 Mm(ctx.margin + 2.0),
2196 Mm(ry + 1.5),
2197 ctx.font_reg,
2198 );
2199 ctx.layer
2200 .set_fill_color(Color::Rgb(Rgb::new(0.10, 0.42, 0.25, None)));
2201 ctx.layer.use_text(
2202 format!("{:.1}%", cov.line_pct()),
2203 5.5,
2204 Mm(hdr_x2 + 2.0),
2205 Mm(ry + 1.5),
2206 ctx.font_bold,
2207 );
2208 if has_fn_cov && cov.functions_found > 0 {
2209 ctx.layer.use_text(
2210 format!("{:.1}%", cov.function_pct()),
2211 5.5,
2212 Mm(hdr_x2 + 24.0),
2213 Mm(ry + 1.5),
2214 ctx.font_bold,
2215 );
2216 }
2217 if has_br_cov && cov.branches_found > 0 {
2218 ctx.layer.use_text(
2219 format!("{:.1}%", cov.branch_pct()),
2220 5.5,
2221 Mm(hdr_x2 + 22.0 + col_fn_w + 2.0),
2222 Mm(ry + 1.5),
2223 ctx.font_bold,
2224 );
2225 }
2226}
2227
2228fn pdf_tc_per_file(
2230 ctx: &PdfCtx<'_>,
2231 run: &AnalysisRun,
2232 footer_h: f32,
2233 has_fn_cov: bool,
2234 has_br_cov: bool,
2235 mut y: f32,
2236) -> f32 {
2237 let cov_files: Vec<_> = run
2238 .per_file_records
2239 .iter()
2240 .filter(|r| r.coverage.is_some())
2241 .collect();
2242 if cov_files.is_empty() {
2243 return y;
2244 }
2245 let col_fn_w: f32 = if has_fn_cov { 22.0 } else { 0.0 };
2246 let (rows_start, cols) = pdf_tc_per_file_header(ctx, has_fn_cov, has_br_cov, col_fn_w, y);
2247 y = rows_start;
2248 for (ri, file) in cov_files.iter().enumerate() {
2249 if y < footer_h + ctx.row_h {
2250 break;
2251 }
2252 let ry = y - ctx.row_h;
2253 pdf_tc_per_file_row(ctx, file, ri, &cols, ry);
2254 y -= ctx.row_h;
2255 }
2256 y
2257}
2258
2259fn pdf_tc_no_coverage_note(ctx: &PdfCtx<'_>, mut y: f32) -> f32 {
2261 use crate::pdf_compat::{Color, Mm, Rgb};
2262 let margin = ctx.margin;
2263 let note_h: f32 = 12.0;
2264 pdf_fill_rect(
2265 ctx.layer,
2266 margin,
2267 y - note_h,
2268 2.0_f32.mul_add(-margin, ctx.w),
2269 note_h,
2270 Rgb::new(0.96, 0.95, 0.93, None),
2271 );
2272 ctx.layer
2273 .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.40, 0.37, None)));
2274 ctx.layer.use_text(
2275 "No code coverage data detected.",
2276 7.0,
2277 Mm(margin + 4.0),
2278 Mm(y - note_h + 7.0),
2279 ctx.font_bold,
2280 );
2281 ctx.layer.use_text(
2282 "Re-run with --lcov-path <file.info> to see per-file line, function, and branch coverage.",
2283 6.0,
2284 Mm(margin + 4.0),
2285 Mm(y - note_h + 2.5),
2286 ctx.font_reg,
2287 );
2288 y -= note_h;
2289 y
2290}
2291
2292fn pdf_render_tc_inline(ctx: &PdfCtx<'_>, run: &AnalysisRun, y_start: f32, footer_h: f32) -> f32 {
2295 let has_cov = run.summary_totals.coverage_lines_found > 0;
2296 let has_fn_cov = run.summary_totals.coverage_functions_found > 0;
2297 let has_br_cov = run.summary_totals.coverage_branches_found > 0;
2298
2299 let mut y = pdf_tc_title_bar(ctx, "TESTS & COVERAGE", y_start) - 4.0;
2300 y = pdf_tc_stat_boxes(ctx, run, has_cov, y);
2301 y = pdf_tc_submodules(ctx, run, footer_h, y);
2302
2303 if has_cov {
2304 y = pdf_tc_gauges(ctx, run, y);
2305 y = pdf_tc_per_file(ctx, run, footer_h, has_fn_cov, has_br_cov, y);
2306 } else {
2307 y = pdf_tc_no_coverage_note(ctx, y);
2308 }
2309 y
2310}
2311
2312fn pdf_page_header_meta(run: &AnalysisRun) -> String {
2315 let mut parts = vec![format!(
2316 "Run ID: {}",
2317 pdf_safe_str(&run.tool.run_id[..run.tool.run_id.len().min(20)])
2318 )];
2319 if let Some(ref c) = run.git_commit_short {
2320 parts.push(format!("Commit: {}", pdf_safe_str(c)));
2321 }
2322 parts.push(to_pt_hhmm(run.tool.timestamp_utc));
2323 parts.join(" \u{00B7} ")
2324}
2325
2326fn pdf_draw_header_meta(
2330 layer: &crate::pdf_compat::PdfLayerReference,
2331 font: crate::pdf_compat::IndirectFontRef,
2332 w: f32,
2333 margin: f32,
2334 baseline_y: f32,
2335 text: &str,
2336) {
2337 use crate::pdf_compat::{Color, Mm, Rgb};
2338 let tw = helvetica_width_mm(text, 6.5, false);
2339 let x = (w - margin - tw).max(margin + 60.0);
2340 layer.set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
2341 layer.use_text(text, 6.5, Mm(x), Mm(baseline_y), font);
2342}
2343
2344fn pdf_page_mini_header(ctx: &PdfCtx<'_>, h: f32, hdr_h: f32, title: &str, run: &AnalysisRun) {
2348 use crate::pdf_compat::{Color, Mm, Rgb};
2349 pdf_fill_rect(
2350 ctx.layer,
2351 0.0,
2352 h - hdr_h,
2353 ctx.w,
2354 hdr_h,
2355 Rgb::new(0.098, 0.11, 0.15, None),
2356 );
2357 ctx.layer
2358 .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
2359 ctx.layer.use_text(
2360 "oxide-sloc",
2361 9.0,
2362 Mm(ctx.margin),
2363 Mm(h - 5.5),
2364 ctx.font_bold,
2365 );
2366 ctx.layer
2367 .set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
2368 ctx.layer.use_text(
2369 pdf_trunc(&pdf_safe_str(title), 45),
2370 7.5,
2371 Mm(46.0),
2372 Mm(h - 5.5),
2373 ctx.font_reg,
2374 );
2375 pdf_draw_header_meta(
2376 ctx.layer,
2377 ctx.font_reg,
2378 ctx.w,
2379 ctx.margin,
2380 h - 5.5,
2381 &pdf_page_header_meta(run),
2382 );
2383}
2384
2385fn pdf_page_footer_band(ctx: &PdfCtx<'_>, footer_h: f32, version: &str) {
2388 use crate::pdf_compat::{Color, Mm, Rgb};
2389 pdf_fill_rect(
2390 ctx.layer,
2391 0.0,
2392 0.0,
2393 ctx.w,
2394 footer_h,
2395 Rgb::new(0.93, 0.91, 0.87, None),
2396 );
2397 ctx.layer
2398 .set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)));
2399 ctx.layer.use_text(
2400 format!("oxide-sloc v{version} \u{00b7} AGPL-3.0-or-later"),
2401 6.5,
2402 Mm(ctx.margin),
2403 Mm(3.0),
2404 ctx.font_reg,
2405 );
2406}
2407
2408#[allow(clippy::cast_precision_loss, clippy::too_many_arguments)]
2411fn pdf_render_tests_coverage_page(
2412 doc: &crate::pdf_compat::PdfDocumentReference,
2413 font_reg: crate::pdf_compat::IndirectFontRef,
2414 font_bold: crate::pdf_compat::IndirectFontRef,
2415 run: &AnalysisRun,
2416 w: f32,
2417 h: f32,
2418 margin: f32,
2419 footer_h: f32,
2420 title: &str,
2421 version: &str,
2422) -> (
2423 crate::pdf_compat::PdfPageIndex,
2424 crate::pdf_compat::PdfLayerIndex,
2425 f32,
2426) {
2427 use crate::pdf_compat::Mm;
2428 const HDR_H: f32 = 8.0;
2429
2430 let (tc_page, tc_layer_idx) = doc.add_page(Mm(w), Mm(h), "Tests & Coverage");
2431 let layer = doc.get_page(tc_page).get_layer(tc_layer_idx);
2432 let ctx = PdfCtx {
2433 layer: &layer,
2434 font_reg,
2435 font_bold,
2436 w,
2437 margin,
2438 row_h: 5.5,
2439 tbl_hdr_h: 6.0,
2440 };
2441
2442 pdf_page_mini_header(&ctx, h, HDR_H, title, run);
2443
2444 let tc_bottom = pdf_render_tc_inline(&ctx, run, h - HDR_H - 4.0, footer_h);
2446
2447 pdf_page_footer_band(&ctx, footer_h, version);
2448
2449 (tc_page, tc_layer_idx, tc_bottom - 3.0)
2450}
2451
2452#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
2453fn pdf_render_page1_footer(
2454 ctx: &PdfCtx<'_>,
2455 run: &AnalysisRun,
2456 footer_h: f32,
2457 version: &str,
2458 banner: Option<&str>,
2459) {
2460 use crate::pdf_compat::{Color, Mm, Rgb};
2461 pdf_fill_rect(
2462 ctx.layer,
2463 0.0,
2464 0.0,
2465 ctx.w,
2466 footer_h,
2467 Rgb::new(0.93, 0.91, 0.87, None),
2468 );
2469 ctx.layer
2470 .set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)));
2471 ctx.layer.use_text(
2473 format!("oxide-sloc v{version} | AGPL-3.0-or-later"),
2474 6.5,
2475 Mm(ctx.margin),
2476 Mm(3.0),
2477 ctx.font_reg,
2478 );
2479 let right_text = format!(
2481 "github.com/oxide-sloc/oxide-sloc | Run ID: {}",
2482 pdf_safe_str(&run.tool.run_id[..run.tool.run_id.len().min(20)])
2483 );
2484 let right_x = (ctx.w - ctx.margin - right_text.len() as f32 * 1.27).max(ctx.margin + 80.0);
2485 ctx.layer
2486 .use_text(right_text, 6.5, Mm(right_x), Mm(3.0), ctx.font_reg);
2487 if let Some(text) = banner {
2489 let safe = pdf_trunc(&pdf_safe_str(text), 40);
2490 let text_x = (ctx.w / 2.0 - safe.len() as f32 * 0.97).max(ctx.margin + 50.0);
2492 ctx.layer
2493 .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
2494 ctx.layer
2495 .use_text(safe, 9.0, Mm(text_x), Mm(2.6), ctx.font_bold);
2496 }
2497}
2498
2499fn per_file_row_bg(ri: usize) -> crate::pdf_compat::Rgb {
2500 if ri.is_multiple_of(2) {
2501 crate::pdf_compat::Rgb::new(0.975, 0.965, 0.95, None)
2502 } else {
2503 crate::pdf_compat::Rgb::new(1.0, 1.0, 1.0, None)
2504 }
2505}
2506
2507const PDF_PERFILE_HDR_H: f32 = 8.0;
2509const PDF_PERFILE_SUB_H: f32 = 5.5;
2510const PDF_PERFILE_TABLE_GAP: f32 = 3.0;
2513
2514struct PdfPerFileCtx<'a> {
2517 doc: &'a crate::pdf_compat::PdfDocumentReference,
2518 font_reg: crate::pdf_compat::IndirectFontRef,
2519 font_bold: crate::pdf_compat::IndirectFontRef,
2520 w: f32,
2521 h: f32,
2522 margin: f32,
2523}
2524
2525fn pdf_perfile_page_slice(
2527 page_idx: usize,
2528 use_continuation: bool,
2529 has_first_page: bool,
2530 fp_rows: usize,
2531 rows_per_page: usize,
2532 total_files: usize,
2533) -> (usize, usize) {
2534 if use_continuation {
2535 (0, fp_rows.min(total_files))
2536 } else if has_first_page {
2537 let s = fp_rows + (page_idx - 1) * rows_per_page;
2538 (s, (s + rows_per_page).min(total_files))
2539 } else {
2540 let s = page_idx * rows_per_page;
2541 (s, (s + rows_per_page).min(total_files))
2542 }
2543}
2544
2545#[allow(clippy::suboptimal_flops, clippy::cast_precision_loss)]
2551fn pdf_draw_perfile_header(
2552 ctx: &PdfPerFileCtx<'_>,
2553 use_continuation: bool,
2554 first_page: Option<(
2555 crate::pdf_compat::PdfPageIndex,
2556 crate::pdf_compat::PdfLayerIndex,
2557 f32,
2558 )>,
2559 page_idx: usize,
2560 page_count: usize,
2561 banner: Option<&str>,
2562 meta: &str,
2563) -> (crate::pdf_compat::PdfLayerReference, f32) {
2564 use crate::pdf_compat::{Color, Mm, Rgb};
2565 if use_continuation {
2566 let (fp_page, fp_layer_idx, fp_top) = first_page.unwrap();
2567 let layer = ctx.doc.get_page(fp_page).get_layer(fp_layer_idx);
2568 (layer, fp_top - PDF_PERFILE_SUB_H)
2569 } else {
2570 let (pf_page, pf_layer_idx) = ctx.doc.add_page(Mm(ctx.w), Mm(ctx.h), "Content");
2571 let layer = ctx.doc.get_page(pf_page).get_layer(pf_layer_idx);
2572 let hdr_top = ctx.h - PDF_PERFILE_HDR_H;
2573 pdf_fill_rect(
2574 &layer,
2575 0.0,
2576 hdr_top,
2577 ctx.w,
2578 PDF_PERFILE_HDR_H,
2579 Rgb::new(0.098, 0.11, 0.15, None),
2580 );
2581 layer.set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
2582 layer.use_text(
2583 "oxide-sloc",
2584 9.0,
2585 Mm(ctx.margin),
2586 Mm(hdr_top + 2.5),
2587 ctx.font_bold,
2588 );
2589 layer.set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
2590 layer.use_text(
2591 "Per-File Detail",
2592 8.0,
2593 Mm(46.0),
2594 Mm(hdr_top + 2.5),
2595 ctx.font_reg,
2596 );
2597 let right = format!(
2599 "{meta} \u{00B7} Page {} of {}",
2600 page_idx + 2,
2601 page_count + 1
2602 );
2603 let right_w = helvetica_width_mm(&right, 6.5, false);
2604 let right_x = (ctx.w - ctx.margin - right_w).max(ctx.margin + 60.0);
2605 layer.use_text(right, 6.5, Mm(right_x), Mm(hdr_top + 2.5), ctx.font_reg);
2606 if let Some(text) = banner {
2607 let safe = pdf_trunc(&pdf_safe_str(text), 40);
2608 let text_x = (ctx.w / 2.0 - safe.len() as f32 * 0.97).max(80.0);
2609 layer.set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
2610 layer.use_text(safe, 9.0, Mm(text_x), Mm(hdr_top + 2.5), ctx.font_bold);
2611 }
2612 (layer, hdr_top - PDF_PERFILE_TABLE_GAP - PDF_PERFILE_SUB_H)
2614 }
2615}
2616
2617#[allow(clippy::suboptimal_flops, clippy::cast_precision_loss)]
2619fn pdf_draw_perfile_rows(
2620 ctx: &PdfCtx<'_>,
2621 records: &[FileRecord],
2622 col_x: &[f32; 13],
2623 pf_tbl_top: f32,
2624) {
2625 use crate::pdf_compat::{Color, Mm, Rgb};
2626 for (ri, rec) in records.iter().enumerate() {
2627 let ry = ((ri + 1) as f32).mul_add(-ctx.row_h, pf_tbl_top - ctx.tbl_hdr_h);
2628 let bg = per_file_row_bg(ri);
2629 pdf_fill_rect(
2630 ctx.layer,
2631 ctx.margin,
2632 ry,
2633 2.0f32.mul_add(-ctx.margin, ctx.w),
2634 ctx.row_h,
2635 bg,
2636 );
2637 ctx.layer
2638 .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
2639 let file_str = pdf_safe_str(&rec.relative_path);
2640 let lang_str = rec
2641 .language
2642 .as_ref()
2643 .map_or_else(|| "--".to_string(), |l| l.display_name().to_string());
2644 let raw = &rec.raw_line_categories;
2645 let eff = &rec.effective_counts;
2646 let cells = [
2647 pdf_trunc_end(&file_str, 110),
2648 lang_str,
2649 pdf_fmt_full(raw.total_physical_lines),
2650 pdf_fmt_full(eff.code_lines),
2651 pdf_fmt_full(eff.comment_lines),
2652 pdf_fmt_full(eff.blank_lines),
2653 pdf_fmt_full(eff.mixed_lines_separate),
2654 pdf_fmt_full(raw.functions),
2655 pdf_fmt_full(raw.classes),
2656 pdf_fmt_full(raw.variables),
2657 pdf_fmt_full(raw.imports),
2658 pdf_fmt_full(raw.test_count),
2659 pdf_fmt_full(raw.test_assertion_count),
2660 ];
2661 for (ci, cell) in cells.iter().enumerate() {
2662 ctx.layer.use_text(
2663 cell.clone(),
2664 5.5,
2665 Mm(col_x[ci] + 0.5),
2666 Mm(ry + 1.0),
2667 ctx.font_reg,
2668 );
2669 }
2670 }
2671}
2672
2673#[allow(
2675 clippy::cast_precision_loss,
2676 clippy::cast_possible_truncation,
2677 clippy::cast_sign_loss,
2678 clippy::too_many_arguments,
2679 clippy::too_many_lines,
2680 clippy::suboptimal_flops
2681)]
2682fn pdf_render_per_file_pages(
2683 doc: &crate::pdf_compat::PdfDocumentReference,
2684 font_reg: crate::pdf_compat::IndirectFontRef,
2685 font_bold: crate::pdf_compat::IndirectFontRef,
2686 run: &AnalysisRun,
2687 w: f32,
2688 h: f32,
2689 margin: f32,
2690 footer_h: f32,
2691 row_h: f32,
2692 tbl_hdr_h: f32,
2693 title: &str,
2694 ts: &str,
2695 version: &str,
2696 banner: Option<&str>,
2697 first_page: Option<(
2700 crate::pdf_compat::PdfPageIndex,
2701 crate::pdf_compat::PdfLayerIndex,
2702 f32,
2703 )>,
2704) {
2705 use crate::pdf_compat::{Color, Mm, Rgb};
2706 let col_x: [f32; 13] = [
2710 10.0, 146.0, 160.0, 172.0, 182.0, 195.0, 205.0, 215.0, 228.0, 239.0, 252.0, 263.0, 273.0,
2711 ];
2712 let col_labels: [&str; 13] = [
2713 "File",
2714 "Language",
2715 "Physical",
2716 "Code",
2717 "Comments",
2718 "Blank",
2719 "Mixed",
2720 "Functions",
2721 "Classes",
2722 "Variables",
2723 "Imports",
2724 "Tests",
2725 "Assertions",
2726 ];
2727 let rows_per_page =
2728 ((h - PDF_PERFILE_HDR_H - PDF_PERFILE_SUB_H - PDF_PERFILE_TABLE_GAP - tbl_hdr_h - footer_h)
2729 / row_h)
2730 .floor() as usize;
2731 let total_files = run.per_file_records.len();
2732
2733 let fp_rows = match first_page {
2735 Some((_, _, fp_top)) => ((fp_top - PDF_PERFILE_SUB_H - tbl_hdr_h - footer_h) / row_h)
2736 .floor()
2737 .max(0.0) as usize,
2738 None => rows_per_page,
2739 };
2740 let page_count = if first_page.is_some() {
2741 1 + total_files.saturating_sub(fp_rows).div_ceil(rows_per_page)
2742 } else {
2743 total_files.div_ceil(rows_per_page)
2744 };
2745 let pf_ctx = PdfPerFileCtx {
2746 doc,
2747 font_reg,
2748 font_bold,
2749 w,
2750 h,
2751 margin,
2752 };
2753 let header_meta = pdf_page_header_meta(run);
2754
2755 for page_idx in 0..page_count {
2756 let use_continuation = page_idx == 0 && first_page.is_some();
2757 let (pf_layer, sub_top) = pdf_draw_perfile_header(
2758 &pf_ctx,
2759 use_continuation,
2760 first_page,
2761 page_idx,
2762 page_count,
2763 banner,
2764 &header_meta,
2765 );
2766
2767 pdf_fill_rect(
2769 &pf_layer,
2770 margin,
2771 sub_top,
2772 w - 2.0 * margin,
2773 PDF_PERFILE_SUB_H,
2774 Rgb::new(0.098, 0.11, 0.15, None),
2775 );
2776 pf_layer.set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
2777 pf_layer.use_text(
2778 "PER-FILE DETAIL",
2779 7.0,
2780 Mm(margin + 2.0),
2781 Mm(sub_top + 1.5),
2782 font_bold,
2783 );
2784 if use_continuation {
2785 let right = format!(
2787 "{} | {} files | {ts}",
2788 pdf_trunc(title, 30),
2789 total_files
2790 );
2791 pf_layer.set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
2792 let right_x = (w - margin - right.len() as f32 * 1.05).max(margin + 80.0);
2793 pf_layer.use_text(right, 5.5, Mm(right_x), Mm(sub_top + 1.5), font_reg);
2794 } else {
2795 pf_layer.set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
2796 pf_layer.use_text(
2797 pdf_trunc(title, 45),
2798 5.5,
2799 Mm(margin + 60.0),
2800 Mm(sub_top + 1.5),
2801 font_reg,
2802 );
2803 pf_layer.use_text(
2804 format!("{total_files} files | {ts}"),
2805 5.5,
2806 Mm(w - margin - 55.0),
2807 Mm(sub_top + 1.5),
2808 font_reg,
2809 );
2810 }
2811
2812 let pf_tbl_top = sub_top;
2813 pdf_fill_rect(
2814 &pf_layer,
2815 margin,
2816 pf_tbl_top - tbl_hdr_h,
2817 2.0f32.mul_add(-margin, w),
2818 tbl_hdr_h,
2819 Rgb::new(0.098, 0.11, 0.15, None),
2820 );
2821 pf_layer.set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
2822 for (i, lbl) in col_labels.iter().enumerate() {
2823 pf_layer.use_text(
2824 *lbl,
2825 5.0,
2826 Mm(col_x[i] + 0.5),
2827 Mm(pf_tbl_top - tbl_hdr_h + 1.5),
2828 font_bold,
2829 );
2830 }
2831
2832 let (start, end) = pdf_perfile_page_slice(
2833 page_idx,
2834 use_continuation,
2835 first_page.is_some(),
2836 fp_rows,
2837 rows_per_page,
2838 total_files,
2839 );
2840 let row_ctx = PdfCtx {
2841 layer: &pf_layer,
2842 font_reg,
2843 font_bold,
2844 w,
2845 margin,
2846 row_h,
2847 tbl_hdr_h,
2848 };
2849 pdf_draw_perfile_rows(
2850 &row_ctx,
2851 &run.per_file_records[start..end],
2852 &col_x,
2853 pf_tbl_top,
2854 );
2855
2856 pdf_fill_rect(
2858 &pf_layer,
2859 0.0,
2860 0.0,
2861 w,
2862 footer_h,
2863 Rgb::new(0.93, 0.91, 0.87, None),
2864 );
2865 pf_layer.set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)));
2866 pf_layer.use_text(
2867 format!("oxide-sloc v{version} | AGPL-3.0-or-later"),
2868 6.5,
2869 Mm(margin),
2870 Mm(3.0),
2871 font_reg,
2872 );
2873 let right_text = format!(
2874 "github.com/oxide-sloc/oxide-sloc | Run ID: {}",
2875 pdf_safe_str(&run.tool.run_id[..run.tool.run_id.len().min(20)])
2876 );
2877 let right_x = (w - margin - right_text.len() as f32 * 1.27).max(margin + 80.0);
2878 pf_layer.use_text(right_text, 6.5, Mm(right_x), Mm(3.0), font_reg);
2879 if let Some(text) = banner {
2881 let safe = pdf_trunc(&pdf_safe_str(text), 40);
2882 let text_x = (w / 2.0 - safe.len() as f32 * 0.97).max(margin + 50.0);
2883 pf_layer.set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
2884 pf_layer.use_text(safe, 9.0, Mm(text_x), Mm(2.6), font_bold);
2885 }
2886 }
2887}
2888
2889fn pdf_section_header_bar(
2893 ctx: &PdfCtx<'_>,
2894 usable_w: f32,
2895 section_top: f32,
2896 hdr_h: f32,
2897 title: &str,
2898) {
2899 use crate::pdf_compat::{Color, Mm, Rgb};
2900 pdf_fill_rect(
2901 ctx.layer,
2902 ctx.margin,
2903 section_top - hdr_h,
2904 usable_w,
2905 hdr_h,
2906 Rgb::new(0.098, 0.11, 0.15, None),
2907 );
2908 ctx.layer
2909 .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
2910 ctx.layer.use_text(
2911 title,
2912 7.0,
2913 Mm(ctx.margin + 2.0),
2914 Mm(section_top - hdr_h + 1.5),
2915 ctx.font_bold,
2916 );
2917}
2918
2919#[allow(
2925 clippy::cast_precision_loss,
2926 clippy::cast_possible_truncation,
2927 clippy::too_many_lines,
2928 clippy::suboptimal_flops
2929)]
2930fn pdf_render_style_section(ctx: &PdfCtx<'_>, ss: &StyleSummary, section_top: f32) -> f32 {
2931 use crate::pdf_compat::{Color, Mm, Rgb};
2932 const HDR_H: f32 = 5.5;
2933 const CHIP_H: f32 = 11.0;
2934 const CHIP_GAP: f32 = 4.0;
2935 const ROW_H: f32 = 5.0;
2936 const TBL_HDR_H: f32 = 5.0;
2937 const GAP: f32 = 2.5;
2938
2939 let usable_w = ctx.w - 2.0 * ctx.margin;
2940 let chip_w = (usable_w - 3.0 * CHIP_GAP) / 4.0;
2941
2942 pdf_section_header_bar(ctx, usable_w, section_top, HDR_H, "CODE STYLE ANALYSIS");
2944 let col_label = format!("{}-Col", ss.col_threshold);
2945 ctx.layer
2946 .set_fill_color(Color::Rgb(Rgb::new(0.85, 0.65, 0.35, None)));
2947 ctx.layer.use_text(
2948 "Lexical heuristics",
2949 5.5,
2950 Mm(ctx.w - ctx.margin - 26.0),
2951 Mm(section_top - HDR_H + 1.5),
2952 ctx.font_reg,
2953 );
2954
2955 let chips_bot = section_top - HDR_H - GAP - CHIP_H;
2957 let chip_data: [(&str, String); 4] = [
2958 ("Files Analyzed", ss.files_analyzed.to_string()),
2959 ("Language Groups", ss.by_language.len().to_string()),
2960 ("Common Indent", ss.common_indent_style.clone()),
2961 (&col_label, format!("{}%", ss.line_col_compliant_pct)),
2962 ];
2963 for (i, (label, value)) in chip_data.iter().enumerate() {
2964 let cx = (i as f32).mul_add(chip_w + CHIP_GAP, ctx.margin);
2965 pdf_fill_rect(
2966 ctx.layer,
2967 cx,
2968 chips_bot,
2969 chip_w,
2970 CHIP_H,
2971 Rgb::new(0.945, 0.925, 0.90, None),
2972 );
2973 ctx.layer
2974 .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
2975 ctx.layer.use_text(
2976 pdf_trunc(value, 16),
2977 10.0,
2978 Mm(cx + 3.0),
2979 Mm(chips_bot + 5.5),
2980 ctx.font_bold,
2981 );
2982 ctx.layer
2983 .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
2984 ctx.layer.use_text(
2985 pdf_safe_str(label),
2986 5.5,
2987 Mm(cx + 3.0),
2988 Mm(chips_bot + 1.5),
2989 ctx.font_reg,
2990 );
2991 }
2992
2993 if ss.by_language.is_empty() {
2995 return chips_bot;
2996 }
2997 let tbl_top = chips_bot - GAP;
2998
2999 let col_w = [0.28_f32, 0.08, 0.36, 0.14, 0.14];
3001 let col_x: Vec<f32> = col_w
3002 .iter()
3003 .scan(ctx.margin, |acc, &w| {
3004 let x = *acc;
3005 *acc += w * usable_w;
3006 Some(x)
3007 })
3008 .collect();
3009 let headers = ["Language Family", "Files", "Top Guide", "Score", &col_label];
3010
3011 pdf_fill_rect(
3012 ctx.layer,
3013 ctx.margin,
3014 tbl_top - TBL_HDR_H,
3015 usable_w,
3016 TBL_HDR_H,
3017 Rgb::new(0.098, 0.11, 0.15, None),
3018 );
3019 ctx.layer
3020 .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
3021 for (hi, hdr) in headers.iter().enumerate() {
3022 ctx.layer.use_text(
3023 pdf_safe_str(hdr),
3024 5.5,
3025 Mm(col_x[hi] + 2.0),
3026 Mm(tbl_top - TBL_HDR_H + 1.5),
3027 ctx.font_bold,
3028 );
3029 }
3030
3031 let mut row_y = tbl_top - TBL_HDR_H;
3032 for (ri, grp) in ss.by_language.iter().take(5).enumerate() {
3033 let ry = row_y - ROW_H;
3034 let bg = if ri % 2 == 0 {
3035 Rgb::new(0.975, 0.965, 0.95, None)
3036 } else {
3037 Rgb::new(1.0, 1.0, 1.0, None)
3038 };
3039 pdf_fill_rect(ctx.layer, ctx.margin, ry, usable_w, ROW_H, bg);
3040 ctx.layer
3041 .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
3042 let cells = [
3043 pdf_trunc(&grp.language_family, 26),
3044 grp.files_count.to_string(),
3045 pdf_trunc(&grp.dominant_guide, 28),
3046 format!("{}%", grp.dominant_score_pct),
3047 format!("{}%", grp.line_col_compliant_pct),
3048 ];
3049 for (ci, cell) in cells.iter().enumerate() {
3050 let is_score = ci == 3 || ci == 4;
3051 if is_score && cell != "--" {
3052 ctx.layer
3053 .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
3054 } else {
3055 ctx.layer
3056 .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
3057 }
3058 ctx.layer.use_text(
3059 pdf_safe_str(cell),
3060 6.0,
3061 Mm(col_x[ci] + 2.0),
3062 Mm(ry + 1.5),
3063 ctx.font_reg,
3064 );
3065 }
3066 row_y = ry;
3067 }
3068
3069 row_y
3070}
3071
3072#[allow(clippy::cast_precision_loss)]
3075fn pdf_render_cocomo_section(ctx: &PdfCtx<'_>, run: &AnalysisRun, section_top: f32) -> f32 {
3076 use crate::pdf_compat::{Color, Mm, Rgb};
3077 const HDR_H: f32 = 5.5;
3078 const ROW_H: f32 = 13.0; const NOTE_H: f32 = 2.0; const GAP: f32 = 5.0; let Some(ref c) = run.cocomo else {
3083 return section_top;
3084 };
3085
3086 let mode_label = match c.mode {
3087 CocomoMode::Organic => "Organic",
3088 CocomoMode::SemiDetached => "Semi-detached",
3089 CocomoMode::Embedded => "Embedded",
3090 };
3091 let usable_w = 2.0_f32.mul_add(-ctx.margin, ctx.w);
3092
3093 pdf_section_header_bar(
3095 ctx,
3096 usable_w,
3097 section_top,
3098 HDR_H,
3099 "CONSTRUCTIVE COST MODEL (COCOMO I) ESTIMATE",
3100 );
3101 ctx.layer
3102 .set_fill_color(Color::Rgb(Rgb::new(0.85, 0.65, 0.35, None)));
3103 let mode_display = format!("{mode_label} mode");
3104 ctx.layer.use_text(
3105 mode_display.as_str(),
3106 5.5,
3107 Mm(ctx.w - ctx.margin - 28.0),
3108 Mm(section_top - HDR_H + 1.5),
3109 ctx.font_reg,
3110 );
3111
3112 let col_w = usable_w / 4.0;
3114 let row_y = section_top - HDR_H - ROW_H;
3115 let data: [(&str, String); 4] = [
3116 ("Person-months", format!("{:.2}", c.effort_person_months)),
3117 ("Schedule (months)", format!("{:.2}", c.duration_months)),
3118 ("Avg. Team Size", format!("{:.2}", c.avg_staff)),
3119 ("Input KSLOC", format!("{:.2}K", c.ksloc)),
3120 ];
3121 for (i, (label, value)) in data.iter().enumerate() {
3122 let cx = (i as f32).mul_add(col_w, ctx.margin);
3123 let bg = if i % 2 == 0 {
3124 Rgb::new(0.975, 0.965, 0.95, None)
3125 } else {
3126 Rgb::new(1.0, 1.0, 1.0, None)
3127 };
3128 pdf_fill_rect(ctx.layer, cx, row_y, col_w, ROW_H, bg);
3129 ctx.layer
3130 .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
3131 ctx.layer
3132 .use_text(*label, 5.5, Mm(cx + 2.0), Mm(row_y + 9.0), ctx.font_reg);
3133 ctx.layer
3134 .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
3135 ctx.layer.use_text(
3136 value.as_str(),
3137 10.0,
3138 Mm(cx + 2.0),
3139 Mm(row_y + 2.5),
3140 ctx.font_bold,
3141 );
3142 }
3143
3144 let note_y = row_y - GAP;
3146 ctx.layer
3147 .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
3148 ctx.layer.use_text(
3149 "COCOMO I (Boehm, 1981): algorithmic model converting SLOC into effort, schedule, and team-size estimates. \
3150 Ballpark figures only - actual outcomes vary with team experience and domain complexity.",
3151 5.5,
3152 Mm(ctx.margin),
3153 Mm(note_y),
3154 ctx.font_reg,
3155 );
3156
3157 note_y - NOTE_H
3158}
3159
3160fn pdf_text_right(ctx: &PdfCtx<'_>, text: &str, pt: f32, x_right: f32, y: f32, bold: bool) {
3163 use crate::pdf_compat::Mm;
3164 let font = if bold { ctx.font_bold } else { ctx.font_reg };
3165 let w = helvetica_width_mm(text, pt, bold);
3166 ctx.layer.use_text(text, pt, Mm(x_right - w), Mm(y), font);
3167}
3168
3169fn pdf_fit_path(path: &str, budget_mm: f32, pt: f32) -> String {
3172 if helvetica_width_mm(path, pt, false) <= budget_mm {
3173 return path.to_string();
3174 }
3175 let mut chars: Vec<char> = path.chars().collect();
3176 while !chars.is_empty() {
3177 chars.remove(0);
3178 let candidate: String = format!("...{}", chars.iter().collect::<String>());
3179 if helvetica_width_mm(&candidate, pt, false) <= budget_mm {
3180 return candidate;
3181 }
3182 }
3183 "...".to_string()
3184}
3185
3186fn pdf_render_hotspots_section(ctx: &PdfCtx<'_>, rows: &[HotspotRow], section_top: f32) -> f32 {
3190 use crate::pdf_compat::{Color, Mm, Rgb};
3191 const HDR_H: f32 = 5.5;
3192 const COLHDR_H: f32 = 5.0;
3193 const ROW_H: f32 = 5.2;
3194 const NOTE_GAP: f32 = 4.0;
3195
3196 let usable_w = 2.0_f32.mul_add(-ctx.margin, ctx.w);
3197
3198 pdf_section_header_bar(
3200 ctx,
3201 usable_w,
3202 section_top,
3203 HDR_H,
3204 "GIT HOTSPOTS (CODE LINES x RECENT COMMITS)",
3205 );
3206
3207 let col_last_r = ctx.w - ctx.margin;
3209 let col_score_r = col_last_r - 32.0;
3210 let col_commits_r = col_score_r - 33.0;
3211 let col_code_r = col_commits_r - 32.0;
3212 let file_x = ctx.margin + 2.0;
3213 let file_budget = (col_code_r - 26.0) - file_x;
3214
3215 let chdr_y = section_top - HDR_H - COLHDR_H;
3217 pdf_fill_rect(
3218 ctx.layer,
3219 ctx.margin,
3220 chdr_y,
3221 usable_w,
3222 COLHDR_H,
3223 Rgb::new(0.90, 0.88, 0.84, None),
3224 );
3225 ctx.layer
3226 .set_fill_color(Color::Rgb(Rgb::new(0.30, 0.30, 0.30, None)));
3227 ctx.layer
3228 .use_text("File", 6.0, Mm(file_x), Mm(chdr_y + 1.4), ctx.font_bold);
3229 pdf_text_right(ctx, "Code lines", 6.0, col_code_r, chdr_y + 1.4, true);
3230 pdf_text_right(ctx, "Commits", 6.0, col_commits_r, chdr_y + 1.4, true);
3231 pdf_text_right(ctx, "Hotspot score", 6.0, col_score_r, chdr_y + 1.4, true);
3232 pdf_text_right(ctx, "Last changed", 6.0, col_last_r, chdr_y + 1.4, true);
3233
3234 let mut y = chdr_y;
3236 for (ri, hrow) in rows.iter().enumerate() {
3237 y -= ROW_H;
3238 let bg = if ri.is_multiple_of(2) {
3239 Rgb::new(0.975, 0.965, 0.95, None)
3240 } else {
3241 Rgb::new(1.0, 1.0, 1.0, None)
3242 };
3243 pdf_fill_rect(ctx.layer, ctx.margin, y, usable_w, ROW_H, bg);
3244 ctx.layer
3246 .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
3247 let path = pdf_fit_path(&pdf_safe_str(&hrow.path), file_budget, 6.0);
3248 ctx.layer
3249 .use_text(path, 6.0, Mm(file_x), Mm(y + 1.4), ctx.font_reg);
3250 ctx.layer
3252 .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
3253 pdf_text_right(
3254 ctx,
3255 &group_thousands(&hrow.code_lines.to_string()),
3256 6.0,
3257 col_code_r,
3258 y + 1.4,
3259 false,
3260 );
3261 pdf_text_right(
3262 ctx,
3263 &hrow.commit_count.to_string(),
3264 6.0,
3265 col_commits_r,
3266 y + 1.4,
3267 false,
3268 );
3269 ctx.layer
3271 .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
3272 pdf_text_right(
3273 ctx,
3274 &group_thousands(&hrow.score.to_string()),
3275 6.0,
3276 col_score_r,
3277 y + 1.4,
3278 true,
3279 );
3280 ctx.layer
3281 .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
3282 pdf_text_right(ctx, &hrow.last_commit_date, 6.0, col_last_r, y + 1.4, false);
3283 }
3284
3285 let note_y = y - NOTE_GAP;
3287 ctx.layer
3288 .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
3289 ctx.layer.use_text(
3290 "Files ranked by code lines x commits over the configured git activity window. \
3291 Distinct from the Compare page's scan-to-scan churn rate.",
3292 5.5,
3293 Mm(ctx.margin),
3294 Mm(note_y + 1.0),
3295 ctx.font_reg,
3296 );
3297
3298 note_y
3299}
3300
3301#[allow(clippy::too_many_arguments)]
3304fn pdf_render_hotspots_page(
3305 doc: &crate::pdf_compat::PdfDocumentReference,
3306 font_reg: crate::pdf_compat::IndirectFontRef,
3307 font_bold: crate::pdf_compat::IndirectFontRef,
3308 run: &AnalysisRun,
3309 rows: &[HotspotRow],
3310 w: f32,
3311 h: f32,
3312 margin: f32,
3313 footer_h: f32,
3314 title: &str,
3315 version: &str,
3316) -> (
3317 crate::pdf_compat::PdfPageIndex,
3318 crate::pdf_compat::PdfLayerIndex,
3319 f32,
3320) {
3321 use crate::pdf_compat::Mm;
3322 const HDR_H: f32 = 8.0;
3323
3324 let (page, layer_idx) = doc.add_page(Mm(w), Mm(h), "Git Hotspots");
3325 let layer = doc.get_page(page).get_layer(layer_idx);
3326 let ctx = PdfCtx {
3327 layer: &layer,
3328 font_reg,
3329 font_bold,
3330 w,
3331 margin,
3332 row_h: 5.5,
3333 tbl_hdr_h: 6.0,
3334 };
3335
3336 pdf_page_mini_header(&ctx, h, HDR_H, title, run);
3337
3338 let bottom = pdf_render_hotspots_section(&ctx, rows, h - HDR_H - 4.0);
3339
3340 pdf_page_footer_band(&ctx, footer_h, version);
3341
3342 (page, layer_idx, bottom - 3.0)
3343}
3344
3345#[allow(clippy::too_many_arguments)]
3354fn measure_terminal_tc_page_height(
3355 run: &AnalysisRun,
3356 w: f32,
3357 h_full: f32,
3358 margin: f32,
3359 footer_h: f32,
3360 row_h: f32,
3361 tbl_hdr_h: f32,
3362 with_cocomo: bool,
3363) -> f32 {
3364 use crate::pdf_compat::{BuiltinFont, Mm, PdfDocument};
3365 let measure = || -> Option<f32> {
3366 let (doc, page, layer_idx) = PdfDocument::new("measure", Mm(w), Mm(h_full), "m");
3367 let font_reg = doc.add_builtin_font(BuiltinFont::Helvetica).ok()?;
3368 let font_bold = doc.add_builtin_font(BuiltinFont::HelveticaBold).ok()?;
3369 let layer = doc.get_page(page).get_layer(layer_idx);
3370 let ctx = PdfCtx {
3371 layer: &layer,
3372 font_reg,
3373 font_bold,
3374 w,
3375 margin,
3376 row_h,
3377 tbl_hdr_h,
3378 };
3379 let content_bottom = if with_cocomo {
3382 let cocomo_bottom = pdf_render_cocomo_section(&ctx, run, h_full - 8.0 - 6.0);
3383 pdf_render_tc_inline(&ctx, run, cocomo_bottom - 2.0, footer_h)
3384 } else {
3385 pdf_render_tc_inline(&ctx, run, h_full - 8.0 - 4.0, footer_h)
3386 };
3387 let pad = 4.0;
3389 Some((h_full - content_bottom + footer_h + pad).clamp(60.0, h_full))
3390 };
3391 measure().unwrap_or(h_full)
3392}
3393
3394#[allow(
3400 clippy::cast_precision_loss,
3401 clippy::cast_possible_truncation,
3402 clippy::cast_sign_loss,
3403 clippy::too_many_arguments
3404)]
3405fn pdf_render_cocomo_or_tc_page(
3406 doc: &crate::pdf_compat::PdfDocumentReference,
3407 font_reg: crate::pdf_compat::IndirectFontRef,
3408 font_bold: crate::pdf_compat::IndirectFontRef,
3409 run: &AnalysisRun,
3410 dims: PdfPageDims,
3411 title: &str,
3412 version: &str,
3413 cocomo_fits_page1: bool,
3414 trim_page: bool,
3415) -> (
3416 crate::pdf_compat::PdfPageIndex,
3417 crate::pdf_compat::PdfLayerIndex,
3418 f32,
3419) {
3420 use crate::pdf_compat::{Color, Mm, Mm as PdfMm, Rgb};
3421 let PdfPageDims {
3422 w,
3423 h,
3424 margin,
3425 footer_h,
3426 row_h,
3427 tbl_hdr_h,
3428 } = dims;
3429
3430 if run.cocomo.is_none() || cocomo_fits_page1 {
3432 let page_h = if trim_page {
3433 measure_terminal_tc_page_height(run, w, h, margin, footer_h, row_h, tbl_hdr_h, false)
3434 } else {
3435 h
3436 };
3437 return pdf_render_tests_coverage_page(
3438 doc, font_reg, font_bold, run, w, page_h, margin, footer_h, title, version,
3439 );
3440 }
3441
3442 let page_h = if trim_page {
3443 measure_terminal_tc_page_height(run, w, h, margin, footer_h, row_h, tbl_hdr_h, true)
3444 } else {
3445 h
3446 };
3447 let (c2_page, c2_layer_idx) = doc.add_page(Mm(w), Mm(page_h), "Content");
3448 let c2_layer = doc.get_page(c2_page).get_layer(c2_layer_idx);
3449 let c2_ctx = PdfCtx {
3450 layer: &c2_layer,
3451 font_reg,
3452 font_bold,
3453 w,
3454 margin,
3455 row_h,
3456 tbl_hdr_h,
3457 };
3458 pdf_fill_rect(
3460 &c2_layer,
3461 0.0,
3462 page_h - 8.0,
3463 w,
3464 8.0,
3465 Rgb::new(0.098, 0.11, 0.15, None),
3466 );
3467 c2_layer.set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
3468 c2_layer.use_text(
3469 "oxide-sloc",
3470 9.0,
3471 PdfMm(margin),
3472 PdfMm(page_h - 5.5),
3473 font_bold,
3474 );
3475 c2_layer.set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
3476 c2_layer.use_text(
3477 pdf_trunc(&pdf_safe_str(title), 45),
3478 7.5,
3479 PdfMm(46.0),
3480 PdfMm(page_h - 5.5),
3481 font_reg,
3482 );
3483 pdf_draw_header_meta(
3484 &c2_layer,
3485 font_reg,
3486 w,
3487 margin,
3488 page_h - 5.5,
3489 &pdf_page_header_meta(run),
3490 );
3491 let cocomo_bottom = pdf_render_cocomo_section(&c2_ctx, run, page_h - 8.0 - 6.0);
3492 let tc_bottom = pdf_render_tc_inline(&c2_ctx, run, cocomo_bottom - 2.0, footer_h);
3494 pdf_fill_rect(
3496 &c2_layer,
3497 0.0,
3498 0.0,
3499 w,
3500 footer_h,
3501 Rgb::new(0.93, 0.91, 0.87, None),
3502 );
3503 c2_layer.set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)));
3504 c2_layer.use_text(
3505 format!("oxide-sloc v{version} | AGPL-3.0-or-later"),
3506 6.5,
3507 PdfMm(margin),
3508 PdfMm(3.0),
3509 font_reg,
3510 );
3511 (c2_page, c2_layer_idx, tc_bottom - 3.0)
3513}
3514
3515#[allow(
3525 clippy::cast_precision_loss,
3526 clippy::cast_possible_truncation,
3527 clippy::cast_sign_loss,
3528 clippy::too_many_lines
3529)]
3530pub fn write_pdf_from_run(run: &AnalysisRun, pdf_path: &Path) -> Result<()> {
3531 use crate::pdf_compat::{BuiltinFont, Mm, PdfDocument};
3532 use std::fs::File;
3533 use std::io::BufWriter;
3534
3535 const W: f32 = 297.0;
3536 const H: f32 = 210.0;
3537 const MARGIN: f32 = 10.0;
3538 const FOOTER_H: f32 = 10.0;
3539 const HDR_H: f32 = 13.5;
3540 const ROW_H: f32 = 5.5;
3541 const TBL_HDR_H: f32 = 6.0;
3542
3543 if let Some(parent) = pdf_path.parent() {
3544 fs::create_dir_all(parent)
3545 .with_context(|| format!("failed to create PDF directory {}", parent.display()))?;
3546 }
3547
3548 let title = pdf_safe_str(&run.effective_configuration.reporting.report_title);
3549 let ts = to_pt_hhmm(run.tool.timestamp_utc);
3550 let version = env!("CARGO_PKG_VERSION");
3551 let banner = run
3552 .effective_configuration
3553 .reporting
3554 .report_header_footer
3555 .as_deref();
3556
3557 let (doc, page1, layer1) =
3558 PdfDocument::new(format!("oxide-sloc: {title}"), Mm(W), Mm(H), "Content");
3559 let font_reg = doc
3560 .add_builtin_font(BuiltinFont::Helvetica)
3561 .map_err(|e| anyhow::anyhow!("printpdf font error: {e}"))?;
3562 let font_bold = doc
3563 .add_builtin_font(BuiltinFont::HelveticaBold)
3564 .map_err(|e| anyhow::anyhow!("printpdf font error: {e}"))?;
3565 let layer = doc.get_page(page1).get_layer(layer1);
3566
3567 let ctx = PdfCtx {
3568 layer: &layer,
3569 font_reg,
3570 font_bold,
3571 w: W,
3572 margin: MARGIN,
3573 row_h: ROW_H,
3574 tbl_hdr_h: TBL_HDR_H,
3575 };
3576 let roots_text_y = pdf_render_page1_header(&ctx, run, &ts, &title, H, HDR_H, banner);
3577 let row2_bot = pdf_render_summary_chips(&ctx, run, roots_text_y);
3578 let info_y = pdf_render_info_lines(&ctx, run, row2_bot);
3579 let tbl_top = info_y - 4.0;
3580 pdf_render_metric_tables(&ctx, run, tbl_top);
3581 let after_tables_y = tbl_top - 64.5 - 4.0;
3584 let after_style_y = run.style_summary.as_ref().map_or(after_tables_y, |ss| {
3585 if after_tables_y > FOOTER_H + 12.0 {
3586 pdf_render_style_section(&ctx, ss, after_tables_y)
3587 } else {
3588 after_tables_y
3589 }
3590 });
3591 let cocomo_fits_page1 = run.cocomo.is_some() && (after_style_y - 3.0) > FOOTER_H + 32.0;
3594 if cocomo_fits_page1 {
3595 pdf_render_cocomo_section(&ctx, run, after_style_y - 3.0);
3596 }
3597 pdf_render_page1_footer(&ctx, run, FOOTER_H, version, banner);
3598
3599 let hotspot_rows = build_hotspot_rows(run, 15);
3604 let has_per_file = !run.per_file_records.is_empty();
3605 let tc_page_gets_per_file = hotspot_rows.is_empty() && has_per_file;
3606 let trim_tc_page = !tc_page_gets_per_file;
3607
3608 let page_dims = PdfPageDims {
3612 w: W,
3613 h: H,
3614 margin: MARGIN,
3615 footer_h: FOOTER_H,
3616 row_h: ROW_H,
3617 tbl_hdr_h: TBL_HDR_H,
3618 };
3619 let cocomo_page_ctx = pdf_render_cocomo_or_tc_page(
3620 &doc,
3621 font_reg,
3622 font_bold,
3623 run,
3624 page_dims,
3625 &title,
3626 version,
3627 cocomo_fits_page1,
3628 trim_tc_page,
3629 );
3630
3631 let per_file_start = if hotspot_rows.is_empty() {
3638 Some(cocomo_page_ctx)
3639 } else {
3640 Some(pdf_render_hotspots_page(
3641 &doc,
3642 font_reg,
3643 font_bold,
3644 run,
3645 &hotspot_rows,
3646 W,
3647 H,
3648 MARGIN,
3649 FOOTER_H,
3650 &title,
3651 version,
3652 ))
3653 };
3654
3655 if !run.per_file_records.is_empty() {
3656 pdf_render_per_file_pages(
3658 &doc,
3659 font_reg,
3660 font_bold,
3661 run,
3662 W,
3663 H,
3664 MARGIN,
3665 FOOTER_H,
3666 ROW_H,
3667 TBL_HDR_H,
3668 &title,
3669 &ts,
3670 version,
3671 banner,
3672 per_file_start,
3673 );
3674 }
3675
3676 doc.save(&mut BufWriter::new(File::create(pdf_path).with_context(
3677 || format!("cannot create PDF at {}", pdf_path.display()),
3678 )?))
3679 .map_err(|e| anyhow::anyhow!("printpdf save error: {e}"))?;
3680
3681 Ok(())
3682}
3683
3684const HELVETICA_WIDTHS: &[(char, u32, u32)] = &[
3692 (' ', 278, 278),
3693 ('!', 333, 278),
3694 ('"', 474, 355),
3695 ('#', 556, 556),
3696 ('$', 556, 556),
3697 ('%', 889, 889),
3698 ('&', 722, 667),
3699 ('\'', 278, 222),
3700 ('(', 333, 333),
3701 (')', 333, 333),
3702 ('*', 389, 389),
3703 ('+', 584, 584),
3704 (',', 278, 278),
3705 ('-', 333, 333),
3706 ('.', 278, 278),
3707 ('/', 278, 278),
3708 (':', 333, 278),
3709 (';', 333, 278),
3710 ('<', 584, 584),
3711 ('=', 584, 584),
3712 ('>', 584, 584),
3713 ('?', 556, 472),
3714 ('@', 975, 1015),
3715 ('A', 722, 667),
3716 ('B', 722, 667),
3717 ('C', 722, 722),
3718 ('D', 722, 722),
3719 ('E', 667, 667),
3720 ('F', 611, 611),
3721 ('G', 778, 778),
3722 ('H', 722, 722),
3723 ('I', 278, 278),
3724 ('J', 556, 500),
3725 ('K', 722, 667),
3726 ('L', 611, 556),
3727 ('M', 833, 833),
3728 ('N', 722, 722),
3729 ('O', 778, 778),
3730 ('P', 667, 667),
3731 ('Q', 778, 778),
3732 ('R', 722, 722),
3733 ('S', 667, 667),
3734 ('T', 611, 611),
3735 ('U', 722, 722),
3736 ('V', 667, 667),
3737 ('W', 944, 944),
3738 ('X', 667, 667),
3739 ('Y', 611, 611),
3740 ('Z', 611, 611),
3741 ('[', 333, 278),
3742 ('\\', 278, 278),
3743 (']', 333, 278),
3744 ('^', 584, 469),
3745 ('_', 556, 556),
3746 ('`', 278, 222),
3747 ('a', 556, 556),
3748 ('b', 611, 556),
3749 ('c', 556, 500),
3750 ('d', 611, 556),
3751 ('e', 556, 556),
3752 ('f', 333, 278),
3753 ('g', 611, 556),
3754 ('h', 611, 556),
3755 ('i', 278, 222),
3756 ('j', 278, 222),
3757 ('k', 556, 500),
3758 ('l', 278, 222),
3759 ('m', 889, 833),
3760 ('n', 611, 556),
3761 ('o', 611, 556),
3762 ('p', 611, 556),
3763 ('q', 611, 556),
3764 ('r', 389, 333),
3765 ('s', 556, 500),
3766 ('t', 333, 278),
3767 ('u', 611, 556),
3768 ('v', 556, 500),
3769 ('w', 778, 722),
3770 ('x', 556, 500),
3771 ('y', 556, 500),
3772 ('z', 500, 500),
3773 ('\u{00B7}', 278, 278), ];
3775
3776fn helvetica_advance(ch: char, bold: bool) -> u32 {
3780 if ch.is_ascii_digit() {
3781 return 556;
3782 }
3783 for &(glyph, bold_w, regular_w) in HELVETICA_WIDTHS {
3784 if glyph == ch {
3785 return if bold { bold_w } else { regular_w };
3786 }
3787 }
3788 if bold { 556 } else { 500 }
3789}
3790
3791#[allow(
3797 clippy::cast_precision_loss,
3798 reason = "bounded glyph-unit sum to mm width"
3799)]
3800fn helvetica_width_mm(text: &str, pt: f32, bold: bool) -> f32 {
3801 let units: u32 = text.chars().map(|ch| helvetica_advance(ch, bold)).sum();
3802 units as f32 * pt * (25.4 / 72.0) / 1000.0
3804}
3805
3806fn pdf_fill_rect(
3807 layer: &crate::pdf_compat::PdfLayerReference,
3808 x: f32,
3809 y: f32,
3810 w: f32,
3811 h: f32,
3812 color: crate::pdf_compat::Rgb,
3813) {
3814 layer.fill_rect(x, y, w, h, color);
3815}
3816
3817fn pdf_safe_str(s: &str) -> String {
3818 let mut out = String::with_capacity(s.len());
3819 for c in s.chars() {
3820 match c {
3821 '\u{2014}' | '\u{2013}' => out.push_str(" - "), '\u{2026}' => out.push_str("..."), '\u{2018}' | '\u{2019}' => out.push('\''), '\u{201C}' | '\u{201D}' => out.push('"'), '\u{00B7}' | '\u{2022}' => out.push('-'), '\u{00A0}' => out.push(' '), c if c.is_ascii() && !c.is_ascii_control() => out.push(c),
3829 _ => {} }
3831 }
3832 out
3833}
3834
3835fn pdf_trunc(s: &str, max: usize) -> String {
3836 if s.len() <= max {
3837 s.to_string()
3838 } else {
3839 format!("{}...", &s[..max.saturating_sub(3)])
3840 }
3841}
3842
3843fn pdf_trunc_end(s: &str, max: usize) -> String {
3846 if s.len() <= max {
3847 s.to_string()
3848 } else {
3849 format!("...{}", &s[s.len() - max.saturating_sub(3)..])
3850 }
3851}
3852
3853fn pdf_fmt_full(n: u64) -> String {
3854 let s = n.to_string();
3856 let mut out = String::with_capacity(s.len() + s.len() / 3);
3857 for (i, ch) in s.chars().rev().enumerate() {
3858 if i > 0 && i % 3 == 0 {
3859 out.push(',');
3860 }
3861 out.push(ch);
3862 }
3863 out.chars().rev().collect()
3864}
3865
3866pub fn write_pdf_from_html(html_path: &Path, pdf_path: &Path) -> Result<()> {
3876 eprintln!("[oxide-sloc][pdf] starting");
3877
3878 let absolute_html = html_path
3879 .canonicalize()
3880 .with_context(|| format!("failed to canonicalize {}", html_path.display()))?;
3881 eprintln!(
3883 "[oxide-sloc][pdf] html = {}",
3884 absolute_html.to_string_lossy().trim_start_matches(r"\\?\")
3885 );
3886
3887 let absolute_pdf = if pdf_path.is_absolute() {
3888 pdf_path.to_path_buf()
3889 } else {
3890 std::env::current_dir()
3891 .context("failed to resolve current working directory")?
3892 .join(pdf_path)
3893 };
3894 eprintln!("[oxide-sloc][pdf] pdf = {}", absolute_pdf.display());
3895
3896 if let Some(parent) = absolute_pdf.parent() {
3897 fs::create_dir_all(parent).with_context(|| {
3898 format!("failed to create PDF output directory {}", parent.display())
3899 })?;
3900 }
3901
3902 match write_pdf_via_cdp(&absolute_html, &absolute_pdf) {
3903 Ok(()) => {}
3904 Err(cdp_err) => {
3905 eprintln!("[oxide-sloc][pdf] CDP failed ({cdp_err:#}), trying wkhtmltopdf fallback");
3906 write_pdf_via_wkhtmltopdf(&absolute_html, &absolute_pdf).with_context(|| {
3907 format!(
3908 "PDF generation failed via both CDP ({cdp_err:#}) and wkhtmltopdf. \
3909 Install a Chromium-based browser (Chrome, Edge, Brave) or wkhtmltopdf \
3910 on the server, or set SLOC_BROWSER to the browser executable path."
3911 )
3912 })?;
3913 }
3914 }
3915
3916 eprintln!("[oxide-sloc][pdf] done");
3917 Ok(())
3918}
3919
3920fn normalize_browser_env_path(raw: &str) -> PathBuf {
3921 let trimmed = raw.trim();
3922 #[cfg(windows)]
3923 {
3924 let bytes = trimmed.as_bytes();
3925 if bytes.len() >= 3
3926 && bytes[0] == b'/'
3927 && bytes[2] == b'/'
3928 && bytes[1].is_ascii_alphabetic()
3929 {
3930 let drive = (bytes[1] as char).to_ascii_uppercase();
3931 let rest = &trimmed[3..];
3932 return PathBuf::from(format!("{drive}:/{rest}"));
3933 }
3934 }
3935 PathBuf::from(trimmed)
3936}
3937
3938fn discover_browser_from_env() -> Option<PathBuf> {
3939 for var_name in ["SLOC_BROWSER", "BROWSER"] {
3940 if let Ok(path) = std::env::var(var_name) {
3941 let candidate = normalize_browser_env_path(&path);
3942 if candidate.is_file() {
3943 return Some(candidate);
3944 }
3945 }
3946 }
3947 None
3948}
3949
3950fn discover_browser() -> Option<PathBuf> {
3951 if let Some(p) = discover_browser_from_env() {
3952 return Some(p);
3953 }
3954
3955 let names = [
3956 "chromium",
3957 "chromium-browser",
3958 "google-chrome",
3959 "google-chrome-stable",
3960 "microsoft-edge",
3961 "msedge",
3962 "brave",
3963 "brave-browser",
3964 "vivaldi",
3965 "opera",
3966 "opera-stable",
3967 ];
3968
3969 for name in names {
3970 if let Some(path) = which_in_path(name) {
3971 return Some(path);
3972 }
3973 }
3974
3975 #[cfg(windows)]
3976 {
3977 for candidate in windows_browser_candidates() {
3978 if candidate.is_file() {
3979 return Some(candidate);
3980 }
3981 }
3982 }
3983
3984 #[cfg(not(windows))]
3987 {
3988 for candidate in linux_browser_candidates() {
3989 if candidate.is_file() {
3990 return Some(candidate);
3991 }
3992 }
3993
3994 if let Some(path) = which_subprocess(&[
3997 "chromium-browser",
3998 "chromium",
3999 "google-chrome",
4000 "google-chrome-stable",
4001 "microsoft-edge",
4002 "brave-browser",
4003 ]) {
4004 return Some(path);
4005 }
4006 }
4007
4008 None
4009}
4010
4011#[cfg(windows)]
4015fn push_chromium_app_browsers(paths: &mut Vec<PathBuf>, base: &Path) {
4016 paths.push(
4017 base.join("Google")
4018 .join("Chrome")
4019 .join("Application")
4020 .join("chrome.exe"),
4021 );
4022 paths.push(
4023 base.join("Microsoft")
4024 .join("Edge")
4025 .join("Application")
4026 .join("msedge.exe"),
4027 );
4028 paths.push(
4029 base.join("BraveSoftware")
4030 .join("Brave-Browser")
4031 .join("Application")
4032 .join("brave.exe"),
4033 );
4034 paths.push(base.join("Vivaldi").join("Application").join("vivaldi.exe"));
4035}
4036
4037#[cfg(windows)]
4038fn windows_browser_candidates() -> Vec<PathBuf> {
4039 let mut paths = Vec::new();
4040
4041 let program_files = std::env::var_os("ProgramFiles");
4042 let program_files_x86 = std::env::var_os("ProgramFiles(x86)");
4043 let local_app_data = std::env::var_os("LocalAppData");
4044
4045 for base in [program_files, program_files_x86].into_iter().flatten() {
4046 let base = PathBuf::from(base);
4047 push_chromium_app_browsers(&mut paths, &base);
4048 paths.push(base.join("Opera").join("launcher.exe"));
4049 paths.push(base.join("Opera GX").join("launcher.exe"));
4050 }
4051
4052 if let Some(base) = local_app_data {
4053 let base = PathBuf::from(base);
4054 push_chromium_app_browsers(&mut paths, &base);
4055 paths.push(base.join("Programs").join("Opera").join("launcher.exe"));
4056 paths.push(base.join("Programs").join("Opera GX").join("launcher.exe"));
4057 }
4058
4059 paths
4060}
4061
4062#[cfg(not(windows))]
4063fn linux_browser_candidates() -> Vec<PathBuf> {
4064 vec![
4065 PathBuf::from("/snap/bin/chromium"),
4067 PathBuf::from("/snap/bin/chromium-browser"),
4068 PathBuf::from("/usr/bin/chromium"),
4070 PathBuf::from("/usr/bin/chromium-browser"),
4071 PathBuf::from("/usr/bin/google-chrome"),
4072 PathBuf::from("/usr/bin/google-chrome-stable"),
4073 PathBuf::from("/usr/bin/microsoft-edge"),
4074 PathBuf::from("/usr/bin/microsoft-edge-stable"),
4075 PathBuf::from("/usr/bin/brave-browser"),
4076 PathBuf::from("/usr/bin/brave-browser-stable"),
4077 PathBuf::from("/usr/lib/chromium-browser/chromium-browser"),
4079 PathBuf::from("/usr/lib/chromium/chromium"),
4080 PathBuf::from("/usr/lib/chromium/chrome"),
4081 PathBuf::from("/opt/google/chrome/google-chrome"),
4083 PathBuf::from("/opt/google/chrome-beta/google-chrome"),
4084 PathBuf::from("/opt/google/chrome-unstable/google-chrome"),
4085 PathBuf::from("/usr/local/bin/chromium"),
4087 PathBuf::from("/usr/local/bin/chromium-browser"),
4088 PathBuf::from("/usr/local/bin/google-chrome"),
4089 PathBuf::from("/var/lib/flatpak/exports/bin/org.chromium.Chromium"),
4091 PathBuf::from("/usr/share/flatpak/exports/bin/org.chromium.Chromium"),
4092 ]
4093}
4094
4095#[cfg(not(windows))]
4098fn which_subprocess(names: &[&str]) -> Option<PathBuf> {
4099 for name in names {
4100 if let Ok(out) = std::process::Command::new("which").arg(name).output()
4101 && out.status.success()
4102 {
4103 let s = String::from_utf8_lossy(&out.stdout);
4104 let path = PathBuf::from(s.trim());
4105 if path.is_file() {
4106 return Some(path);
4107 }
4108 }
4109 }
4110 None
4111}
4112
4113fn which_in_path(exe: &str) -> Option<PathBuf> {
4114 let path_var = std::env::var_os("PATH")?;
4115 for dir in std::env::split_paths(&path_var) {
4116 let candidate = dir.join(exe);
4117 if candidate.is_file() {
4118 return Some(candidate);
4119 }
4120 #[cfg(windows)]
4121 {
4122 let candidate = dir.join(format!("{exe}.exe"));
4123 if candidate.is_file() {
4124 return Some(candidate);
4125 }
4126 }
4127 }
4128 None
4129}
4130
4131fn file_url(path: &Path) -> String {
4132 let raw = path.to_string_lossy().replace('\\', "/");
4133 let normalized = if raw.starts_with('/') {
4134 raw
4135 } else {
4136 format!("/{raw}")
4137 };
4138
4139 let mut encoded = String::with_capacity(normalized.len() + 8);
4140 for byte in normalized.bytes() {
4141 match byte {
4142 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'/' | b'-' | b'_' | b'.' | b'~' | b':' => {
4143 encoded.push(byte as char);
4144 }
4145 _ => {
4146 let _ = write!(encoded, "%{byte:02X}");
4147 }
4148 }
4149 }
4150
4151 format!("file://{encoded}")
4152}
4153
4154fn file_row_view(file: &FileRecord) -> FileRow {
4155 FileRow {
4156 relative_path: file.relative_path.clone(),
4157 language: file.language.map_or_else(
4158 || "-".into(),
4159 |language| language.display_name().to_string(),
4160 ),
4161 total_physical_lines: file.raw_line_categories.total_physical_lines,
4162 code_lines: file.effective_counts.code_lines,
4163 comment_lines: file.effective_counts.comment_lines,
4164 blank_lines: file.effective_counts.blank_lines,
4165 mixed_lines_separate: file.effective_counts.mixed_lines_separate,
4166 functions: file.raw_line_categories.functions,
4167 classes: file.raw_line_categories.classes,
4168 variables: file.raw_line_categories.variables,
4169 imports: file.raw_line_categories.imports,
4170 test_count: file.raw_line_categories.test_count,
4171 test_assertion_count: file.raw_line_categories.test_assertion_count,
4172 test_suite_count: file.raw_line_categories.test_suite_count,
4173 line_cov_pct: file
4174 .coverage
4175 .as_ref()
4176 .map(|c| format!("{:.1}", c.line_pct()))
4177 .unwrap_or_default(),
4178 fn_cov_pct: file
4179 .coverage
4180 .as_ref()
4181 .filter(|c| c.functions_found > 0)
4182 .map(|c| format!("{:.1}", c.function_pct()))
4183 .unwrap_or_default(),
4184 branch_cov_pct: file
4185 .coverage
4186 .as_ref()
4187 .filter(|c| c.branches_found > 0)
4188 .map(|c| format!("{:.1}", c.branch_pct()))
4189 .unwrap_or_default(),
4190 cov_lines_detail: file.coverage.as_ref().map_or_else(String::new, |c| {
4191 format!("{}/{}", c.lines_hit, c.lines_found)
4192 }),
4193 status: format!("{:?}", file.status),
4194 status_class: format!("{:?}", file.status).to_ascii_lowercase(),
4195 warnings: if file.warnings.is_empty() {
4196 String::new()
4197 } else {
4198 file.warnings.join("; ")
4199 },
4200 }
4201}
4202
4203fn is_pacific_dst_report(dt: DateTime<Utc>) -> bool {
4204 use chrono::{Datelike, NaiveDate, NaiveTime, TimeZone, Weekday};
4205 let year = dt.year();
4206 let nth_sun = |month: u32, n: u32, hour: u32| {
4207 let mut count = 0u32;
4208 let mut day = 1u32;
4209 loop {
4210 let d = NaiveDate::from_ymd_opt(year, month, day).expect("valid");
4211 if d.weekday() == Weekday::Sun {
4212 count += 1;
4213 if count == n {
4214 return Utc.from_utc_datetime(
4215 &d.and_time(NaiveTime::from_hms_opt(hour, 0, 0).expect("valid")),
4216 );
4217 }
4218 }
4219 day += 1;
4220 }
4221 };
4222 let dst_start = nth_sun(3, 2, 10);
4223 let dst_end = nth_sun(11, 1, 9);
4224 dt >= dst_start && dt < dst_end
4225}
4226
4227fn to_pst_display(dt: DateTime<Utc>) -> String {
4228 let (offset, label) = if is_pacific_dst_report(dt) {
4229 (
4230 FixedOffset::west_opt(7 * 3600).expect("valid PDT offset"),
4231 "PDT",
4232 )
4233 } else {
4234 (
4235 FixedOffset::west_opt(8 * 3600).expect("valid PST offset"),
4236 "PST",
4237 )
4238 };
4239 format!(
4240 "{} {label}",
4241 dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
4242 )
4243}
4244
4245fn to_pt_hhmm(dt: DateTime<Utc>) -> String {
4247 let (offset, label) = if is_pacific_dst_report(dt) {
4248 (
4249 FixedOffset::west_opt(7 * 3600).expect("valid PDT offset"),
4250 "PDT",
4251 )
4252 } else {
4253 (
4254 FixedOffset::west_opt(8 * 3600).expect("valid PST offset"),
4255 "PST",
4256 )
4257 };
4258 format!(
4259 "{} {label}",
4260 dt.with_timezone(&offset).format("%Y-%m-%d %H:%M")
4261 )
4262}
4263
4264fn fmt_commit_date_pt(s: &str) -> String {
4267 use chrono::DateTime as ChronoDateTime;
4268 ChronoDateTime::parse_from_rfc3339(s).map_or_else(
4269 |_| s.replace('T', " "),
4270 |dt| to_pt_hhmm(dt.with_timezone(&Utc)),
4271 )
4272}
4273
4274fn build_warning_console(warnings: &[String]) -> String {
4275 if warnings.is_empty() {
4276 return "No top-level warnings.".to_string();
4277 }
4278
4279 warnings
4280 .iter()
4281 .enumerate()
4282 .map(|(index, warning)| {
4283 format!(
4284 "[{index:03}] {warning}",
4285 index = index + 1,
4286 warning = warning
4287 )
4288 })
4289 .collect::<Vec<_>>()
4290 .join("\n")
4291}
4292
4293fn summarize_warnings(warnings: &[String]) -> Vec<WarningSummaryRow> {
4294 let mut counts: BTreeMap<&'static str, usize> = BTreeMap::new();
4295 for warning in warnings {
4296 let key = if warning.contains("unsupported or undetected language") {
4297 "Unsupported or undetected text formats"
4298 } else if warning.contains("file exceeded max_file_size_bytes") {
4299 "Large files skipped by size limit"
4300 } else if warning.contains("binary file skipped by default") {
4301 "Binary assets skipped"
4302 } else if warning.contains("minified file skipped by policy") {
4303 "Minified files skipped by policy"
4304 } else if warning.contains("vendor file skipped by policy") {
4305 "Vendor files skipped by policy"
4306 } else if warning.contains("best effort") || warning.contains("unclosed string literal") {
4307 "Best-effort parse results"
4308 } else {
4309 "Other warnings"
4310 };
4311 *counts.entry(key).or_default() += 1;
4312 }
4313
4314 counts
4315 .into_iter()
4316 .map(|(label, count)| {
4317 let (tone_class, detail) = match label {
4318 "Unsupported or undetected text formats" => (
4319 "tone-neutral",
4320 "These are usually docs, manifests, templates, or formats that have not been promoted into first-class analyzers yet.",
4321 ),
4322 "Large files skipped by size limit" => (
4323 "tone-warn",
4324 "Artifacts and archives larger than the configured cap were skipped intentionally to keep runs fast and predictable.",
4325 ),
4326 "Binary assets skipped" => (
4327 "tone-neutral",
4328 "Binary bundles are excluded from source counting unless you explicitly opt into them.",
4329 ),
4330 "Minified files skipped by policy" => (
4331 "tone-warn",
4332 "Generated and minified assets are being filtered out to avoid inflating code totals.",
4333 ),
4334 "Vendor files skipped by policy" => (
4335 "tone-neutral",
4336 "Vendored third-party code is being excluded so the report stays focused on repository-owned source.",
4337 ),
4338 "Best-effort parse results" => (
4339 "tone-danger",
4340 "These files were analyzed, but the parser hit malformed or ambiguous content and fell back to a best-effort count.",
4341 ),
4342 _ => (
4343 "tone-danger",
4344 "Warnings in this bucket need manual review because they do not match one of the common policy-based skip reasons.",
4345 ),
4346 };
4347
4348 WarningSummaryRow {
4349 label: label.to_string(),
4350 count,
4351 tone_class: tone_class.to_string(),
4352 detail: detail.to_string(),
4353 }
4354 })
4355 .collect()
4356}
4357
4358fn classify_unsupported_path(path: &str) -> &'static str {
4360 let ext_lc = Path::new(path)
4361 .extension()
4362 .and_then(|e| e.to_str())
4363 .map(str::to_ascii_lowercase)
4364 .unwrap_or_default();
4365
4366 if ext_lc == "md"
4367 || path.ends_with("README")
4368 || path.ends_with("README.md")
4369 || path.ends_with("LICENSE")
4370 {
4371 "Documentation / text"
4372 } else if ext_lc == "json" || path.ends_with(".spdx.json") || path.ends_with("devkit.json") {
4373 "JSON manifests and config"
4374 } else if ext_lc == "toml"
4375 || path.ends_with("MANIFEST.in")
4376 || path.ends_with("requirements.txt")
4377 {
4378 "Project metadata and packaging"
4379 } else if ext_lc == "html" {
4380 "HTML templates"
4381 } else if ext_lc == "txt" {
4382 "Plain text assets"
4383 } else if ext_lc.is_empty() {
4384 "Extensionless or custom text files"
4385 } else {
4386 "Other unsupported text formats"
4387 }
4388}
4389
4390fn bucket_recommendation(label: &str) -> String {
4392 match label {
4393 "Documentation / text" => "These files are documentation, not source code. Add a docs/text exclude glob (e.g. **/*.md) or mark them as non-source in your config so they stop generating warnings.".to_string(),
4394 "JSON manifests and config" => "JSON config and manifest files are not source code. Exclude them with an ignore glob (e.g. **/*.json) or add them to excluded_directories so they are silently skipped.".to_string(),
4395 "Project metadata and packaging" => "TOML, requirements.txt, and MANIFEST.in files describe package metadata — not source. Add an exclude glob such as **/*.toml or list them in excluded_directories to suppress these warnings.".to_string(),
4396 "HTML templates" => "HTML is already a supported language. These files may have unusual extensions or be in a directory that is being excluded. Check that the files have a .html extension and are not inside an excluded path.".to_string(),
4397 "Plain text assets" => "Plain .txt files are not analyzed by default. If they contain source, rename them with a source extension. Otherwise add **/*.txt to your exclude globs.".to_string(),
4398 "Extensionless or custom text files" => "Files without an extension cannot be auto-detected. Add a shebang line (e.g. #!/usr/bin/env python3) so oxide-sloc can identify the language, or add an explicit language override in your config.".to_string(),
4399 _ => "Files in this group were not recognized. Check the file extensions and either add an exclude glob to silence the warning or open an issue to request analyzer support.".to_string(),
4400 }
4401}
4402
4403fn bucket_description(label: &str) -> String {
4405 match label {
4406 "Documentation / text" => {
4407 "README, LICENSE, and markdown files — not source code.".to_string()
4408 }
4409 "JSON manifests and config" => {
4410 "JSON configuration or manifest files (package.json, lockfiles, etc.).".to_string()
4411 }
4412 "Project metadata and packaging" => {
4413 "TOML, requirements.txt, and MANIFEST.in files that describe package metadata."
4414 .to_string()
4415 }
4416 "HTML templates" => {
4417 "HTML files not covered by the built-in HTML analyzer (unexpected extension or path)."
4418 .to_string()
4419 }
4420 "Plain text assets" => "Plain .txt files that have no analyzable structure.".to_string(),
4421 "Extensionless or custom text files" => {
4422 "Files with no extension that could not be language-detected.".to_string()
4423 }
4424 _ => "Files with an unrecognized extension or format.".to_string(),
4425 }
4426}
4427
4428fn build_support_opportunities(warnings: &[String]) -> Vec<WarningOpportunityRow> {
4429 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
4430 let mut examples: BTreeMap<String, Vec<String>> = BTreeMap::new();
4431
4432 for warning in warnings {
4433 if !warning.contains("unsupported or undetected language") {
4434 continue;
4435 }
4436
4437 let path = warning
4438 .split_once(':')
4439 .map(|(path, _)| path.trim())
4440 .unwrap_or_default();
4441 if path.is_empty() {
4442 continue;
4443 }
4444
4445 let bucket = classify_unsupported_path(path);
4446 *counts.entry(bucket.to_string()).or_default() += 1;
4447
4448 let ex = examples.entry(bucket.to_string()).or_default();
4449 if ex.len() < 3 {
4450 let basename = Path::new(path)
4451 .file_name()
4452 .and_then(|n| n.to_str())
4453 .unwrap_or(path)
4454 .to_string();
4455 if !ex.contains(&basename) {
4456 ex.push(basename);
4457 }
4458 }
4459 }
4460
4461 let mut rows = counts.into_iter().collect::<Vec<_>>();
4462 rows.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
4463
4464 rows.into_iter()
4465 .map(|(label, count)| {
4466 let recommendation = bucket_recommendation(&label);
4467 let bucket_description = bucket_description(&label);
4468 let example_files = examples.remove(&label).unwrap_or_default();
4469 WarningOpportunityRow {
4470 label,
4471 count,
4472 recommendation,
4473 example_files,
4474 bucket_description,
4475 }
4476 })
4477 .collect()
4478}
4479
4480#[derive(Debug, Clone)]
4481struct LanguageRow {
4482 language: String,
4483 files: u64,
4484 total_physical_lines: u64,
4485 code_lines: u64,
4486 comment_lines: u64,
4487 blank_lines: u64,
4488 mixed_lines_separate: u64,
4489 functions: u64,
4490 classes: u64,
4491 variables: u64,
4492 imports: u64,
4493 test_count: u64,
4494 test_assertion_count: u64,
4495 test_suite_count: u64,
4496 test_density_str: String,
4497}
4498
4499#[derive(Debug, Clone)]
4500struct FileRow {
4501 relative_path: String,
4502 language: String,
4503 total_physical_lines: u64,
4504 code_lines: u64,
4505 comment_lines: u64,
4506 blank_lines: u64,
4507 mixed_lines_separate: u64,
4508 functions: u64,
4509 classes: u64,
4510 variables: u64,
4511 imports: u64,
4512 test_count: u64,
4513 test_assertion_count: u64,
4514 test_suite_count: u64,
4515 line_cov_pct: String,
4517 fn_cov_pct: String,
4519 branch_cov_pct: String,
4521 cov_lines_detail: String,
4523 status: String,
4524 status_class: String,
4525 warnings: String,
4526}
4527
4528#[derive(Debug, Clone)]
4529struct WarningSummaryRow {
4530 label: String,
4531 count: usize,
4532 tone_class: String,
4533 detail: String,
4534}
4535
4536#[derive(Debug, Clone)]
4537struct WarningOpportunityRow {
4538 label: String,
4539 count: usize,
4540 recommendation: String,
4541 example_files: Vec<String>,
4543 bucket_description: String,
4545}
4546
4547#[derive(Template)]
4548#[template(
4549 source = r##"<!doctype html>
4550<html lang="en">
4551<head>
4552 <meta charset="utf-8" />
4553 <meta name="viewport" content="width=device-width, initial-scale=1" />
4554 <title>{{ browser_title }}</title>
4555 <link rel="icon" href="{{ small_logo_uri }}" type="image/png" />
4556 <style nonce="{{ nonce }}">
4557 :root {
4558 --radius: 18px;
4559 --bg: #f5efe8;
4560 --surface: rgba(255,255,255,0.82);
4561 --surface-2: #fbf7f2;
4562 --surface-3: #efe6dc;
4563 --line: #e6d0bf;
4564 --line-strong: #dcb89f;
4565 --text: #43342d;
4566 --muted: #7b675b;
4567 --muted-2: #a08777;
4568 --nav: #b85d33;
4569 --nav-2: #7a371b;
4570 --accent: #6f9bff;
4571 --accent-2: #4a78ee;
4572 --oxide: #d37a4c;
4573 --oxide-2: #b35428;
4574 --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
4575 --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
4576 --good-bg: #e8f5ed;
4577 --good-text: #1a8f47;
4578 --warn-bg: #fff4dc;
4579 --warn-text: #9a6d00;
4580 --danger-bg: #fdebec;
4581 --danger-text: #cc4b4b;
4582 --info-bg: #f2f6ff;
4583 --info-text: #4467d8;
4584 }
4585 {% if let Some(hex) = accent_hex %}
4586 :root, body.dark-theme { --accent: {{ hex }}; --accent-2: {{ hex }}; }
4587 {% endif %}
4588 body.dark-theme {
4589 --bg: #1b1511;
4590 --surface: #261c17;
4591 --surface-2: #2d221d;
4592 --surface-3: #372922;
4593 --line: #524238;
4594 --line-strong: #6c5649;
4595 --text: #f5ece6;
4596 --muted: #c7b7aa;
4597 --muted-2: #aa9485;
4598 --nav: #b85d33;
4599 --nav-2: #7a371b;
4600 --accent: #6f9bff;
4601 --accent-2: #4a78ee;
4602 --oxide: #d37a4c;
4603 --oxide-2: #b35428;
4604 --shadow: 0 18px 42px rgba(0,0,0,0.28);
4605 --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
4606 --good-bg: #163927;
4607 --good-text: #8fe2a8;
4608 --warn-bg: #3c2d11;
4609 --warn-text: #f3cb75;
4610 --danger-bg: #3d1f1f;
4611 --danger-text: #ff9f9f;
4612 --info-bg: #202e55;
4613 --info-text: #a9c1ff;
4614 }
4615 * { box-sizing: border-box; }
4616 html, body { margin: 0; min-height: 100vh; font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: var(--bg); color: var(--text); }
4617 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; }
4618 .top-nav { position: sticky; top: 0; z-index: 30; background: linear-gradient(180deg, var(--nav), var(--nav-2)); border-bottom: 1px solid rgba(255,255,255,0.12); box-shadow: 0 4px 14px rgba(0,0,0,0.18); }
4619 .top-nav-inner { max-width: 1720px; margin: 0 auto; padding: 4px 24px; min-height: 56px; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 14px; }
4620 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; flex: 0 0 auto; }
4621 .brand-logo { width: 42px; height: 46px; object-fit: contain; flex: 0 0 auto; filter: drop-shadow(0 4px 10px rgba(0,0,0,0.22)); }
4622 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
4623 .background-watermarks img { position: absolute; opacity: 0.15; filter: blur(0.3px); user-select: none; max-width: none; }
4624 .code-particles { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
4625 .code-particle { position: absolute; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; font-weight: 600; color: var(--oxide); opacity: 0; white-space: nowrap; user-select: none; animation: floatCode linear infinite; }
4626 @keyframes floatCode { 0% { opacity: 0; transform: translateY(0) rotate(var(--rot)); } 10% { opacity: var(--op); } 85% { opacity: var(--op); } 100% { opacity: 0; transform: translateY(-200px) rotate(var(--rot)); } }
4627 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
4628 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; letter-spacing: -0.01em; }
4629 .brand-subtitle { color: rgba(255,255,255,0.72); font-size: 11px; line-height: 1.2; margin-top: 2px; letter-spacing: 0.01em; }
4630 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
4631 .nav-project-pill, .nav-pill, .theme-toggle, .header-button {
4632 display: inline-flex; align-items: center; gap: 8px; min-height: 38px; padding: 0 14px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.18); color: #fff; background: rgba(255,255,255,0.08); font-size: 12px; font-weight: 700; box-shadow: none;
4633 }
4634 .nav-project-pill { pointer-events: auto; width: 100%; max-width: 300px; justify-content: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
4635 .nav-project-label { color: rgba(255,255,255,0.72); text-transform: uppercase; letter-spacing: 0.09em; font-size: 10px; font-weight: 800; }
4636 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size: 13px; }
4637 .nav-status { display:flex; align-items:center; justify-content:flex-end; gap:10px; flex-wrap:nowrap; min-width:0; }
4638 @media (max-width: 1400px) { .nav-status { gap: 6px; } .header-button, .theme-toggle { padding: 0 10px; } }
4639 @media (max-width: 1150px) { .nav-status { gap: 4px; } .header-button, .theme-toggle { padding: 0 8px; font-size: 11px; min-height: 34px; } .brand-subtitle { display: none; } }
4640 .theme-toggle, .header-button { cursor:pointer; background: rgba(255,255,255,0.08); text-decoration:none; transition: background 0.15s ease, transform 0.15s ease; }
4641 .theme-toggle:hover, .header-button:hover { background: rgba(255,255,255,0.18); transform: translateY(-1px); }
4642 .theme-toggle { width: 38px; justify-content:center; padding:0; }
4643 .nav-dropdown-wrap { position: relative; }
4644 .nav-dropdown-wrap::after { content: ''; position: absolute; left: 0; right: 0; bottom: -6px; height: 6px; }
4645 .nav-dropdown-trigger { }
4646 .nav-dropdown-menu { display: none; position: absolute; top: 100%; right: 0; background: var(--nav-2); border: 1px solid rgba(255,255,255,0.15); border-radius: 10px; min-width: 140px; padding: 6px; z-index: 50; box-shadow: 0 8px 24px rgba(0,0,0,0.28); }
4647 .nav-dropdown-wrap:hover .nav-dropdown-menu, .nav-dropdown-wrap:focus-within .nav-dropdown-menu { display: flex; flex-direction: column; gap: 2px; }
4648 .nav-dropdown-item { display: block; width: 100%; padding: 8px 12px; border: none; border-radius: 7px; background: transparent; color: #fff; font-size: 13px; font-weight: 700; text-align: left; cursor: pointer; }
4649 .nav-dropdown-item:hover { background: rgba(255,255,255,0.12); }
4650 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
4651 .theme-toggle .icon-sun { display:none; }
4652 body.dark-theme .theme-toggle .icon-sun { display:block; }
4653 body.dark-theme .theme-toggle .icon-moon { display:none; }
4654 .settings-modal{position:fixed;z-index:9999;background:var(--surface);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}
4655 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
4656 .settings-modal-header{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}
4657 .settings-close{background:none;border:none;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--muted);border-radius:6px;padding:0;}
4658 .settings-close:hover{color:var(--text);background:var(--surface-2);}
4659 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
4660 .settings-modal-body{padding:14px 16px 16px;}
4661 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
4662 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
4663 .scheme-swatch{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}
4664 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
4665 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
4666 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
4667 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
4668 .tz-select{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}
4669 .tz-select:focus{border-color:var(--oxide);}
4670 .page { max-width: 1720px; margin: 0 auto; padding: 32px 24px 40px; }
4671 @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
4672 /* Uniform two-row card strip. JS pads the card count to even (revealing a
4673 reserve card when odd) and sets the column count to n/2, so the cards form
4674 exactly two full rows with every column aligned and every card the same
4675 width — no oversized card, no empty trailing cell. */
4676 .summary-grid { display:grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap:10px; align-items:stretch; }
4677 .panel, .metric, .warning-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
4678 .panel { padding: 20px; }
4679 .metric { padding: 11px 12px 20px; position: relative; cursor: help; transition: transform 0.15s ease, box-shadow 0.15s ease; min-height: 70px; }
4680 .metric:hover { transform: translateY(-3px); box-shadow: var(--shadow-strong); }
4681 .metric-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); }
4682 .section-kicker { font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted-2); }
4683 .metric-value { margin-top: 6px; }
4684 .metric-big { display:block; font-size: 20px; font-weight: 900; color: var(--oxide); line-height: 1.15; letter-spacing: -0.02em; }
4685 .metric-exact { position: absolute; bottom: 6px; right: 10px; font-size: 12px; font-weight: 600; color: var(--muted); font-family: ui-monospace, monospace; }
4686 .metric-tooltip { position: absolute; bottom: calc(100% + 10px); left: 50%; transform: translateX(-50%) translateY(7px); background: var(--text); color: var(--bg); padding: 10px 14px; border-radius: 10px; font-size: 12px; font-weight: 500; line-height: 1.55; white-space: normal; max-width: 340px; min-width: 200px; text-align: left; pointer-events: none; opacity: 0; transition: opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index: 100; box-shadow: 0 4px 18px rgba(0,0,0,0.25); }
4687 .metric-tooltip::after { content: ''; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); border: 5px solid transparent; border-top-color: var(--text); }
4688 .metric:hover .metric-tooltip { opacity: 1; transform: translateX(-50%) translateY(0); }
4689 .hero { padding: 24px 24px 20px; margin-bottom: 18px; background: linear-gradient(150deg, rgba(111,155,255,0.06) 0%, transparent 55%), var(--surface); border-top: 3px solid var(--accent); }
4690 .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:16px; }
4691 .hero h1 { margin:0 0 8px; font-size: 28px; letter-spacing: -0.04em; }
4692 .run-id-row { display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap:10px; margin-top:16px; }
4693 @media(max-width:960px) { .run-id-row { grid-template-columns: 1fr 1fr; } }
4694 @media(max-width:560px) { .run-id-row { grid-template-columns: 1fr; } }
4695 .run-id-chip { display:flex; flex-direction:column; gap:5px; padding:12px 14px; border-radius:10px; background:var(--surface-2); border:1px solid var(--line); border-left:3px solid var(--accent); color:var(--text); position:relative; cursor:default; transition:transform 0.18s ease, box-shadow 0.18s ease; }
4696 .run-id-chip[data-copy] { cursor:pointer; }
4697 a.run-id-chip-link { text-decoration:none; cursor:pointer; }
4698 a.run-id-chip-link:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; border-left-color:var(--oxide); }
4699 .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
4700 .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
4701 .run-id-chip-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.1em; color:var(--accent); display:flex; align-items:center; gap:4px; }
4702 .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
4703 .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
4704 .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
4705 .submodule-state-badge { display:inline-block; font-size:10px; font-style:italic; font-weight:600; color:var(--accent-2); background:rgba(100,130,220,0.10); border:1px solid rgba(100,130,220,0.22); border-radius:4px; padding:1px 6px; letter-spacing:0.03em; }
4706 body.dark-theme .submodule-state-badge { color:var(--accent); background:rgba(111,155,255,0.13); border-color:rgba(111,155,255,0.28); }
4707 .chip-tooltip { position:absolute; top:calc(100% + 8px); left:50%; transform:translateX(-50%) translateY(-7px); background:var(--text); color:var(--bg); padding:6px 11px; border-radius:8px; font-size:11px; font-weight:500; white-space:nowrap; pointer-events:none; opacity:0; transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index:200; box-shadow:0 4px 16px rgba(0,0,0,0.25); line-height:1.4; }
4708 .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
4709 .run-id-chip:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
4710 a.run-id-chip-link:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
4711 .chip-copy-icon { display:inline-block; margin-left:5px; font-size:10px; opacity:0.55; vertical-align:middle; }
4712 .chip-label-icon { display:inline-block; vertical-align:middle; margin-right:3px; margin-top:-1px; opacity:0.8; }
4713 .chip-popout-icon { display:inline-block; vertical-align:middle; margin-left:4px; opacity:0.6; flex-shrink:0; }
4714 .run-id-short-badge { font-family:ui-monospace,monospace; font-size:13px; font-weight:700; color:var(--muted); background:var(--surface-2); border:1px solid var(--line); border-radius:6px; padding:2px 8px; letter-spacing:0.04em; white-space:nowrap; vertical-align:middle; }
4715 body.dark-theme .run-id-short-badge { color:var(--muted-2); }
4716 .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
4717 @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
4718 .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
4719 .subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
4720 .meta { display:flex; flex-wrap:wrap; align-items:center; gap:0; margin:14px 0 20px; padding:10px 0; border-top:1px solid var(--line); border-bottom:1px solid var(--line); width:100%; }
4721 .meta-chip { flex:1; display:inline-flex; align-items:center; justify-content:center; gap:5px; padding:0 10px; font-size:13px; font-weight:500; color:var(--muted); border-right:1px solid var(--line); line-height:1.8; }
4722 .meta-chip:last-child { border-right:none; }
4723 .meta-chip b { color:var(--text); font-weight:700; }
4724 .prev-scan-banner { background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:18px 20px; margin:0 0 20px; display:flex; flex-direction:column; gap:12px; width:100%; box-sizing:border-box; box-shadow:0 4px 16px rgba(77,44,20,0.10); }
4725 body.dark-theme .prev-scan-banner { box-shadow:0 4px 16px rgba(0,0,0,0.3); }
4726 .prev-scan-banner-empty { flex-direction:row; align-items:center; gap:8px; font-size:13px; color:var(--muted); font-style:italic; }
4727 .prev-scan-banner-top { display:flex; flex-direction:column; gap:4px; }
4728 .prev-scan-meta { display:flex; align-items:center; gap:7px; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); flex-wrap:wrap; }
4729 .prev-scan-ts { font-weight:500; text-transform:none; letter-spacing:0; color:var(--muted); }
4730 .prev-scan-count { font-weight:500; text-transform:none; letter-spacing:0; color:var(--muted); }
4731 .prev-scan-summary { font-size:13px; font-weight:600; color:var(--text); }
4732 .prev-scan-summary b { font-weight:900; }
4733 .delta-neutral-text { color:var(--muted); }
4734 .delta-up { color:#2a6846; }
4735 .delta-down { color:#b23030; }
4736 body.dark-theme .delta-up { color:#5aba8a; }
4737 body.dark-theme .delta-down { color:#e07070; }
4738 .delta-card-row { display:grid; grid-template-columns:repeat(7,1fr); gap:12px; width:100%; }
4739 @media(max-width:1000px){ .delta-card-row { grid-template-columns:repeat(4,1fr); } }
4740 @media(max-width:540px){ .delta-card-row { grid-template-columns:repeat(2,1fr); } }
4741 .delta-card-inline { display:flex; flex-direction:column; gap:4px; padding:14px 16px; border-radius:12px; border:1px solid var(--line); background:var(--surface); position:relative; cursor:default; transition:transform .2s ease, box-shadow .2s ease; box-shadow:0 2px 8px rgba(77,44,20,0.07); }
4742 .delta-card-inline:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.22); z-index:10; }
4743 body.dark-theme .delta-card-inline { box-shadow:0 2px 8px rgba(0,0,0,0.2); }
4744 body.dark-theme .delta-card-inline:hover { box-shadow:0 12px 32px rgba(0,0,0,0.55); }
4745 .delta-card-val { font-size:20px; font-weight:900; color:var(--oxide); line-height:1.2; }
4746 .delta-card-val.pos { color:#2a6846; }
4747 .delta-card-val.neg { color:#b23030; }
4748 .delta-card-val.mod { color:#7a5a10; }
4749 body.dark-theme .delta-card-val.pos { color:#5aba8a; }
4750 body.dark-theme .delta-card-val.neg { color:#e07070; }
4751 body.dark-theme .delta-card-val.mod { color:#d4a843; }
4752 .delta-card-lbl { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-top:4px; }
4753 .delta-card-tip { position:absolute; top:calc(100% + 8px); left:50%; transform:translateX(-50%) translateY(-7px); background:var(--text); color:var(--bg); padding:7px 12px; border-radius:8px; font-size:11px; white-space:nowrap; pointer-events:none; opacity:0; transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index:200; box-shadow:0 4px 12px rgba(0,0,0,0.18); }
4754 .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
4755 .delta-card-inline:hover .delta-card-tip { opacity:1; transform:translateX(-50%) translateY(0); }
4756 .delta-panel-link { display:inline-flex; align-items:center; gap:6px; padding:8px 16px; border-radius:10px; border:1px solid var(--line); background:var(--surface); color:var(--text); font-size:12px; font-weight:600; text-decoration:none; transition:background .15s, border-color .15s, color .15s, transform .15s, box-shadow .15s; box-shadow:0 2px 6px rgba(77,44,20,0.08); }
4757 .delta-panel-link:hover { background:var(--oxide); color:#fff; border-color:var(--oxide); transform:translateY(-2px); box-shadow:0 6px 18px rgba(77,44,20,0.25); }
4758 body.dark-theme .delta-panel-link { box-shadow:0 2px 6px rgba(0,0,0,0.2); }
4759 body.dark-theme .delta-panel-link:hover { box-shadow:0 6px 18px rgba(0,0,0,0.45); }
4760 .soft-chip { display:inline-flex; align-items:center; min-height:32px; padding:0 12px; border-radius:999px; border:1px solid var(--line); background:var(--surface-2); color:var(--text); font-size:13px; font-weight:700; }
4761 .toolbar { display:flex; flex-wrap:wrap; justify-content:space-between; gap: 12px; align-items: center; margin-bottom: 16px; }
4762 .toolbar-left { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
4763 .search { min-width: 280px; padding: 10px 12px; border-radius: 10px; border:1px solid var(--line-strong); background: var(--surface-2); color:var(--text); }
4764 .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
4765 .pill { padding: 6px 10px; border-radius: 999px; border:1px solid var(--line); background: var(--surface-2); font-size: 12px; font-weight: 700; }
4766 .pill.good { background: var(--good-bg); color: var(--good-text); }
4767 .pill.info { background: var(--info-bg); color: var(--info-text); }
4768 .export-group { display:flex; gap:6px; align-items:center; }
4769 .export-btn { display:inline-flex; align-items:center; gap:5px; padding:6px 12px; border-radius:8px; border:1px solid var(--line-strong); background:var(--surface-2); color:var(--text); font-size:12px; font-weight:700; cursor:pointer; white-space:nowrap; }
4770 .export-btn:hover { background:var(--accent); color:#fff; border-color:var(--accent); }
4771 .page-size-row { display:flex; align-items:center; gap:6px; }
4772 .page-size-label { font-size:13px; color:var(--muted); font-weight:600; }
4773 .page-size-select { padding:5px 10px; border-radius:8px; border:1px solid var(--line-strong); background:var(--surface-2); color:var(--text); font-size:13px; font-weight:700; cursor:pointer; }
4774 .page-count-label { font-size:12px; color:var(--muted); white-space:nowrap; }
4775 .pagination-bar { display:flex; align-items:center; justify-content:center; gap:14px; padding:10px 0 2px; }
4776 .pager-btn { padding:6px 16px; border-radius:8px; border:1px solid var(--line-strong); background:var(--surface-2); color:var(--text); font-size:13px; font-weight:700; cursor:pointer; transition:background .15s,color .15s; }
4777 .pager-btn:hover:not(:disabled) { background:var(--accent); color:#fff; border-color:var(--accent); }
4778 .pager-btn:disabled { opacity:.4; cursor:default; }
4779 .pager-info { font-size:13px; color:var(--muted); font-weight:600; min-width:120px; text-align:center; }
4780 .pager-edge { font-size:12px; padding:5px 10px; }
4781 .pager-jump-wrap { font-size:13px; color:var(--muted); font-weight:600; display:flex; align-items:center; gap:5px; white-space:nowrap; }
4782 .pager-jump { width:52px; padding:3px 5px; border-radius:6px; border:1px solid var(--line-strong); background:var(--surface); color:var(--text); font-size:13px; font-weight:700; text-align:center; -moz-appearance:textfield; }
4783 .pager-jump::-webkit-inner-spin-button,.pager-jump::-webkit-outer-spin-button { -webkit-appearance:none; margin:0; }
4784 .table-shell { border: 1px solid var(--line); border-radius: 16px; overflow: auto; background: var(--surface-2); max-height: 900px; }
4785 /* Clip wrapper: hides the scrollbar track that hangs 8px past the right edge */
4786 .table-shell-clip { overflow: hidden !important; max-height: none !important; }
4787 /* Skipped-files scroll pane: auto so no phantom space when content is short */
4788 #skipped-shell { overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; scrollbar-color: var(--line-strong) var(--surface-2); }
4789 #per-file-table tbody tr:last-child td, #skipped-table tbody tr:last-child td { border-bottom: none; }
4790 #skipped-shell::-webkit-scrollbar { width: 8px; }
4791 #skipped-shell::-webkit-scrollbar-track { background: var(--surface-2); }
4792 #skipped-shell::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 4px; }
4793 table { width: 100%; border-collapse: collapse; font-size: 14px; }
4794 th, td { text-align: left; padding: 11px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
4795 th { color: var(--muted); font-weight: 800; background: var(--surface-2); cursor: pointer; position: sticky; top: 0; z-index: 1; white-space: nowrap; }
4796 /* Per-file detail table — auto layout so File column sizes to content */
4797 .table-resizable { table-layout: auto; }
4798 .table-resizable th { position: sticky; top: 0; z-index: 2; overflow: hidden; white-space: nowrap; min-width: 52px; }
4799 .table-resizable td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
4800 .table-resizable td.mono { overflow: visible; text-overflow: unset; white-space: nowrap; }
4801 #skipped-table { table-layout: fixed; width: 100%; }
4802 #skipped-table th, #skipped-table td { padding: 7px 8px; }
4803 #skipped-table td:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
4804 /* Hotspots table — overflow visible so header tooltips can escape the cell */
4805 #hotspots-table th { overflow: visible; }
4806 .hs-hint { color: var(--muted); font-style: italic; }
4807 /* Column-header explainer tooltip (shared visual language with .stat-chip-tip) */
4808 .col-tip { position: absolute; top: calc(100% + 9px); left: 0; z-index: 60; width: max-content; max-width: 270px;
4809 background: var(--text); color: var(--bg); padding: 9px 12px; border-radius: 9px;
4810 font-size: 11.5px; font-weight: 500; line-height: 1.5; letter-spacing: normal; text-transform: none;
4811 white-space: normal; text-align: left; box-shadow: 0 10px 30px rgba(0,0,0,0.22);
4812 opacity: 0; pointer-events: none; transition: opacity .18s ease; }
4813 .col-tip.col-tip-r { left: auto; right: 0; }
4814 .col-tip strong { color: var(--bg); }
4815 .col-tip::after { content: ''; position: absolute; bottom: 100%; left: 16px;
4816 border: 6px solid transparent; border-bottom-color: var(--text); }
4817 .col-tip.col-tip-r::after { left: auto; right: 16px; }
4818 #hotspots-table th:hover .col-tip { opacity: 1; }
4819 /* Column resize handle */
4820 .col-resize-handle { position: absolute; top: 0; right: 0; bottom: 0; width: 6px; cursor: col-resize; z-index: 10; }
4821 .col-resize-handle:hover, .col-resize-handle.dragging { background: rgba(211,122,76,0.3); }
4822 #per-file-table { table-layout: fixed; width: 100%; min-width: 0; }
4823 #per-file-table th, #per-file-table td { padding: 8px 6px; }
4824 /* File column: pinned, truncates long paths */
4825 #per-file-table th:first-child { position: sticky; top: 0; left: 0; z-index: 3; width: 26%; background: var(--surface-2); padding: 8px 6px; overflow: hidden; text-overflow: ellipsis; }
4826 #per-file-table td:first-child { position: sticky; left: 0; z-index: 1; background: var(--surface-2); padding: 8px 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
4827 #per-file-table th:nth-child(2) { width: 6%; }
4828 /* 12 numeric columns share the remaining 68%: 26+6+12×5.67≈98% total */
4829 #per-file-table th:nth-child(n+3) { width: 5.67%; }
4830 /* Override mono class overflow so file paths truncate */
4831 #per-file-table td.mono { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
4832 #per-file-table tbody tr:hover td:first-child { background: rgba(255,247,238,0.6); }
4833 body.dark-theme #per-file-table tbody tr:hover td:first-child { background: rgba(255,255,255,0.03); }
4834 /* Language breakdown: auto layout with resizable columns — headers size to content */
4835 #lang-breakdown-table { width: 100%; min-width: 760px; }
4836 #lang-breakdown-table th, #lang-breakdown-table td { padding: 8px 6px; font-size: 13px; }
4837 /* Skipped table: extend truncation to all cells, not just the first column */
4838 #skipped-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 0; }
4839 /* Support opportunities table: fixed layout so Category/Count columns don't grow */
4840 .support-table { table-layout: fixed; width: 100%; }
4841 .support-table th:first-child { width: 20%; }
4842 .support-table th:nth-child(2) { width: 6%; }
4843 .support-table th:nth-child(3) { width: 24%; }
4844 .support-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 0; vertical-align: top; padding-top: 10px; padding-bottom: 10px; }
4845 /* Description and example columns must wrap — content can be long */
4846 .support-table td:nth-child(3) { white-space: normal; overflow: visible; text-overflow: unset; max-width: none; line-height: 1.45; }
4847 .support-table td:last-child { white-space: normal; overflow: visible; text-overflow: unset; max-width: none; }
4848 .support-example-file { display: inline-block; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; padding: 1px 6px; background: var(--surface); border: 1px solid var(--line); border-radius: 4px; margin-bottom: 2px; word-break: break-all; }
4849 .support-recommendation { color: var(--muted); font-size: 11px; margin: 6px 0 0; line-height: 1.5; }
4850 .num-col { text-align: right !important; }
4851 /* Per-file coverage table: fixed layout keeps numeric columns compact and off the shell border */
4852 .cov-file-table { table-layout: fixed; }
4853 .cov-file-table th:not(:first-child), .cov-file-table td:not(:first-child) { width: 150px; }
4854 .cov-file-table th.num-col, .cov-file-table td.num-col { padding-right: 16px; }
4855 tbody tr:hover { background: rgba(255, 247, 238, 0.6); }
4856 body.dark-theme tbody tr:hover { background: rgba(255,255,255,0.03); }
4857 tr:last-child td { border-bottom: none; }
4858 .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
4859 .small { color: var(--muted); font-size: 13px; }
4860 .status-tag { display:inline-flex; align-items:center; padding: 4px 8px; border-radius: 999px; border:1px solid var(--line); background: var(--surface-2); font-size: 12px; font-weight: 700; }
4861 .status-analyzedexact { background: var(--good-bg); color: var(--good-text); border-color: rgba(28,135,70,0.18); }
4862 .status-analyzedbesteffort, .status-skippedbypolicy { background: var(--warn-bg); color: var(--warn-text); border-color: rgba(146,96,0,0.18); }
4863 .status-skippedunsupported, .status-skippedbinary { background: var(--danger-bg); color: var(--danger-text); border-color: rgba(179,59,59,0.18); }
4864 .stack { display:grid; gap:22px; }
4865 .summary-strip { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin-bottom:18px; }
4866 @media(max-width:800px) { .summary-strip { grid-template-columns:repeat(2,1fr) !important; } }
4867 .test-density-row { display:flex; align-items:center; gap:12px; margin-bottom:16px; padding:10px 16px; border-radius:10px; background:var(--surface-2); border:1px solid var(--line); }
4868 .test-density-num { font-size:22px; font-weight:900; color:var(--oxide); line-height:1; }
4869 .test-density-meta { display:flex; flex-direction:column; gap:2px; }
4870 .test-density-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); }
4871 .test-density-sub { font-size:11px; color:var(--muted-2); }
4872 .test-density-badge { margin-left:auto; padding:4px 12px; border-radius:999px; font-size:12px; font-weight:700; }
4873 .test-density-badge.good { background:var(--good-bg); color:var(--good-text); }
4874 .test-density-badge.warn { background:var(--warn-bg); color:var(--warn-text); }
4875 .test-density-badge.danger { background:var(--danger-bg); color:var(--danger-text); }
4876 .info-callout { display:flex; align-items:flex-start; gap:10px; margin-top:14px; padding:11px 14px; border-radius:10px; background:var(--info-bg); border:1px solid rgba(68,103,216,0.18); color:var(--info-text); font-size:13px; line-height:1.5; }
4877 .info-callout-icon { flex:0 0 auto; font-size:15px; margin-top:1px; }
4878 .info-callout code { background:rgba(68,103,216,0.12); border-radius:4px; padding:1px 5px; font-size:12px; }
4879 body.dark-theme .info-callout { background:rgba(100,130,255,0.09); border-color:rgba(100,130,255,0.22); }
4880 .empty-state-row td { text-align:center; padding:20px; color:var(--muted-2); font-size:13px; font-style:italic; }
4881 /* auto-fit so a lone gauge card (line-only coverage) spans the full width instead of 1/3 */
4882 .cov-gauge-row { display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:16px; margin-bottom:18px; }
4883 @media(max-width:700px) { .cov-gauge-row { grid-template-columns:1fr; } }
4884 .cov-gauge-card { position:relative; background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:18px 20px; display:flex; flex-direction:column; gap:8px; cursor:default; transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1); min-width:0; }
4885 .cov-gauge-card:hover { transform:translateY(-3px); box-shadow:0 10px 28px rgba(77,44,20,0.15); z-index:10; }
4886 .cov-gauge-tip { position:absolute; top:calc(100% + 10px); left:50%; transform:translateX(-50%) translateY(-7px); background:var(--text); color:var(--bg); padding:10px 14px; border-radius:8px; font-size:12px; font-weight:500; line-height:1.55; white-space:normal; max-width:420px; min-width:200px; text-align:left; pointer-events:none; opacity:0; transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index:200; box-shadow:0 4px 18px rgba(0,0,0,0.25); }
4887 .cov-gauge-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
4888 .cov-gauge-card:hover .cov-gauge-tip { opacity:1; transform:translateX(-50%) translateY(0); }
4889 .cov-gauge-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); }
4890 .cov-gauge-val { font-size:32px; font-weight:900; line-height:1; }
4891 .cov-gauge-track { height:8px; border-radius:4px; background:var(--line); overflow:hidden; }
4892 .cov-gauge-fill { height:100%; border-radius:4px; transition:width .5s ease; }
4893 .cov-gauge-sub { font-size:11px; color:var(--muted); }
4894 .stat-chip { background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:14px 16px; position:relative; cursor:default; transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1); }
4895 .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
4896 .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
4897 .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-top:4px; }
4898 .stat-chip-tip { position:absolute; top:calc(100% + 10px); left:50%; transform:translateX(-50%) translateY(-7px); background:var(--text); color:var(--bg); padding:10px 14px; border-radius:8px; font-size:12px; font-weight:500; line-height:1.55; white-space:normal; max-width:420px; min-width:200px; text-align:left; pointer-events:none; opacity:0; transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index:200; box-shadow:0 4px 18px rgba(0,0,0,0.25); }
4899 .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
4900 .stat-chip:hover .stat-chip-tip { opacity:1; transform:translateX(-50%) translateY(0); }
4901 .stat-chip-exact { position:absolute; bottom:6px; right:10px; font-size:12px; font-weight:600; color:var(--muted); font-variant-numeric:tabular-nums; line-height:1; }
4902 .cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
4903 .cocomo-mode-tip { position:absolute; top:calc(100% + 8px); left:0; transform:translateY(-7px); background:var(--text); color:var(--bg); padding:9px 13px; border-radius:8px; font-size:11px; font-weight:500; line-height:1.55; white-space:normal; max-width:300px; min-width:180px; pointer-events:none; opacity:0; transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index:300; box-shadow:0 4px 18px rgba(0,0,0,0.25); }
4904 .cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
4905 .cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; transform:translateY(0); }
4906 .report-stack { display:grid; gap: 18px; align-items:start; }
4907 pre { background: var(--surface-2); border: 1px solid var(--line); border-radius: 16px; padding: 16px; overflow: auto; font-size: 12px; color: var(--text); }
4908 .warn-list { margin: 0; padding-left: 18px; line-height: 1.6; }
4909 .sort-indicator { color: var(--muted-2); font-size: 11px; margin-left: 6px; }
4910 .warning-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
4911 .warning-card { padding: 10px 12px; }
4912 .warning-card h3 { margin: 0 0 4px; font-size: 12px; font-weight: 700; }
4913 .warning-card .count { font-size: 16px; font-weight: 800; margin-bottom: 4px; }
4914 .tone-neutral .count { color: var(--text); }
4915 .tone-warn .count { color: var(--warn-text); }
4916 .tone-danger .count { color: var(--danger-text); }
4917 .tone-neutral .warning-count { color: var(--oxide); }
4918 .tone-warn .warning-count { color: var(--warn-text); }
4919 .tone-danger .warning-count { color: var(--danger-text); }
4920 .support-note { color: var(--muted); font-size: 11px; line-height: 1.45; }
4921 .support-table th { cursor: default; }
4922 details { border: 1px solid var(--line); border-radius: 14px; background: var(--surface-2); }
4923 summary { cursor: pointer; padding: 14px 16px; font-weight: 700; }
4924 details > div { padding: 0 16px 16px; }
4925 .warning-console { margin: 0; padding: 14px 16px; border-radius: 12px; border:1px solid var(--line); background: #16120f; color: #d4f0d0; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre-wrap; line-height: 1.55; max-height: 260px; overflow: auto; }
4926 .warning-console-actions { display:flex; gap:10px; flex-wrap:wrap; margin-top: 12px; }
4927 .warning-console.hidden { display:none; }
4928 @media (max-width: 1200px) {
4929 .warning-grid { grid-template-columns: 1fr 1fr; }
4930 }
4931 @media (max-width: 960px) {
4932 .top-nav-inner { grid-template-columns: 1fr; }
4933 .nav-project-slot, .nav-status { justify-content:flex-start; }
4934 .warning-grid, .report-stack { grid-template-columns: 1fr; }
4935 .hero-top { flex-direction: column; }
4936 .search { min-width: 100%; width: 100%; }
4937 }
4938 @media (max-width: 640px) {
4939 .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
4940 }
4941 /* ── Report header / footer identification banner ─────────────────── */
4942 .report-id-banner { background: var(--nav); color: #fff; font-size: 11px; font-weight: 700; letter-spacing: 0.05em; display: flex; align-items: center; justify-content: center; height: 27px; padding: 0 16px; position: fixed; top: 0; left: 0; right: 0; z-index: 32; }
4943 .report-id-footer-banner { background: var(--nav); color: #fff; font-size: 11px; font-weight: 700; letter-spacing: 0.05em; display: flex; align-items: center; justify-content: center; height: 27px; padding: 0 16px; position: fixed; bottom: 0; left: 0; right: 0; z-index: 32; }
4944 body.has-report-banner .top-nav { top: 27px; }
4945 body.has-report-banner { padding-bottom: 27px; }
4946 /* ── Print & PDF export ──────────────────────────────────────────── */
4947 @page { size: A4 landscape; margin: 0.35in 0.5in; }
4948
4949 @media print {
4950 *, *::before, *::after {
4951 -webkit-print-color-adjust: exact !important;
4952 print-color-adjust: exact !important;
4953 box-sizing: border-box !important;
4954 }
4955
4956 html, body {
4957 background: #f5efe8 !important;
4958 min-height: auto !important;
4959 width: 100% !important;
4960 }
4961
4962 /* Report id banner — fixed position repeats the banner on every printed page */
4963 .report-id-banner { display: flex !important; align-items: center !important; justify-content: center !important; position: fixed !important; top: 0 !important; left: 0 !important; right: 0 !important; padding: 3px 12px !important; font-size: 10px !important; background: #3d3d3d !important; color: #fff !important; z-index: 9999 !important; }
4964 .report-id-footer-banner { display: flex !important; align-items: center !important; justify-content: center !important; position: fixed !important; bottom: 0 !important; left: 0 !important; right: 0 !important; padding: 3px 12px !important; font-size: 10px !important; margin-top: 0 !important; background: #3d3d3d !important; color: #fff !important; z-index: 9999 !important; }
4965 body.has-report-banner .top-nav { top: 0 !important; }
4966 body.has-report-banner { padding-bottom: 0 !important; }
4967 /* Hide interactive UI-chrome; keep section heading text visible */
4968 .top-nav, .hero-actions,
4969 .background-watermarks, #code-particles,
4970 .header-button, .theme-toggle,
4971 .nav-dropdown-wrap, .config-actions,
4972 .warnings-show-link, .warning-console-actions,
4973 .toolbar .pill-row, .toolbar .export-group,
4974 input[type="search"], button { display: none !important; }
4975 /* Show toolbar as a plain block so h2 headings are visible */
4976 .toolbar { display: block !important; margin-bottom: 8px !important; }
4977 .toolbar-left { display: block !important; }
4978
4979 /* Remove page-level layout constraints */
4980 .page {
4981 max-width: none !important;
4982 width: 100% !important;
4983 padding: 0 !important;
4984 margin: 0 !important;
4985 }
4986
4987 .panel, .hero, .section,
4988 .saved-report-shell, .saved-panel, .report-shell, .stack {
4989 max-width: none !important;
4990 width: 100% !important;
4991 box-shadow: none !important;
4992 border: 1px solid #ddd !important;
4993 border-radius: 10px !important;
4994 margin-bottom: 10px !important;
4995 overflow: visible !important;
4996 }
4997
4998 /* Force grids to their full-width column counts regardless of viewport */
4999 .summary-grid {
5000 display: grid !important;
5001 grid-template-columns: repeat(5, minmax(0, 1fr)) !important;
5002 gap: 10px !important;
5003 }
5004
5005 .warning-grid {
5006 display: grid !important;
5007 grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
5008 gap: 8px !important;
5009 }
5010
5011 .report-stack {
5012 display: grid !important;
5013 gap: 12px !important;
5014 align-items: start !important;
5015 }
5016
5017 /* Metric cards */
5018 .metric {
5019 box-shadow: none !important;
5020 border: 1px solid #e0d0c0 !important;
5021 border-radius: 8px !important;
5022 break-inside: avoid !important;
5023 padding: 10px 12px 22px !important;
5024 min-height: 0 !important;
5025 }
5026
5027 .metric-big { font-size: 20px !important; }
5028 .metric-exact { font-size: 10px !important; bottom: 5px !important; right: 8px !important; }
5029 .metric-label { font-size: 10px !important; }
5030
5031 /* Page break control — small atomic cards stay together; large panels
5032 and tables flow freely so they never force blank pages. */
5033 .metric, .warning-card, .run-id-chip { break-inside: avoid !important; }
5034 .hero, .panel, .stack { break-inside: auto !important; }
5035 section { break-inside: auto !important; }
5036 /* Keep each chart panel whole — browser moves it to the next page rather than
5037 slicing through the middle of a canvas. */
5038 .chart-section { break-inside: avoid !important; }
5039 /* Keep the summary grid on the same page as the hero header when possible */
5040 .summary-grid { break-before: avoid !important; }
5041 /* Section headings never orphan at the bottom of a page */
5042 h2, h3 { break-after: avoid !important; orphans: 3; widows: 3; }
5043 /* Keep the first few rows of a table with the header */
5044 thead { break-after: avoid !important; }
5045
5046 /* Language charts — table layout is inherently side-by-side */
5047 #lang-overview-charts table { display: inline-table !important; }
5048 #lang-overview-charts td { vertical-align: top !important; }
5049
5050 /* Tables */
5051 .table-shell {
5052 max-height: none !important;
5053 overflow: visible !important;
5054 width: 100% !important;
5055 break-inside: auto !important;
5056 }
5057
5058 table {
5059 width: 100% !important;
5060 table-layout: auto !important;
5061 font-size: 10px !important;
5062 border-collapse: collapse !important;
5063 orphans: 4 !important;
5064 widows: 4 !important;
5065 }
5066
5067 /* Remove the screen-layout min-width so tables scale to page width */
5068 #per-file-table, #skipped-table { min-width: 0 !important; }
5069 /* Release sticky column positioning (not meaningful on paper) */
5070 #per-file-table th:first-child,
5071 #per-file-table td:first-child { position: static !important; }
5072 /* Show ALL rows — JS pagination hides rows via inline style; !important overrides it */
5073 #per-file-table tbody tr, #skipped-table tbody tr, #hotspots-table tbody tr { display: table-row !important; }
5074 /* Hide pagination controls — not interactive in PDF */
5075 .page-size-row, .pagination-bar { display: none !important; }
5076 /* Header tooltips and the interaction hint are screen-only */
5077 .col-tip { display: none !important; }
5078 .hs-hint { display: none !important; }
5079
5080 thead { display: table-header-group; }
5081 tr { break-inside: avoid !important; }
5082
5083 th {
5084 position: relative !important;
5085 font-size: 9px !important;
5086 font-weight: 700 !important;
5087 color: #333 !important;
5088 padding: 5px 8px !important;
5089 background: rgba(211,122,76,0.18) !important;
5090 white-space: normal !important;
5091 }
5092 /* Resize handles are screen-only — hide them in print */
5093 .col-resize-handle { display: none !important; }
5094 /* Sort indicators are redundant on paper */
5095 .sort-indicator { display: none !important; }
5096
5097 td {
5098 white-space: normal !important;
5099 overflow-wrap: anywhere !important;
5100 word-break: break-word !important;
5101 padding: 5px 8px !important;
5102 font-size: 10px !important;
5103 border-bottom: 1px solid #e8d8c8 !important;
5104 }
5105
5106 pre, code {
5107 white-space: pre-wrap !important;
5108 overflow-wrap: anywhere !important;
5109 word-break: break-word !important;
5110 font-size: 9px !important;
5111 max-height: none !important;
5112 }
5113
5114 .warning-card {
5115 box-shadow: none !important;
5116 border: 1px solid #ddd !important;
5117 break-inside: avoid !important;
5118 padding: 10px !important;
5119 }
5120
5121 .hero-top { flex-direction: row !important; }
5122
5123 .run-id-row { flex-wrap: wrap !important; gap: 4px !important; }
5124 .run-id-chip { font-size: 9px !important; padding: 4px 8px !important; border-left-width: 2px !important; }
5125 .meta { flex-wrap: wrap !important; gap: 0 !important; padding: 4px 0 !important; border-top: 1px solid #ccc !important; border-bottom: 1px solid #ccc !important; width: 100% !important; }
5126 .meta-chip { flex: 1 !important; justify-content: center !important; font-size: 9px !important; padding: 0 8px !important; border-right: 1px solid #ccc !important; }
5127 .meta-chip:last-child { border-right: none !important; }
5128
5129 .report-footer {
5130 border-top: 1px solid #ccc !important;
5131 margin-top: 12px !important;
5132 font-size: 10px !important;
5133 }
5134
5135 /* Collapse all <details> in print except the warnings block */
5136 details { border: 1px solid #ddd !important; border-radius: 8px !important; }
5137 details > summary { display: block !important; font-size: 10px !important; }
5138 details > div { display: none !important; }
5139 .warning-console { display: none !important; }
5140 .warning-console-actions { display: none !important; }
5141 /* Always expand the run-warnings details in PDF */
5142 details.warnings-details > div { display: block !important; }
5143 details.warnings-details .warning-console {
5144 display: block !important;
5145 max-height: none !important;
5146 overflow: visible !important;
5147 font-size: 8px !important;
5148 white-space: pre-wrap !important;
5149 word-break: break-all !important;
5150 }
5151 details.warnings-details .code-block-toolbar { display: none !important; }
5152
5153 /* Pill badges */
5154 .pill { font-size: 9px !important; padding: 2px 6px !important; min-height: auto !important; }
5155
5156 /* Support opportunities table */
5157 .support-table td:first-child { font-weight: 600; font-size: 10px !important; }
5158
5159 /* Hide canvas-based interactive chart sections; replaced by pre-rendered variants */
5160 .chart-section { display: none !important; }
5161 .charts-grid { display: none !important; }
5162 /* Pre-rendered chart variants — no forced page break; flow naturally after hero section */
5163 .pdf-variants-root { display: block !important; padding: 0 !important; }
5164 .pdf-variant-group { break-inside: auto !important; margin-bottom: 8px !important; background: #faf6f0 !important; border: 1px solid #e0d0c0 !important; border-radius: 10px !important; padding: 10px 12px !important; }
5165 .pdf-variant-group-title { break-after: avoid !important; font-size: 12px !important; font-weight: 800 !important; color: #3d2d26 !important; margin: 0 0 6px !important; padding-bottom: 4px !important; border-bottom: 2px solid #d37a4c !important; }
5166 .pdf-variant-grid { display: grid !important; grid-template-columns: 1fr 1fr !important; gap: 6px !important; }
5167 /* Single-column chart (scatter, etc.) — centre and constrain width in print */
5168 .pdf-variant-grid.single-col { grid-template-columns: 1fr !important; }
5169 .pdf-variant-grid.single-col .pdf-variant-panel { max-width: 62% !important; margin: 0 auto !important; }
5170 .pdf-variant-panel { break-inside: avoid !important; }
5171 .pdf-variant-label { font-size: 9px !important; font-weight: 700 !important; text-transform: uppercase !important; letter-spacing: .06em !important; color: #7b675b !important; margin: 0 0 2px !important; }
5172 .pdf-variant-img { width: 100% !important; height: auto !important; display: block !important; border-radius: 5px !important; border: 1px solid #ddd !important; }
5173 }
5174
5175
5176 .warnings-show-link {
5177 display: inline-flex;
5178 align-items: center;
5179 gap: 8px;
5180 padding: 8px 12px;
5181 border-radius: 10px;
5182 border: 1px solid rgba(111, 144, 255, 0.35);
5183 background: #eef3ff;
5184 color: #2f5fe3 !important;
5185 font-weight: 800;
5186 text-decoration: none;
5187 box-shadow: inset 0 1px 0 rgba(255,255,255,0.45);
5188 }
5189
5190 body.dark-theme .warnings-show-link {
5191 background: #1c2847;
5192 color: #a9c1ff !important;
5193 border-color: rgba(169, 193, 255, 0.32);
5194 }
5195
5196 .effective-config-note {
5197 margin: 8px 0 0;
5198 color: var(--muted);
5199 font-size: 14px;
5200 line-height: 1.6;
5201 }
5202 .config-actions { display: flex; gap: 8px; flex-shrink: 0; }
5203 .config-pre-wrap { position: relative; }
5204 .config-pre { margin: 0; background: #16120f; color: #d4f0d0; border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.5; overflow: auto; resize: vertical; max-height: 320px; min-height: 100px; white-space: pre; }
5205 body.dark-theme .config-pre { background: #0e0c0a; color: #b8f0b8; }
5206 .code-block-toolbar { display:flex; justify-content:flex-end; margin-bottom:6px; }
5207 .code-copy-btn { display:inline-flex; align-items:center; gap:5px; background: var(--surface-2); border: 1px solid var(--line-strong); color: var(--muted); border-radius: 8px; padding: 5px 12px; font-size: 12px; font-weight: 700; cursor: pointer; transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; white-space: nowrap; }
5208 .code-copy-btn:hover { background: rgba(184,93,51,0.08); color: var(--oxide-2); border-color: rgba(184,93,51,0.30); }
5209 body.dark-theme .code-copy-btn { background: rgba(255,255,255,0.07); border-color: rgba(255,255,255,0.18); color: rgba(255,255,255,0.75); }
5210 body.dark-theme .code-copy-btn:hover { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.40); color: var(--oxide); }
5211
5212
5213 .page {
5214 position: relative;
5215 z-index: 1;
5216 }
5217 .report-footer { margin-top: 16px; padding: 14px 24px; border-top: 1px solid var(--line); text-align: center; color: var(--muted); font-size: 12px; font-weight: 600; }
5218
5219 /* ── Chart controls & containers ───────────────────────────────────── */
5220 .chart-section { }
5221 .chart-controls { display:flex; gap:12px; align-items:center; flex-wrap:wrap; margin-bottom:14px; }
5222 .chart-controls label { font-size:13px; font-weight:700; color:var(--muted); display:flex; align-items:center; gap:6px; }
5223 .chart-select { background:var(--surface-2); border:1px solid var(--line-strong); border-radius:8px; padding:5px 10px; color:var(--text); font-size:13px; font-weight:600; cursor:pointer; outline:none; appearance:auto; }
5224 .chart-select:focus { border-color:var(--accent); }
5225 .chart-expand-btn { background:none; border:1px solid var(--line-strong); border-radius:6px; cursor:pointer; color:var(--muted); padding:4px 10px; font-size:13px; line-height:1; transition:background .13s,color .13s; }
5226 .chart-expand-btn:hover { background:var(--surface-2); color:var(--text); }
5227 .chart-modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.55); z-index:9999; display:flex; align-items:center; justify-content:center; padding:24px; box-sizing:border-box; }
5228 .chart-modal { background:var(--bg); border-radius:16px; padding:24px 28px; max-width:1000px; width:100%; max-height:88vh; overflow-y:auto; position:relative; box-shadow:0 24px 80px rgba(0,0,0,0.3); }
5229 .chart-modal-title { font-size:15px; font-weight:800; text-transform:uppercase; letter-spacing:.05em; color:var(--text); margin:0 0 2px; display:block; }
5230 .chart-modal-subtitle { font-size:13px; font-weight:600; color:var(--muted); margin:0 0 16px; display:block; letter-spacing:.02em; }
5231 .chart-modal-close { position:absolute; top:14px; right:18px; background:none; border:none; font-size:22px; cursor:pointer; color:var(--text); line-height:1; padding:0; }
5232 .chart-modal-close:hover { opacity:.7; }
5233 .chart-modal-header { display:flex; align-items:center; gap:12px; flex-wrap:nowrap; margin:0 0 16px; padding-right:44px; }
5234 .chart-modal-header .chart-modal-title { flex:1 1 auto; margin:0; min-width:0; }
5235 body.dark-theme .chart-modal { background:var(--surface); }
5236 .chart-container { width:100%; overflow:visible; }
5237 .charts-grid { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:18px; align-items:stretch; }
5238 .charts-grid > .panel { margin:0; min-width:0; display:flex; flex-direction:column; }
5239 .charts-grid .chart-section > div { display:flex; flex-direction:column; flex:1; }
5240 .charts-grid .chart-container { flex:1; min-height:180px; }
5241 .chart-pre { min-height:72px; }
5242 @media (max-width:820px) { .charts-grid { grid-template-columns:1fr; } }
5243 .r-lang-overview { display:flex; gap:40px; align-items:center; justify-content:center; flex-wrap:wrap; padding:8px 0 16px; }
5244 .r-lang-overview-cell { display:flex; flex-direction:column; align-items:center; gap:8px; flex:1 1 280px; max-width:480px; }
5245 .r-lang-overview-cell p { margin:0; font-size:11px; font-weight:800; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); text-align:center; }
5246 .r-lang-overview svg { display:block; max-width:100%; height:auto; }
5247 .rchit { cursor:pointer; transition:opacity .17s,filter .17s,transform .17s; transform-box:fill-box; transform-origin:center center; }
5248 .rchit:hover { filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18)); transform:scale(1.05); }
5249 .lang-bar-row { cursor:pointer; transition:transform .2s cubic-bezier(.34,1.56,.64,1); }
5250 .lang-bar-row:hover { transform:translateY(-2px); }
5251 .lang-bar-row .rchit:hover { filter:none; transform:none; }
5252 .lang-bar-row:hover .rchit { filter:brightness(1.12); transform:scaleY(1.22); }
5253 #r-tt { display:none; position:fixed; background:rgba(15,10,6,.95); color:#fff; border-radius:10px; padding:8px 13px; font-size:12px; line-height:1.5; pointer-events:none; z-index:10001; box-shadow:0 4px 20px rgba(0,0,0,.32); border:1px solid rgba(255,255,255,.1); max-width:240px; white-space:nowrap; }
5254 .chart-tab-bar { display:flex; gap:6px; margin-bottom:12px; flex-wrap:wrap; }
5255 .chart-tab { padding:5px 16px; border-radius:999px; border:1px solid var(--line-strong); background:var(--surface-2); color:var(--muted); font-size:12px; font-weight:700; cursor:pointer; transition:background 0.12s,color 0.12s,border-color 0.12s; }
5256 .chart-tab:hover { background:var(--surface-3); color:var(--text); }
5257 .chart-tab.active { background:var(--accent); color:#fff; border-color:var(--accent); }
5258 .chart-locked-card { display:none; padding:20px 24px; border-radius:14px; background:var(--info-bg); border:1px solid rgba(111,144,255,0.28); color:var(--info-text); font-size:14px; line-height:1.6; }
5259 .chart-locked-card a { color:var(--accent-2); font-weight:700; }
5260 .chart-locked-card h3 { margin:0 0 6px; font-size:15px; }
5261
5262 /* Print: hide interactive controls; keep SVGs; show locked card for history mode */
5263 @media print {
5264 .chart-controls, .chart-tab-bar { display:none !important; }
5265 /* Single-column grid: each chart gets full page width, renders shorter, fits on one page */
5266 .charts-grid { grid-template-columns: 1fr !important; gap: 10px !important; }
5267 /* Cap canvas height so a single chart never overflows a landscape page */
5268 canvas { max-width: 100% !important; max-height: 280px !important; }
5269 .chart-container { width: 100% !important; overflow: visible !important; }
5270 .chart-container svg { max-height:300px !important; }
5271 /* chart-locked-card: do NOT force display:block — let JS-set visibility carry
5272 into print (hidden in normal mode; visible only when history mode is active).
5273 When it does show, use readable dark text instead of accent blue. */
5274 .chart-locked-card { background:#f0f0f0 !important; border:1px solid #bbb !important; color:#333 !important; font-size:11px !important; padding:10px 14px !important; border-radius:8px !important; }
5275 .chart-locked-card h3 { color:#222 !important; font-size:13px !important; }
5276 .chart-locked-card a, .chart-locked-card strong { color:#1a4fa0 !important; }
5277 .chart-locked-card code { background:rgba(0,0,0,0.08) !important; padding:1px 4px !important; border-radius:3px !important; }
5278 }
5279
5280 /* PDF-only chart variants container — hidden on screen, rendered in print */
5281 .pdf-variants-root{display:none;}
5282 .pdf-variant-group{margin-bottom:12px;background:#faf6f0;border:1px solid #e0d0c0;border-radius:10px;padding:12px 14px;}
5283 .pdf-variant-group-title{font-size:15px;font-weight:800;color:#3d2d26;margin:0 0 8px;padding-bottom:5px;border-bottom:2px solid #d37a4c;}
5284 .pdf-variant-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;}
5285 /* Solo charts (one per row) — constrained width, centred */
5286 .pdf-variant-grid.single-col{grid-template-columns:1fr;}
5287 .pdf-variant-grid.single-col .pdf-variant-panel{max-width:62%;margin:0 auto;}
5288 .pdf-variant-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#7b675b;margin:0 0 3px;}
5289 .pdf-variant-img{width:100%;height:auto;display:block;border-radius:6px;border:1px solid #ddd;}
5290
5291 #rpt-loading-overlay{position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;overflow:hidden;transition:opacity .6s cubic-bezier(.4,0,.2,1);background:radial-gradient(125% 125% at 50% 0%,#fbf4ec 0%,#f4ebe0 45%,#ecdfd0 100%);}
5292 #rpt-loading-overlay.fade-out{opacity:0;pointer-events:none;}
5293 /* Drifting color blobs — transform/opacity only (GPU composited, no per-frame repaint) */
5294 .rpt-bg-blob{position:absolute;border-radius:50%;filter:blur(64px);opacity:.5;pointer-events:none;will-change:transform;}
5295 .rpt-blob-a{width:48vw;height:48vw;left:-10vw;top:-12vw;background:radial-gradient(circle,#e8932f,transparent 64%);animation:rpt-drift-a 17s ease-in-out infinite;}
5296 .rpt-blob-b{width:42vw;height:42vw;right:-8vw;bottom:-10vw;background:radial-gradient(circle,#d3621a,transparent 64%);animation:rpt-drift-b 21s ease-in-out infinite;}
5297 .rpt-blob-c{width:34vw;height:34vw;right:20vw;top:-8vw;background:radial-gradient(circle,#caa14f,transparent 64%);opacity:.38;animation:rpt-drift-c 25s ease-in-out infinite;}
5298 @keyframes rpt-drift-a{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(9vw,7vw,0) scale(1.18);}}
5299 @keyframes rpt-drift-b{0%,100%{transform:translate3d(0,0,0) scale(1.06);}50%{transform:translate3d(-8vw,-6vw,0) scale(.88);}}
5300 @keyframes rpt-drift-c{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(-7vw,8vw,0) scale(1.22);}}
5301 body.dark-theme #rpt-loading-overlay{background:radial-gradient(125% 125% at 50% 0%,#241810 0%,#1a120b 45%,#130c06 100%);}
5302 body.dark-theme .rpt-bg-blob{opacity:.36;}
5303 .rpt-load-card{position:relative;z-index:1;display:flex;flex-direction:column;align-items:center;gap:22px;width:432px;max-width:88vw;padding:46px 54px 38px;background:linear-gradient(155deg,rgba(255,255,253,.95),rgba(255,248,240,.9));border:1px solid rgba(196,110,40,.16);border-radius:26px;box-shadow:0 1px 0 rgba(255,255,255,.8) inset,0 22px 64px rgba(120,64,16,.16),0 4px 16px rgba(0,0,0,.06);animation:rpt-card-in .5s cubic-bezier(.22,.68,0,1.12) both;}
5304 @keyframes rpt-card-in{from{opacity:0;transform:translateY(14px) scale(.96);}to{opacity:1;transform:none;}}
5305 body.dark-theme .rpt-load-card{background:linear-gradient(155deg,rgba(42,24,12,.92),rgba(28,15,6,.95));border-color:rgba(200,120,50,.16);box-shadow:0 1px 0 rgba(255,200,140,.05) inset,0 22px 64px rgba(0,0,0,.5),0 4px 16px rgba(0,0,0,.35);}
5306 /* Logo is static — no bounce (kept GPU-cheap) */
5307 .rpt-load-logo{width:58px;height:58px;object-fit:contain;filter:drop-shadow(0 6px 16px rgba(90,48,12,.45));animation:rpt-card-in .5s ease .05s both;}
5308 .rpt-spinner-wrap{position:relative;width:90px;height:90px;}
5309 .rpt-spinner-track{position:absolute;inset:0;border-radius:50%;border:5px solid rgba(196,92,16,.12);}
5310 .rpt-spinner{position:absolute;inset:0;border-radius:50%;background:conic-gradient(from 0deg,rgba(196,92,16,0) 0%,rgba(196,92,16,.18) 35%,#c45c10 100%);will-change:transform;animation:rpt-spin 1s linear infinite;-webkit-mask:radial-gradient(farthest-side,transparent calc(100% - 6px),#fff calc(100% - 5px));mask:radial-gradient(farthest-side,transparent calc(100% - 6px),#fff calc(100% - 5px));}
5311 @keyframes rpt-spin{to{transform:rotate(360deg);}}
5312 .rpt-spinner-pct{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:17px;font-weight:800;color:#c45c10;font-variant-numeric:tabular-nums;}
5313 body.dark-theme .rpt-spinner-track{border-color:rgba(196,92,16,.2);}
5314 body.dark-theme .rpt-spinner-pct{color:#e8932f;}
5315 .rpt-load-divider{width:54px;height:1px;background:linear-gradient(90deg,transparent,rgba(196,92,16,.22),transparent);}
5316 .rpt-loading-text{font-size:15px;font-weight:600;letter-spacing:.08em;display:flex;align-items:baseline;gap:2px;}
5317 .rpt-load-word{background:linear-gradient(90deg,#9a7a64 0%,#c45c10 45%,#e08a3a 55%,#9a7a64 100%);background-size:220% auto;-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;color:transparent;animation:rpt-text-shimmer 3.2s linear infinite;}
5318 @keyframes rpt-text-shimmer{to{background-position:-220% center;}}
5319 .rpt-dot{display:inline-block;color:#c45c10;-webkit-text-fill-color:#c45c10;animation:rpt-bounce 1.7s ease-in-out infinite;opacity:0;}
5320 .rpt-dot:nth-child(2){animation-delay:.28s;}
5321 .rpt-dot:nth-child(3){animation-delay:.56s;}
5322 @keyframes rpt-bounce{0%,60%,100%{opacity:0;transform:translateY(0);}30%{opacity:1;transform:translateY(-5px);}}
5323 .rpt-status{font-size:12.5px;font-weight:600;letter-spacing:.02em;color:var(--muted,#8a7060);min-height:16px;text-align:center;}
5324 .rpt-status-in{animation:rpt-status-pop .38s ease both;}
5325 @keyframes rpt-status-pop{from{opacity:0;transform:translateY(4px);}to{opacity:1;transform:none;}}
5326 .rpt-progress{width:100%;height:6px;border-radius:99px;background:rgba(196,92,16,.12);overflow:hidden;}
5327 .rpt-progress-bar{height:100%;width:100%;transform:scaleX(0);transform-origin:left center;border-radius:99px;background:linear-gradient(90deg,#e8932f,#c45c10);transition:transform .25s cubic-bezier(.4,0,.2,1);will-change:transform;}
5328 body.dark-theme .rpt-progress{background:rgba(196,92,16,.2);}
5329 .rpt-feed{width:100%;min-height:66px;display:flex;flex-direction:column;justify-content:flex-end;gap:3px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;line-height:1.5;color:rgba(138,112,96,.72);text-align:left;overflow:hidden;}
5330 .rpt-feed-line{display:flex;align-items:center;gap:6px;opacity:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;animation:rpt-feed-in .4s ease forwards;}
5331 .rpt-feed-line::before{content:'>';color:#c45c10;font-weight:700;}
5332 @keyframes rpt-feed-in{from{opacity:0;transform:translateX(-6px);}to{opacity:.82;transform:none;}}
5333 body.dark-theme .rpt-feed{color:rgba(204,172,150,.62);}
5334 body.dark-theme .rpt-load-divider{background:linear-gradient(90deg,transparent,rgba(196,92,16,.28),transparent);}
5335 @media (prefers-reduced-motion:reduce){ #rpt-loading-overlay .rpt-bg-blob,#rpt-loading-overlay .rpt-spinner,#rpt-loading-overlay .rpt-load-word,#rpt-loading-overlay .rpt-dot{animation:none!important;}}
5336 /* ── Code Style Analysis section ── */
5337 .style-guide-grid{display:grid;gap:10px;}
5338 .style-guide-row{display:grid;grid-template-columns:140px 1fr 52px;align-items:center;gap:10px;padding:6px 8px;border-radius:8px;cursor:default;position:relative;transition:transform .18s ease,box-shadow .18s ease,background .18s ease;}
5339 .style-guide-row:hover{transform:translateY(-2px);box-shadow:0 6px 22px rgba(77,44,20,0.18);background:var(--surface-2);}
5340 .style-guide-label{font-size:12px;font-weight:800;color:var(--text);text-align:right;white-space:nowrap;}
5341 .style-guide-track{background:var(--surface-3);border-radius:6px;height:20px;overflow:hidden;position:relative;box-shadow:inset 0 1px 3px rgba(0,0,0,.08);}
5342 .style-guide-fill{height:100%;border-radius:6px;background:linear-gradient(90deg,var(--oxide),var(--oxide-2));transition:width .65s cubic-bezier(.25,.46,.45,.94),filter .18s ease;position:relative;}
5343 .style-guide-fill::after{content:'';position:absolute;inset:0;background:linear-gradient(90deg,rgba(255,255,255,.18) 0%,rgba(255,255,255,.04) 100%);border-radius:6px;}
5344 .style-guide-row:hover .style-guide-fill{filter:brightness(1.12);}
5345 .style-guide-score{font-size:12px;font-weight:800;color:var(--oxide);text-align:right;white-space:nowrap;}
5346 .style-guide-desc{font-size:10px;color:var(--muted);margin-top:2px;grid-column:2/3;}
5347 .style-bar-tip{position:absolute;bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);background:var(--text);color:var(--bg);padding:7px 14px;border-radius:8px;font-size:11px;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1);z-index:300;box-shadow:0 4px 18px rgba(0,0,0,.24);}
5348 .style-bar-tip::after{content:'';position:absolute;top:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-top-color:var(--text);}
5349 .style-guide-row:hover .style-bar-tip{opacity:1;}
5350 .style-metrics-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:18px 0 0;}
5351 @media(max-width:800px){.style-metrics-strip{grid-template-columns:repeat(2,1fr);}}
5352 .style-chip{background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:12px 14px;text-align:center;cursor:default;position:relative;transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1);}
5353 .style-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);}
5354 .style-chip-val{font-size:18px;font-weight:900;color:var(--oxide);}
5355 .style-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:3px;}
5356 .style-chip-tip{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1);z-index:200;}
5357 .style-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
5358 .style-chip:hover .style-chip-tip{opacity:1;}
5359 .style-file-table{width:100%;border-collapse:collapse;font-size:12px;table-layout:fixed;}
5360 .style-file-table th{background:var(--surface-3);padding:7px 10px;font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);text-align:left;border-bottom:2px solid var(--line);cursor:pointer;user-select:none;white-space:nowrap;position:relative;}
5361 .style-file-table th:hover{background:var(--surface-2);color:var(--text);}
5362 .style-sort-ind{display:inline-block;margin-left:4px;font-size:9px;opacity:.4;vertical-align:middle;}
5363 .style-file-table th.sft-sort-asc .style-sort-ind,.style-file-table th.sft-sort-desc .style-sort-ind{opacity:1;color:var(--oxide);}
5364 .style-file-table td{padding:6px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
5365 .style-file-table tr:hover td{background:var(--surface-2);}
5366 .style-score-bar{display:inline-block;width:48px;height:8px;border-radius:4px;background:var(--surface-3);vertical-align:middle;position:relative;margin-right:4px;}
5367 .style-score-fill{position:absolute;left:0;top:0;height:100%;border-radius:4px;background:linear-gradient(90deg,var(--oxide),var(--oxide-2));}
5368 .style-badge{display:inline-block;padding:2px 7px;border-radius:12px;font-size:10px;font-weight:700;background:var(--surface-3);color:var(--oxide);border:1px solid var(--line);text-decoration:none;transition:background .15s,transform .15s,box-shadow .15s;}
5369 a.style-badge:hover{background:var(--oxide);color:#fff !important;transform:translateY(-1px);box-shadow:0 3px 10px rgba(77,44,20,.24);}
5370 .style-heuristic-note{display:flex;align-items:flex-start;gap:10px;background:var(--info-bg);color:var(--info-text);border-radius:10px;padding:11px 14px;font-size:13px;line-height:1.5;margin-top:14px;border:1px solid rgba(68,103,216,0.18);}
5371 .style-lang-tabs{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px;}
5372 .style-lang-tab{padding:4px 12px;border-radius:14px;border:1px solid var(--line);background:var(--surface);font-size:11px;font-weight:700;cursor:pointer;color:var(--text);transition:background .15s;}
5373 .style-lang-tab:hover{background:var(--surface-2);}
5374 .style-lang-tab.active{background:var(--oxide);color:#fff;border-color:var(--oxide);}
5375 .style-sig-chip{display:inline-block;padding:1px 6px;border-radius:8px;font-size:10px;background:var(--surface-2);color:var(--muted);border:1px solid var(--line);margin-right:3px;cursor:default;}
5376 .style-row-warn td{background:rgba(178,48,48,0.06)!important;}
5377 .style-row-warn td:first-child{border-left:3px solid #b23030;}
5378 .style-sig-more{display:inline-block;padding:1px 6px;border-radius:8px;font-size:10px;background:transparent;color:var(--oxide);border:1px solid var(--oxide);margin-right:3px;font-weight:700;cursor:pointer;transition:background .15s,color .15s;}
5379 .style-sig-more:hover{background:var(--oxide);color:#fff;}
5380 .style-sig-info-btn{background:none;border:none;cursor:pointer;font-size:13px;color:var(--muted);padding:0 2px;line-height:1;vertical-align:middle;transition:color .15s;margin-left:4px;}
5381 .style-sig-info-btn:hover{color:var(--oxide);}
5382 .style-sig-pop{position:fixed;background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:10px 14px;box-shadow:0 8px 24px rgba(0,0,0,.18);z-index:9999;min-width:200px;max-width:300px;font-size:12px;line-height:1.6;}
5383 .style-sig-pop-title{font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px;}
5384 .style-sig-pop-row{display:flex;gap:8px;padding:4px 0;border-bottom:1px solid var(--line);}
5385 .style-sig-pop-row:last-child{border-bottom:none;}
5386 .style-sig-pop-key{color:var(--muted);font-weight:700;white-space:nowrap;flex-shrink:0;}
5387 .style-sig-pop-val{color:var(--text);}
5388 .style-sig-info-overlay{position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:10000;display:flex;align-items:center;justify-content:center;}
5389 .style-sig-info-modal{background:var(--bg);border-radius:14px;padding:22px 26px;max-width:500px;width:92%;box-shadow:0 12px 40px rgba(0,0,0,.2);position:relative;max-height:80vh;overflow-y:auto;}
5390 .style-sig-info-close{position:absolute;top:12px;right:16px;background:none;border:none;cursor:pointer;font-size:20px;color:var(--muted);line-height:1;}
5391 .style-sig-info-close:hover{color:var(--text);}
5392 .style-sig-info-grid{display:grid;grid-template-columns:max-content 1fr;gap:6px 14px;margin-top:14px;font-size:13px;}
5393 .style-sig-info-name{color:var(--oxide);font-weight:700;padding:2px 0;}
5394 .style-sig-info-desc{color:var(--text);padding:2px 0;}
5395 body.dark-theme .style-guide-track{background:var(--surface-3);}
5396 body.dark-theme .style-chip{background:var(--surface-2);}
5397 body.dark-theme .style-file-table th{background:var(--surface-3);}
5398 body.dark-theme .style-heuristic-note{border-color:rgba(100,130,255,0.22);}
5399 body.dark-theme .style-lang-tab{background:var(--surface-2);color:var(--text);}
5400 body.dark-theme .style-lang-tab.active{background:var(--oxide);color:#fff;}
5401 body.dark-theme .style-sig-chip{background:var(--surface-3);color:var(--muted);}
5402 body.dark-theme .style-sig-pop{background:var(--surface);box-shadow:0 8px 24px rgba(0,0,0,.4);}
5403 body.dark-theme .style-sig-info-modal{background:var(--surface);}
5404 .sig-tip{position:fixed;background:rgba(28,18,8,0.93);color:#f0ebe4;padding:9px 13px 15px 13px;border-radius:9px;font-size:12px;line-height:1.65;pointer-events:none;z-index:9998;opacity:0;transition:opacity .1s;box-shadow:0 4px 16px rgba(0,0,0,.35);display:none;min-width:160px;max-width:300px;}
5405 .sig-tip.visible{opacity:1;}
5406 .sig-tip::after{content:'';position:absolute;top:100%;left:var(--sig-tip-ax,50%);transform:translateX(-50%);border:7px solid transparent;border-top-color:rgba(28,18,8,0.93);}
5407 .sig-tip-hd{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:rgba(240,235,228,.5);margin-bottom:5px;}
5408 .sig-tip-row{display:flex;gap:8px;align-items:baseline;}
5409 .sig-tip-k{color:#e07b3a;font-weight:700;white-space:nowrap;flex-shrink:0;}
5410 .sig-tip-v{color:#f0ebe4;}
5411</style>
5412<script nonce="{{ nonce }}">{{ chart_js|safe }}</script>
5413</head>
5414<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
5415 <div id="rpt-loading-overlay" aria-live="polite" aria-label="Loading report">
5416 <div class="rpt-bg-blob rpt-blob-a" aria-hidden="true"></div>
5417 <div class="rpt-bg-blob rpt-blob-b" aria-hidden="true"></div>
5418 <div class="rpt-bg-blob rpt-blob-c" aria-hidden="true"></div>
5419 <div class="rpt-load-card">
5420 <img src="{{ small_logo_uri }}" alt="oxide-sloc" class="rpt-load-logo" />
5421 <div class="rpt-spinner-wrap">
5422 <div class="rpt-spinner-track"></div>
5423 <div class="rpt-spinner"></div>
5424 <div class="rpt-spinner-pct" id="rpt-pct">0%</div>
5425 </div>
5426 <div class="rpt-load-divider"></div>
5427 <div class="rpt-loading-text"><span class="rpt-load-word">Loading report</span><span class="rpt-dot">.</span><span class="rpt-dot">.</span><span class="rpt-dot">.</span></div>
5428 <div class="rpt-status" id="rpt-status">Initializing analysis engine</div>
5429 <div class="rpt-progress"><div class="rpt-progress-bar" id="rpt-progress-bar"></div></div>
5430 <div class="rpt-feed" id="rpt-feed" aria-hidden="true"></div>
5431 </div>
5432 </div>
5433 <script nonce="{{ nonce }}">
5434 (function(){
5435 var ov=document.getElementById('rpt-loading-overlay');if(!ov)return;
5436 var statusEl=document.getElementById('rpt-status'),bar=document.getElementById('rpt-progress-bar'),pct=document.getElementById('rpt-pct'),feed=document.getElementById('rpt-feed');
5437 var msgs=['Initializing analysis engine','Discovering source files','Detecting languages','Tokenizing and counting lines','Classifying comments and docstrings','Computing complexity metrics','Aggregating per-language totals','Estimating COCOMO effort','Rendering charts','Finalizing report'];
5438 var logs=['scan: walking directory tree','lexer: state machine warm','metrics: SLOC and ULOC ready','cocomo: effort model loaded','charts: canvas contexts bound','render: assembling sections','dedup: hashing file contents','git: reading activity window'];
5439 var mi=0,li=0,prog=0,ready=false,start=Date.now(),MIN=1700;
5440 function setProg(p){prog=p;if(bar)bar.style.transform='scaleX('+(p/100).toFixed(3)+')';if(pct)pct.textContent=Math.round(p)+'%';}
5441 function nextMsg(){if(statusEl){statusEl.classList.remove('rpt-status-in');void statusEl.offsetWidth;statusEl.textContent=msgs[mi%msgs.length];statusEl.classList.add('rpt-status-in');}mi++;}
5442 function addLog(){if(!feed)return;var l=document.createElement('div');l.className='rpt-feed-line';l.textContent=logs[li%logs.length];feed.appendChild(l);li++;while(feed.childNodes.length>4)feed.removeChild(feed.firstChild);}
5443 nextMsg();addLog();setProg(6);
5444 var msgTimer=setInterval(nextMsg,900),logTimer=setInterval(addLog,640),progTimer=setInterval(function(){var cap=ready?100:90;if(prog<cap){var step=(cap-prog)*0.08+0.6;setProg(Math.min(cap,prog+step));}},80);
5445 function done(){clearInterval(msgTimer);clearInterval(logTimer);clearInterval(progTimer);setProg(100);if(statusEl){statusEl.textContent='Done';statusEl.classList.add('rpt-status-in');}setTimeout(function(){ov.classList.add('fade-out');setTimeout(function(){if(ov.parentNode)ov.parentNode.removeChild(ov);},600);},260);}
5446 window.__rptFinish=function(){ready=true;setTimeout(done,Math.max(0,MIN-(Date.now()-start)));};
5447 })();
5448 </script>
5449 <div class="background-watermarks" aria-hidden="true">
5450 <img src="{{ logo_text_uri }}" alt="" />
5451 <img src="{{ logo_text_uri }}" alt="" />
5452 <img src="{{ logo_text_uri }}" alt="" />
5453 <img src="{{ logo_text_uri }}" alt="" />
5454 <img src="{{ logo_text_uri }}" alt="" />
5455 <img src="{{ logo_text_uri }}" alt="" />
5456 <img src="{{ logo_text_uri }}" alt="" />
5457 <img src="{{ logo_text_uri }}" alt="" />
5458 <img src="{{ logo_text_uri }}" alt="" />
5459 <img src="{{ logo_text_uri }}" alt="" />
5460 <img src="{{ logo_text_uri }}" alt="" />
5461 <img src="{{ logo_text_uri }}" alt="" />
5462 </div>
5463 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
5464 {% if let Some(banner) = report_header_footer %}
5465 <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
5466 {% endif %}
5467 <div class="top-nav">
5468 <div class="top-nav-inner">
5469 <a class="brand" href="/" data-local-brand="1">
5470 {% if let Some(uri) = custom_logo_uri %}
5471 <img class="brand-logo" src="{{ uri }}" alt="logo" />
5472 {% else %}
5473 <img class="brand-logo" src="{{ small_logo_uri }}" alt="OxideSLOC logo" />
5474 {% endif %}
5475 <div class="brand-copy">
5476 {% if let Some(name) = company_name %}
5477 <div class="brand-title">{{ name }}</div>
5478 {% else %}
5479 <div class="brand-title">OxideSLOC</div>
5480 {% endif %}
5481 <div class="brand-subtitle">Saved HTML report</div>
5482 </div>
5483 </a>
5484 <div class="nav-project-slot">
5485 <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ title }}</span></div>
5486 </div>
5487 <div class="nav-status">
5488 <button type="button" class="header-button" data-copy-link>Copy link</button>
5489 <button type="button" class="header-button" data-share-report>Share</button>
5490 <div class="nav-dropdown-wrap">
5491 <button type="button" class="header-button nav-dropdown-trigger" aria-haspopup="true">Export ▾</button>
5492 <div class="nav-dropdown-menu">
5493 <button type="button" class="nav-dropdown-item" data-export-csv>Export CSV</button>
5494 <button type="button" class="nav-dropdown-item" data-export-xls>Export Excel</button>
5495 </div>
5496 </div>
5497 <a id="nav-view-pdf-btn" href="/runs/pdf/{{ run.tool.run_id }}" target="_blank" rel="noopener" class="header-button" style="text-decoration:none;"{% if let Some(purl) = standalone_pdf_url %} data-standalone-pdf="{{ purl }}"{% endif %}>View PDF</a>
5498 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
5499 <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
5500 </button>
5501 <button type="button" class="theme-toggle" data-theme-toggle aria-label="Toggle theme" title="Toggle theme">
5502 <svg class="icon-moon" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 15.5A8.5 8.5 0 1 1 12.5 4 6.7 6.7 0 0 0 20 15.5Z"></path></svg>
5503 <svg class="icon-sun" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.2"></circle><path d="M12 2.5v2.2M12 19.3v2.2M21.5 12h-2.2M4.7 12H2.5M18.9 5.1l-1.6 1.6M6.7 17.3l-1.6 1.6M18.9 18.9l-1.6-1.6M6.7 6.7 5.1 5.1"></path></svg>
5504 </button>
5505 </div>
5506 </div>
5507 </div>
5508
5509 <div class="page">
5510 <section class="hero panel">
5511 <div class="hero-top">
5512 <div>
5513 <div class="section-kicker">Saved report artifact</div>
5514 <div style="display:flex;align-items:baseline;gap:18px;flex-wrap:wrap;">
5515 <h1>{{ title }}</h1>
5516 <span class="run-id-short-badge" title="Short run ID \u2014 matches the ID shown in View Reports">{{ run_id_short }}</span>
5517 </div>
5518 </div>
5519 </div>
5520 <div class="run-id-row">
5521 <span class="run-id-chip" data-copy="{{ run.tool.run_id }}">
5522 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/></svg>Run ID</span>
5523 <span class="run-id-chip-value">{{ run.tool.run_id }}</span>
5524 <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
5525 </span>
5526 {% if let Some(long_commit) = run.git_commit_long %}
5527 {% if let Some(commit_url) = git_commit_url %}
5528 <a class="run-id-chip run-id-chip-link" href="{{ commit_url }}" target="_blank" rel="noopener noreferrer" title="Open commit in source control">
5529 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><line x1="1" y1="12" x2="7" y2="12"/><line x1="17" y1="12" x2="23" y2="12"/></svg>Git Commit<svg class="chip-popout-icon" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></span>
5530 <span class="run-id-chip-value">{{ long_commit }}</span>
5531 <span class="chip-tooltip">Opens commit in source control — new tab</span>
5532 </a>
5533 {% else %}
5534 <span class="run-id-chip" data-copy="{{ long_commit }}">
5535 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><line x1="1" y1="12" x2="7" y2="12"/><line x1="17" y1="12" x2="23" y2="12"/></svg>Git Commit</span>
5536 <span class="run-id-chip-value">{{ long_commit }}</span>
5537 <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
5538 </span>
5539 {% endif %}
5540 {% else %}
5541 <span class="run-id-chip muted-chip">
5542 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><line x1="1" y1="12" x2="7" y2="12"/><line x1="17" y1="12" x2="23" y2="12"/></svg>Git Commit</span>
5543 <span class="run-id-chip-value">Not detected</span>
5544 <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
5545 </span>
5546 {% endif %}
5547 {% if let Some(branch) = run.git_branch %}
5548 {% if let Some(branch_url) = git_branch_url %}
5549 <a class="run-id-chip run-id-chip-link" href="{{ branch_url }}" target="_blank" rel="noopener noreferrer" title="Open branch in source control">
5550 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>Branch<svg class="chip-popout-icon" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></span>
5551 <span class="run-id-chip-value">{{ branch }}</span>
5552 <span class="chip-tooltip">Opens branch in source control — new tab</span>
5553 </a>
5554 {% else %}
5555 <span class="run-id-chip">
5556 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>Branch</span>
5557 <span class="run-id-chip-value">{{ branch }}</span>
5558 <span class="chip-tooltip">Git branch scanned for this report</span>
5559 </span>
5560 {% endif %}
5561 {% else %}
5562 {% if is_sub_report %}
5563 <span class="run-id-chip">
5564 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>Submodule</span>
5565 <span class="run-id-chip-value"><span class="submodule-state-badge">detached HEAD</span></span>
5566 <span class="chip-tooltip">Submodules are pinned to a specific commit — no branch ref</span>
5567 </span>
5568 {% else %}
5569 <span class="run-id-chip muted-chip">
5570 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>Branch</span>
5571 <span class="run-id-chip-value">Not detected</span>
5572 <span class="chip-tooltip">No Git branch was found for this scan</span>
5573 </span>
5574 {% endif %}
5575 {% endif %}
5576 {% if let Some(author) = run.git_commit_author %}
5577 <span class="run-id-chip" data-author="{{ author }}">
5578 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>Last Commit By</span>
5579 <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
5580 <span class="chip-tooltip">Author of the most recent commit in this repository</span>
5581 </span>
5582 {% else %}
5583 <span class="run-id-chip muted-chip">
5584 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>Last Commit By</span>
5585 <span class="run-id-chip-value">Not detected</span>
5586 <span class="chip-tooltip">No commit author was found for this scan</span>
5587 </span>
5588 {% endif %}
5589 </div>
5590
5591 <div class="meta">
5592 <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
5593 <span class="meta-chip">Scanned <b>{{ scan_time_pst }}</b></span>
5594 <span class="meta-chip">OS <b>{{ run.environment.operating_system }} / {{ run.environment.architecture }}</b></span>
5595 <span class="meta-chip">Files analyzed <b>{{ run.summary_totals.files_analyzed }}</b></span>
5596 <span class="meta-chip">Files skipped <b>{{ run.summary_totals.files_skipped }}</b></span>
5597 </div>
5598
5599 {% if has_delta %}
5600 <div class="prev-scan-banner" aria-label="Changes vs. previous scan">
5601 <div class="prev-scan-banner-top">
5602 <div class="prev-scan-meta">
5603 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
5604 {% if prev_run_id != "" %}<a href="/runs/html/{{ prev_run_id }}" target="_blank" rel="noopener" style="color:inherit;text-decoration:none;font-weight:700;">PREVIOUS SCAN</a>{% else %}<strong>PREVIOUS SCAN</strong>{% endif %}
5605 <span class="prev-scan-ts">{{ prev_scan_label }}</span>
5606 {% if prev_scan_count > 0 %}
5607 <span class="prev-scan-count">· {{ prev_scan_count }} scan{% if prev_scan_count != 1 %}s{% endif %} total</span>
5608 {% endif %}
5609 </div>
5610 <div class="prev-scan-summary">
5611 Code before: <b data-raw="{{ prev_code_lines }}">{{ prev_code_lines }}</b>
5612 →
5613 Code now: <b data-raw="{{ run.summary_totals.code_lines }}">{{ run.summary_totals.code_lines }}</b>
5614 ·
5615 <span class="{% if delta_code_added > 0 %}delta-up{% else %}delta-neutral-text{% endif %}">+<span data-raw="{{ delta_code_added }}">{{ delta_code_added }}</span> added</span>
5616
5617 <span class="{% if delta_code_removed > 0 %}delta-down{% else %}delta-neutral-text{% endif %}">−<span data-raw="{{ delta_code_removed }}">{{ delta_code_removed }}</span> removed</span>
5618 </div>
5619 </div>
5620 <div class="delta-card-row">
5621 <div class="delta-card-inline {% if delta_code_added > 0 %}pos{% endif %}">
5622 <div class="delta-card-val pos">+{{ delta_code_added|commas }}</div>
5623 <div class="delta-card-lbl">Lines added</div>
5624 <div class="delta-card-tip">Code lines added since {{ prev_scan_label }}</div>
5625 </div>
5626 <div class="delta-card-inline {% if delta_code_removed > 0 %}neg{% endif %}">
5627 <div class="delta-card-val neg">−{{ delta_code_removed|commas }}</div>
5628 <div class="delta-card-lbl">Lines removed</div>
5629 <div class="delta-card-tip">Code lines removed since {{ prev_scan_label }}</div>
5630 </div>
5631 <div class="delta-card-inline">
5632 <div class="delta-card-val">{{ delta_unmodified_lines|commas }}</div>
5633 <div class="delta-card-lbl">Unmodified lines</div>
5634 <div class="delta-card-tip">Code lines unchanged since {{ prev_scan_label }}</div>
5635 </div>
5636 <div class="delta-card-inline {% if delta_files_modified > 0 %}mod{% endif %}">
5637 <div class="delta-card-val mod">{{ delta_files_modified|commas }}</div>
5638 <div class="delta-card-lbl">Files modified</div>
5639 <div class="delta-card-tip">Files with at least one line changed</div>
5640 </div>
5641 <div class="delta-card-inline {% if delta_files_added > 0 %}pos{% endif %}">
5642 <div class="delta-card-val pos">{{ delta_files_added|commas }}</div>
5643 <div class="delta-card-lbl">Files added</div>
5644 <div class="delta-card-tip">New files added since {{ prev_scan_label }}</div>
5645 </div>
5646 <div class="delta-card-inline {% if delta_files_removed > 0 %}neg{% endif %}">
5647 <div class="delta-card-val neg">{{ delta_files_removed|commas }}</div>
5648 <div class="delta-card-lbl">Files removed</div>
5649 <div class="delta-card-tip">Files deleted since {{ prev_scan_label }}</div>
5650 </div>
5651 <div class="delta-card-inline">
5652 <div class="delta-card-val">{{ delta_files_unchanged|commas }}</div>
5653 <div class="delta-card-lbl">Files unchanged</div>
5654 <div class="delta-card-tip">Files with no changes since {{ prev_scan_label }}</div>
5655 </div>
5656 <div class="delta-card-inline">
5657 <div class="delta-card-val">{{ delta_files_total|commas }}</div>
5658 <div class="delta-card-lbl">Files total</div>
5659 <div class="delta-card-tip">Total files across both scans (modified + added + removed + unchanged)</div>
5660 </div>
5661 </div>
5662 </div>
5663 {% else %}
5664 <div class="prev-scan-banner prev-scan-banner-empty" aria-label="No previous scan">
5665 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
5666 No previous scan found for this project — this report is the baseline.
5667 </div>
5668 {% endif %}
5669
5670 <div class="summary-grid">
5671 <div class="metric" data-metric-value="{{ run.summary_totals.total_physical_lines }}"><div class="metric-tooltip">Total lines across all analyzed files, including code, comments, and blank lines.</div><div class="metric-label">Physical lines</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
5672 <div class="metric" data-metric-value="{{ run.summary_totals.code_lines }}"><div class="metric-tooltip">Lines containing executable source code, excluding comments and blanks.</div><div class="metric-label">Code</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
5673 <div class="metric" data-metric-value="{{ run.summary_totals.comment_lines }}"><div class="metric-tooltip">Lines consisting entirely of comments or inline documentation.</div><div class="metric-label">Comments</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
5674 <div class="metric" data-metric-value="{{ run.summary_totals.blank_lines }}"><div class="metric-tooltip">Empty or whitespace-only lines used for readability and spacing.</div><div class="metric-label">Blank</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
5675 <div class="metric" data-metric-value="{{ run.summary_totals.mixed_lines_separate }}"><div class="metric-tooltip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div><div class="metric-label">Mixed separate</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
5676 <div class="metric" data-metric-value="{{ run.summary_totals.functions }}"><div class="metric-tooltip">Best-effort count of function/method definitions detected across all source files.</div><div class="metric-label">Functions</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
5677 <div class="metric" data-metric-value="{{ run.summary_totals.classes }}"><div class="metric-tooltip">Best-effort count of class, struct, interface, and type definitions.</div><div class="metric-label">Classes / Types</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
5678 <div class="metric" data-metric-value="{{ run.summary_totals.variables }}"><div class="metric-tooltip">Best-effort count of variable and constant declarations.</div><div class="metric-label">Variables</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
5679 <div class="metric" data-metric-value="{{ run.summary_totals.imports }}"><div class="metric-tooltip">Best-effort count of import, include, and module-use statements.</div><div class="metric-label">Imports</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
5680 <div class="metric" data-metric-value="{{ run.summary_totals.test_count }}"><div class="metric-tooltip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div><div class="metric-label">Tests</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
5681 <div class="metric" data-metric-density><div class="metric-tooltip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div><div class="metric-label">Code density</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
5682 <div class="metric" data-metric-value="{{ run.summary_totals.files_analyzed }}"><div class="metric-tooltip">Total number of source files included in this analysis.</div><div class="metric-label">Files analyzed</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
5683 {% if run.summary_totals.cyclomatic_complexity > 0 %}<div class="metric" data-metric-value="{{ run.summary_totals.cyclomatic_complexity }}"><div class="metric-tooltip">Sum of branch decision keywords (if, for, while, ||, &&, …) across all code lines. Approximates total McCabe cyclomatic complexity.</div><div class="metric-label">Complexity score</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>{% endif %}
5684 {% if let Some(lsloc) = run.summary_totals.lsloc %}<div class="metric" data-metric-value="{{ lsloc }}"><div class="metric-tooltip">Logical SLOC: count of executable statements (semicolons for C-family; non-continuation lines for Python/Ruby/Shell). Normalises across coding styles.</div><div class="metric-label">Logical SLOC</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>{% endif %}
5685 {% if uloc > 0 %}<div class="metric" data-metric-value="{{ uloc }}"><div class="metric-tooltip">Unique Lines of Code: distinct non-blank code lines across all files. Counts each line once regardless of how many files it appears in.</div><div class="metric-label">Unique SLOC (ULOC)</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>{% endif %}
5686 {% if uloc > 0 && dryness_pct_str != "" %}<div class="metric"><div class="metric-tooltip">ULOC ÷ Code Lines — the fraction of code lines that are unique. Higher = less copy-paste across the codebase. 100% means every code line is distinct.</div><div class="metric-label">DRYness</div><div class="metric-value"><span class="metric-big">{{ dryness_pct_str }}%</span></div></div>{% endif %}
5687 {% if duplicate_group_count > 0 %}<div class="metric" data-metric-value="{{ duplicate_group_count }}"><div class="metric-tooltip">Groups of files with identical content detected. These may inflate SLOC totals. Re-run with --no-duplicates to exclude them.</div><div class="metric-label">Duplicate groups</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>{% endif %}
5688 <!-- Reserve "pad" card: revealed by JS only when the visible card count is
5689 odd, so the strip always has an even number of cards that fill exactly
5690 two aligned rows (no oversized card, no empty trailing cell). -->
5691 <div class="metric metric-pad" data-metric-value="{{ run.summary_totals.test_assertion_count }}" style="display:none"><div class="metric-tooltip">Best-effort count of test assertion call lines (assertEquals, EXPECT_*, etc.) detected across all test files.</div><div class="metric-label">Assertions</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
5692 </div>
5693 </section>
5694
5695 <!-- ── PDF-only pre-rendered chart variants (hidden on screen) ─────── -->
5696 <div id="pdf-variants" class="pdf-variants-root"></div>
5697
5698 <div class="report-stack">
5699 <!-- ── Chart row 1: Overview + Composition ───────────────────────── -->
5700 <div class="charts-grid">
5701 <section class="panel stack chart-section">
5702 <div>
5703 <div class="toolbar">
5704 <div class="toolbar-left"><h2>Project Overview</h2></div>
5705 <button class="chart-expand-btn" id="overview-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
5706 </div>
5707 <div class="chart-pre">
5708 <p style="margin:0 0 14px;color:var(--muted);font-size:13px;line-height:1.6;">A configurable cartesian view of your codebase. Choose what to show on each axis.</p>
5709 <div class="chart-controls">
5710 <label>Y Axis:
5711 <select class="chart-select" id="overview-y-axis">
5712 <option value="code">Code Lines</option>
5713 <option value="comments">Comment Lines</option>
5714 <option value="blanks">Blank Lines</option>
5715 <option value="physical">Total Physical Lines</option>
5716 <option value="files">File Count</option>
5717 </select>
5718 </label>
5719 <label>X Axis / Mode:
5720 <select class="chart-select" id="overview-x-mode">
5721 <option value="languages">Languages</option>
5722 {% if has_submodule_data %}<option value="submodules">Submodules</option>{% endif %}
5723 <option value="history-commits">Per Commit (Web UI)</option>
5724 <option value="history-tags">Per Tag (Web UI)</option>
5725 <option value="history-releases">Per Release (Web UI)</option>
5726 <option value="history-repos">Other Repos (Web UI)</option>
5727 </select>
5728 </label>
5729 </div>
5730 </div>
5731 <div id="overview-chart" class="chart-container"><div id="canvas-proj-wrap" style="position:relative;min-height:150px;"><canvas id="canvas-proj"></canvas></div></div>
5732 <div class="chart-locked-card" id="overview-chart-locked">
5733 <h3>Historical trend requires the web UI</h3>
5734 <p style="margin:0">Run <code>oxide-sloc serve</code> and navigate to <strong>/trend-reports</strong> to view per-commit, per-tag, per-release, and cross-repo comparisons on an interactive timeline chart. The web UI stores scan history and can plot any metric over time.</p>
5735 </div>
5736 </div>
5737 </section>
5738
5739 <section class="panel stack chart-section">
5740 <div>
5741 <div class="toolbar">
5742 <div class="toolbar-left"><h2>Language Composition</h2></div>
5743 <button class="chart-expand-btn" id="comp-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
5744 </div>
5745 <div class="chart-pre">
5746 <p style="margin:0 0 14px;color:var(--muted);font-size:13px;">Code, comments, and blank lines as a percentage of total physical lines per language.</p>
5747 <div class="chart-tab-bar">
5748 <button type="button" class="chart-tab active" data-comp-tab="absolute">Absolute Lines</button>
5749 <button type="button" class="chart-tab" data-comp-tab="pct">Composition %</button>
5750 </div>
5751 </div>
5752 <div id="composition-chart" class="chart-container" style="overflow:hidden;display:flex;align-items:center;justify-content:center;"><div id="comp-svg-container" style="width:100%;"></div></div>
5753 </div>
5754 </section>
5755 </div>
5756
5757 <!-- ── Chart row 2: Scatter + Semantic ───────────────────────────── -->
5758 <div class="charts-grid">
5759 <section class="panel stack chart-section">
5760 <div>
5761 <div class="toolbar">
5762 <div class="toolbar-left"><h2>Files vs Code Lines</h2></div>
5763 <button class="chart-expand-btn" id="scatter-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
5764 </div>
5765 <p style="margin:0 0 14px;color:var(--muted);font-size:13px;">Each bubble is a language. X = files analyzed, Y = code lines, bubble size ∝ total physical lines.</p>
5766 <div id="scatter-chart" class="chart-container" style="position:relative;height:224px;"><canvas id="canvas-scatter"></canvas></div>
5767 </div>
5768 </section>
5769
5770 <section class="panel stack chart-section">
5771 <div>
5772 <div class="toolbar">
5773 <div class="toolbar-left"><h2>Semantic Metrics</h2></div>
5774 {% if has_semantic_data %}
5775 <button class="chart-expand-btn" id="semantic-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
5776 {% endif %}
5777 </div>
5778 <p style="margin:0 0 14px;color:var(--muted);font-size:13px;">Detected structural elements per language. Select a metric to explore.</p>
5779 {% if has_semantic_data %}
5780 <div class="chart-controls">
5781 <label>Metric:
5782 <select class="chart-select" id="semantic-metric">
5783 <option value="functions">Functions</option>
5784 <option value="classes">Classes / Types</option>
5785 <option value="variables">Variables</option>
5786 <option value="imports">Imports</option>
5787 <option value="tests">Tests</option>
5788 </select>
5789 </label>
5790 </div>
5791 <div id="semantic-chart" class="chart-container" style="position:relative;height:234px;"><canvas id="canvas-semantic"></canvas></div>
5792 {% else %}
5793 <div style="display:flex;align-items:center;justify-content:center;height:200px;color:var(--muted);font-size:13px;text-align:center;line-height:1.6;">
5794 <div>No structural metrics detected for this scan.<br>Semantic analysis covers languages with function/class detection<br>(e.g., Go, Python, Rust, Java, C++).</div>
5795 </div>
5796 {% endif %}
5797 </div>
5798 </section>
5799 <section class="panel stack chart-section">
5800 <div>
5801 <div class="toolbar">
5802 <div class="toolbar-left"><h2>Comment Density</h2></div>
5803 <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
5804 </div>
5805 <p style="margin:0 0 14px;color:var(--muted);font-size:13px;">Comments as a percentage of significant lines (code + comments) per language — a proxy for documentation coverage.</p>
5806 <div id="density-chart" class="chart-container" style="position:relative;min-height:150px;"><canvas id="canvas-density"></canvas></div>
5807 </div>
5808 </section>
5809
5810 <section class="panel stack chart-section">
5811 <div>
5812 <div class="toolbar">
5813 <div class="toolbar-left"><h2>File Size Distribution</h2></div>
5814 <button class="chart-expand-btn" id="filesize-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
5815 </div>
5816 <p style="margin:0 0 14px;color:var(--muted);font-size:13px;">Number of files in each SLOC bucket — a quick view of whether the codebase favours small focused modules or large files.</p>
5817 <div id="filesize-chart" class="chart-container" style="position:relative;min-height:150px;"><canvas id="canvas-filesize"></canvas></div>
5818 </div>
5819 </section>
5820 </div>
5821
5822 <!-- ── Tests & Coverage ──────────────────────────────────────────── -->
5823 <section class="panel stack">
5824 <div>
5825 <div class="toolbar">
5826 <div class="toolbar-left"><h2>Tests & Coverage</h2></div>
5827 {% if has_coverage_data %}<div class="pill-row"><span class="pill good">LCOV coverage data present</span></div>{% endif %}
5828 </div>
5829 <div class="summary-strip">
5830 <div class="stat-chip">
5831 <div class="stat-chip-val" data-fmt="{{ run.summary_totals.test_count }}">{{ run.summary_totals.test_count|commas }}</div>
5832 <div class="stat-chip-label">Test Functions</div>
5833 <div class="stat-chip-tip">Lexically detected test case / function definitions (GTest, PyTest, JUnit, Unity, etc.)</div>
5834 <span class="stat-chip-exact">{{ run.summary_totals.test_count|commas }}</span>
5835 </div>
5836 <div class="stat-chip">
5837 <div class="stat-chip-val" data-fmt="{{ test_assertion_count }}">{{ test_assertion_count|commas }}</div>
5838 <div class="stat-chip-label">Assertions</div>
5839 <div class="stat-chip-tip">Test assertion call lines (ASSERT_EQ, EXPECT_TRUE, assertEquals, Assert.AreEqual, assert_eq!, etc.)</div>
5840 <span class="stat-chip-exact">{{ test_assertion_count|commas }}</span>
5841 </div>
5842 <div class="stat-chip">
5843 <div class="stat-chip-val" data-fmt="{{ test_suite_count }}">{{ test_suite_count|commas }}</div>
5844 <div class="stat-chip-label">Test Suites</div>
5845 <div class="stat-chip-tip">Test suite / fixture / group declarations (TEST_GROUP, BOOST_AUTO_TEST_SUITE, [TestClass], etc.)</div>
5846 <span class="stat-chip-exact">{{ test_suite_count|commas }}</span>
5847 </div>
5848 <div class="stat-chip">
5849 <div class="stat-chip-val">{{ test_files_count|commas }} / {{ run.summary_totals.files_analyzed|commas }}</div>
5850 <div class="stat-chip-label">Test Files</div>
5851 <div class="stat-chip-tip">Files containing at least one detected test definition out of total analyzed files</div>
5852 </div>
5853 </div>
5854 <div class="summary-strip" style="margin-top:0;">
5855 <div class="stat-chip">
5856 <div class="stat-chip-val">{{ test_density }}</div>
5857 <div class="stat-chip-label">Tests per 1K SLOC</div>
5858 <div class="stat-chip-tip">Workspace-wide test density: test functions ÷ code lines × 1000</div>
5859 </div>
5860 <div class="stat-chip">
5861 <div class="stat-chip-val" style="font-size:15px;word-break:break-word;line-height:1.2;">{{ most_tested_lang }}</div>
5862 <div class="stat-chip-label">Most Tested Language</div>
5863 <div class="stat-chip-tip">Language with the highest absolute test function count</div>
5864 </div>
5865 <div class="stat-chip">
5866 <div class="stat-chip-val">{{ langs_with_tests }}</div>
5867 <div class="stat-chip-label">Languages with Tests</div>
5868 <div class="stat-chip-tip">Number of distinct languages where test definitions were detected</div>
5869 </div>
5870 <div class="stat-chip">
5871 {% if has_coverage_data %}<div class="stat-chip-val">{{ cov_line_pct }}%</div>{% else %}<div class="stat-chip-val" style="color:var(--muted);">—</div>{% endif %}
5872 <div class="stat-chip-label">Line Coverage</div>
5873 <div class="stat-chip-tip">Overall line coverage from LCOV data — run with --lcov-path to populate</div>
5874 </div>
5875 </div>
5876 {% if has_coverage_data %}
5877 <div class="cov-gauge-row">
5878 <div class="cov-gauge-card">
5879 <div class="cov-gauge-label">Line Coverage</div>
5880 <div class="cov-gauge-val" style="color:var(--{{ cov_line_class }}-text);">{{ cov_line_pct }}%</div>
5881 <div class="cov-gauge-track"><div class="cov-gauge-fill" style="width:{{ cov_line_pct }}%;background:var(--{{ cov_line_class }}-text);"></div></div>
5882 <div class="cov-gauge-sub">Lines hit / instrumented</div>
5883 <div class="cov-gauge-tip">Share of instrumented source lines executed at least once during the test run (from LCOV <code>DA</code> records).</div>
5884 </div>
5885 {% if has_fn_coverage %}
5886 <div class="cov-gauge-card">
5887 <div class="cov-gauge-label">Function Coverage</div>
5888 <div class="cov-gauge-val" style="color:var(--{{ cov_fn_class }}-text);">{{ cov_fn_pct }}%</div>
5889 <div class="cov-gauge-track"><div class="cov-gauge-fill" style="width:{{ cov_fn_pct }}%;background:var(--{{ cov_fn_class }}-text);"></div></div>
5890 <div class="cov-gauge-sub">Functions hit / found</div>
5891 </div>
5892 {% endif %}
5893 {% if has_branch_coverage %}
5894 <div class="cov-gauge-card">
5895 <div class="cov-gauge-label">Branch Coverage</div>
5896 <div class="cov-gauge-val" style="color:var(--{{ cov_branch_class }}-text);">{{ cov_branch_pct }}%</div>
5897 <div class="cov-gauge-track"><div class="cov-gauge-fill" style="width:{{ cov_branch_pct }}%;background:var(--{{ cov_branch_class }}-text);"></div></div>
5898 <div class="cov-gauge-sub">Branches hit / found</div>
5899 </div>
5900 {% endif %}
5901 </div>
5902 {% endif %}
5903 <div class="table-shell" style="margin-top:16px;">
5904 <table data-sort-table style="min-width:560px;">
5905 <thead>
5906 <tr>
5907 <th data-sort-type="text">Language</th>
5908 <th data-sort-type="number">Test Fns</th>
5909 <th data-sort-type="number">Assertions</th>
5910 <th data-sort-type="number">Suites</th>
5911 <th data-sort-type="text">Density (per 1K SLOC)</th>
5912 </tr>
5913 </thead>
5914 <tbody>
5915 {% for row in language_rows %}
5916 {% if row.test_count > 0 || row.test_assertion_count > 0 %}
5917 <tr>
5918 <td>{{ row.language }}</td>
5919 <td>{{ row.test_count|commas }}</td>
5920 <td>{{ row.test_assertion_count|commas }}</td>
5921 <td>{{ row.test_suite_count|commas }}</td>
5922 <td>{{ row.test_density_str }}</td>
5923 </tr>
5924 {% endif %}
5925 {% endfor %}
5926 {% if run.summary_totals.test_count == 0 && test_assertion_count == 0 %}
5927 <tr class="empty-state-row"><td colspan="5">No test functions or assertions detected in this scan</td></tr>
5928 {% endif %}
5929 </tbody>
5930 </table>
5931 </div>
5932 {% if has_coverage_data %}
5933 <div style="display:flex;align-items:center;gap:10px;margin:16px 0 8px;">
5934 <h3 style="margin:0;font-size:14px;font-weight:800;color:var(--text);">Per-File Coverage</h3>
5935 <span class="pill good" style="font-size:10px;">{{ file_rows.len() }} files with data</span>
5936 </div>
5937 <div class="table-shell">
5938 <table data-sort-table class="cov-file-table" style="min-width:560px;">
5939 <thead>
5940 <tr>
5941 <th data-sort-type="text">File</th>
5942 <th class="num-col" data-sort-type="number">Line Cov %</th>
5943 <th class="num-col" data-sort-type="text">Lines Hit / Found</th>
5944 {% if has_fn_coverage %}<th class="num-col" data-sort-type="number">Fn Cov %</th>{% endif %}
5945 {% if has_branch_coverage %}<th class="num-col" data-sort-type="number">Branch Cov %</th>{% endif %}
5946 </tr>
5947 </thead>
5948 <tbody>
5949 {% for row in file_rows %}
5950 {% if !row.line_cov_pct.is_empty() %}
5951 <tr>
5952 <td class="mono" style="font-size:11px;max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
5953 <td class="num-col">{{ row.line_cov_pct }}%</td>
5954 <td class="num-col" style="font-size:11px;color:var(--muted);">{{ row.cov_lines_detail }}</td>
5955 {% if has_fn_coverage %}<td class="num-col">{% if !row.fn_cov_pct.is_empty() %}{{ row.fn_cov_pct }}%{% else %}—{% endif %}</td>{% endif %}
5956 {% if has_branch_coverage %}<td class="num-col">{% if !row.branch_cov_pct.is_empty() %}{{ row.branch_cov_pct }}%{% else %}—{% endif %}</td>{% endif %}
5957 </tr>
5958 {% endif %}
5959 {% endfor %}
5960 {% if file_rows.is_empty() %}
5961 <tr class="empty-state-row"><td colspan="5">No per-file coverage data available</td></tr>
5962 {% endif %}
5963 </tbody>
5964 </table>
5965 </div>
5966 {% else %}
5967 <div class="info-callout">
5968 <span class="info-callout-icon">ℹ️</span>
5969 <span>No code coverage detected. Re-run with <code>--lcov-path coverage.info</code> to see line, function, and branch coverage here.</span>
5970 </div>
5971 {% endif %}
5972 </div>
5973 </section>
5974
5975 <!-- ── Multi-Language Code Style Analysis ───────────────────────── -->
5976 {% if has_style_data %}
5977 {% if let Some(ss) = style_summary %}
5978 <section class="panel stack">
5979 <div>
5980 <div class="toolbar">
5981 <div class="toolbar-left"><h2>Code Style Analysis</h2></div>
5982 <div class="pill-row"><span class="pill info">{{ style_lang_count }} language group(s) · Lexical heuristics</span></div>
5983 </div>
5984 <div class="style-heuristic-note">
5985 <span class="info-callout-icon">ℹ️</span>
5986 <span>Scores are lexical approximations based on indentation, line length, brace placement, and language-specific signals — not a full parse. Use as a directional signal.</span>
5987 </div>
5988 <!-- Summary chips -->
5989 <div class="style-metrics-strip">
5990 <div class="style-chip">
5991 <div class="style-chip-val">{{ ss.files_analyzed }}</div>
5992 <div class="style-chip-label">Files Analyzed</div>
5993 <div class="style-chip-tip">Total files with style data</div>
5994 </div>
5995 <div class="style-chip">
5996 <div class="style-chip-val">{{ style_lang_count }}</div>
5997 <div class="style-chip-label">Language Groups</div>
5998 <div class="style-chip-tip">Distinct language families detected</div>
5999 </div>
6000 <div class="style-chip">
6001 <div class="style-chip-val">{{ ss.common_indent_style }}</div>
6002 <div class="style-chip-label">Common Indent</div>
6003 <div class="style-chip-tip">Most prevalent indentation across all files</div>
6004 </div>
6005 <div class="style-chip">
6006 <div class="style-chip-val">{{ ss.line_col_compliant_pct }}%</div>
6007 <div class="style-chip-label">{{ ss.col_threshold }}-Col Compliant</div>
6008 <div class="style-chip-tip">Files where ≤5% of lines exceed {{ ss.col_threshold }} chars</div>
6009 </div>
6010 </div>
6011 <!-- Language selector tab strip -->
6012 <div style="margin-top:20px;">
6013 <div style="font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:10px;">Style Guide Adherence by Language</div>
6014 <div id="style-lang-tabs" class="style-lang-tabs"></div>
6015 <div class="style-guide-grid" id="style-guide-bars"></div>
6016 </div>
6017 <!-- Per-file style table -->
6018 <div style="margin-top:22px;">
6019 <div class="toolbar" style="margin-bottom:8px;">
6020 <div class="toolbar-left">
6021 <span style="font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);">Per-File Style Details</span>
6022 <input id="sft-search" class="search" type="search" placeholder="Filter files, languages, guides..." style="margin-left:12px;" />
6023 <div class="page-size-row"><label class="page-size-label" for="sft-page-size">Show:</label><select id="sft-page-size" class="page-size-select"><option value="20" selected>20</option><option value="50">50</option><option value="100">100</option><option value="all">All</option></select><span id="sft-count-label" class="page-count-label"></span></div>
6024 </div>
6025 </div>
6026 <div class="table-scroll-wrap">
6027 <table class="style-file-table" id="style-file-table">
6028 <thead>
6029 <tr>
6030 <th data-sort-key="path" style="width:35%;" title="File path relative to the scanned root. Click to sort alphabetically.">File <span class="style-sort-ind">▾</span></th>
6031 <th data-sort-key="lang" style="width:10%;" title="Programming language detected for this file. Click to sort.">Language <span class="style-sort-ind">▾</span></th>
6032 <th data-sort-key="indent" style="width:12%;" title="Dominant indentation style detected: Tabs, 2-Space, 4-Space, 8-Space, Mixed, or Unknown. Click to sort.">Indent <span class="style-sort-ind">▾</span></th>
6033 <th data-sort-key="guide" style="width:20%;" title="Style guide with the highest lexical-adherence score for this file. Click a badge to open the official guide documentation. Click header to sort.">Best Match Guide <span class="style-sort-ind">▾</span></th>
6034 <th data-sort-key="score" style="width:10%;" title="Adherence score (0-100%) for the best-matching style guide. Higher = closer match to that guide's conventions. Lexical heuristic only \u2014 not a full parse. Click to sort.">Score <span class="style-sort-ind">▾</span></th>
6035 <th style="width:13%;" title="Hover a row to see all signals \u2014 signal name and detected value.">Signals</th>
6036 </tr>
6037 </thead>
6038 <tbody id="style-file-tbody">
6039 <tr><td colspan="6" style="text-align:center;color:var(--muted);padding:18px;">Loading...</td></tr>
6040 </tbody>
6041 </table>
6042 </div>
6043 <div id="sft-pagination" class="pagination-bar">
6044 <button id="sft-first" class="pager-btn pager-edge" disabled title="First page">⇤ First</button>
6045 <button id="sft-prev" class="pager-btn" disabled>← Prev</button>
6046 <span class="pager-jump-wrap">Page <input id="sft-page-jump" class="pager-jump" type="number" min="1" value="1" title="Jump to page"> of <span id="sft-page-total">—</span></span>
6047 <span id="sft-page-info" class="pager-info"></span>
6048 <button id="sft-next" class="pager-btn">Next →</button>
6049 <button id="sft-last" class="pager-btn pager-edge" title="Last page">Last ⇥</button>
6050 </div>
6051 </div>
6052 </div>
6053 </section>
6054 {% endif %}
6055 {% endif %}
6056
6057 <!-- ── Submodule Breakdown (2-column, conditional) ─────────────── -->
6058 {% if has_submodule_data %}
6059 <div class="charts-grid">
6060 <section class="panel stack chart-section">
6061 <div>
6062 <div class="toolbar">
6063 <div class="toolbar-left"><h2>Submodule Breakdown</h2></div>
6064 <button class="chart-expand-btn" id="sub-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
6065 </div>
6066 <div class="chart-controls">
6067 <label>Y Axis:
6068 <select class="chart-select" id="sub-y-axis">
6069 <option value="code">Code Lines</option>
6070 <option value="comment">Comment Lines</option>
6071 <option value="blank">Blank Lines</option>
6072 <option value="physical">Total Physical Lines</option>
6073 <option value="files">File Count</option>
6074 </select>
6075 </label>
6076 <label>Sort:
6077 <select class="chart-select" id="sub-sort">
6078 <option value="desc">Value ↓</option>
6079 <option value="asc">Value ↑</option>
6080 <option value="name">Name A→Z</option>
6081 </select>
6082 </label>
6083 </div>
6084 <div id="submodule-chart" class="chart-container"><div id="canvas-sub-wrap" style="position:relative;min-height:150px;"><canvas id="canvas-sub"></canvas></div></div>
6085 </div>
6086 </section>
6087 <section class="panel stack chart-section">
6088 <div>
6089 <div class="toolbar">
6090 <div class="toolbar-left"><h2>Submodule Composition</h2></div>
6091 <button class="chart-expand-btn" id="sub-comp-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
6092 </div>
6093 <p style="margin:0 0 14px;color:var(--muted);font-size:13px;">Code vs comments vs blank lines per submodule — bar width reflects relative size.</p>
6094 <div id="submodule-donut" style="width:100%;padding:4px 0;overflow:hidden;"></div>
6095 </div>
6096 </section>
6097 </div>
6098 {% endif %}
6099
6100 {% if has_cocomo %}
6101 <section class="panel" id="cocomo-section">
6102 <div class="toolbar">
6103 <div class="toolbar-left">
6104 <h2>Constructive Cost Model — COCOMO I</h2>
6105 <span class="cocomo-mode-pill-wrap" style="margin-left:12px;">
6106 <span class="pill" style="background:var(--surface-3);color:var(--muted);border:1px solid var(--line);font-size:11px;">{{ cocomo_mode_label }} mode</span>
6107 <span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
6108 </span>
6109 </div>
6110 </div>
6111 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
6112 <div class="stat-chip">
6113 <div class="stat-chip-label">Person-months</div>
6114 <div class="stat-chip-val">{{ cocomo_effort_str|commas }}</div>
6115 <div class="stat-chip-tip">Total estimated developer effort to build this codebase from scratch. One person-month = one developer working full-time for one calendar month. Computed as 2.4 × KSLOC^1.05 (Organic mode).</div>
6116 </div>
6117 <div class="stat-chip">
6118 <div class="stat-chip-label">Schedule (months)</div>
6119 <div class="stat-chip-val">{{ cocomo_duration_str|commas }}</div>
6120 <div class="stat-chip-tip">Estimated calendar duration assuming an optimally sized team. Computed as 2.5 × effort^0.38. Adding more people beyond this optimum rarely shortens the timeline.</div>
6121 </div>
6122 <div class="stat-chip">
6123 <div class="stat-chip-label">Avg. Team Size</div>
6124 <div class="stat-chip-val">{{ cocomo_staff_str|commas }}</div>
6125 <div class="stat-chip-tip">Average number of engineers working in parallel, derived as effort ÷ schedule. Actual headcount may peak higher during intensive phases of the project.</div>
6126 </div>
6127 <div class="stat-chip">
6128 <div class="stat-chip-label">Input KSLOC</div>
6129 <div class="stat-chip-val">{{ cocomo_ksloc_str|commas }}K</div>
6130 <div class="stat-chip-tip">KSLOC = Kilo Source Lines of Code (1 KSLOC = 1,000 lines). This is the primary input to the COCOMO model. Only executable code lines are counted — blank lines and comments are excluded. ({{ run.summary_totals.code_lines|commas }} total code lines)</div>
6131 </div>
6132 </div>
6133 <p style="font-size:13px;color:var(--muted);padding:8px 4px 0;line-height:1.6;white-space:nowrap;">COCOMO I (Constructive Cost Model) is a 1981 algorithmic model by Barry Boehm that converts SLOC into effort, schedule, and team-size estimates.<br>These are ballpark figures — actual outcomes vary widely by team experience, toolchain maturity, and domain complexity.</p>
6134 </section>
6135 {% endif %}
6136
6137 {% if has_hotspots %}
6138 <section class="panel stack" id="hotspots-section">
6139 <div class="toolbar"><div class="toolbar-left"><h2>Git Hotspots</h2><input id="hotspots-search" class="search" type="search" placeholder="Filter files..." /><div class="page-size-row"><label class="page-size-label" for="hotspots-page-size">Show:</label><select id="hotspots-page-size" class="page-size-select"><option value="15" selected>15</option><option value="25">25</option><option value="50">50</option><option value="all">All</option></select><span id="hotspots-count-label" class="page-count-label"></span></div></div></div>
6140 <p style="font-size:13px;color:var(--muted);padding:4px 4px 10px;line-height:1.6;">Files ranked by <strong>code lines × recent commits</strong> over the configured git activity window. Large files that change often are the strongest refactoring candidates. <span class="hs-hint">Click a column header to sort; drag its right edge to resize; hover a header for what it means.</span></p>
6141 <div class="table-shell">
6142 <table id="hotspots-table" data-sort-table class="table-resizable hotspots-table">
6143 <colgroup><col><col><col><col><col></colgroup>
6144 <thead><tr>
6145 <th data-sort-type="text">File<span class="col-tip">Repository-relative path of the file. Click to sort the list alphabetically by path.</span><div class="col-resize-handle"></div></th>
6146 <th data-sort-type="number" class="num-col">Code lines<span class="col-tip col-tip-r">Executable source lines in the file (blank lines and comments excluded). Bigger files are harder to change safely.</span><div class="col-resize-handle"></div></th>
6147 <th data-sort-type="number" class="num-col">Commits<span class="col-tip col-tip-r">How many times the file was committed within the git activity window. More commits = more churn.</span><div class="col-resize-handle"></div></th>
6148 <th data-sort-type="number" class="num-col">Hotspot score<span class="col-tip col-tip-r"><strong>Code lines × Commits.</strong> A large file that changes often scores high — it concentrates both size and churn, making it the strongest refactoring candidate. Lower is calmer.</span><div class="col-resize-handle"></div></th>
6149 <th data-sort-type="text" class="num-col">Last changed<span class="col-tip col-tip-r">Date of the most recent commit that touched this file, within the activity window.</span><div class="col-resize-handle"></div></th>
6150 </tr></thead>
6151 <tbody>
6152 {% for h in hotspot_rows %}
6153 <tr>
6154 <td class="mono" title="{{ h.path }}">{{ h.path }}</td>
6155 <td class="num-col">{{ h.code_lines|commas }}</td>
6156 <td class="num-col">{{ h.commit_count }}</td>
6157 <td class="num-col" style="font-weight:700;color:var(--oxide);">{{ h.score|commas }}</td>
6158 <td class="num-col" style="color:var(--muted);">{{ h.last_commit_date }}</td>
6159 </tr>
6160 {% endfor %}
6161 </tbody>
6162 </table>
6163 </div>
6164 <div id="hotspots-pagination" class="pagination-bar">
6165 <button id="hs-first" class="pager-btn pager-edge" disabled title="First page">⇤ First</button>
6166 <button id="hs-prev" class="pager-btn" disabled>← Prev</button>
6167 <span class="pager-jump-wrap">Page <input id="hs-page-jump" class="pager-jump" type="number" min="1" value="1" title="Jump to page"> of <span id="hs-page-total">—</span></span>
6168 <span id="hs-page-info" class="pager-info"></span>
6169 <button id="hs-next" class="pager-btn">Next →</button>
6170 <button id="hs-last" class="pager-btn pager-edge" title="Last page">Last ⇥</button>
6171 </div>
6172 </section>
6173 {% endif %}
6174
6175 <section class="panel stack">
6176 <div>
6177 <div class="toolbar"><div class="toolbar-left"><h2>Language Breakdown</h2></div><button class="chart-expand-btn" id="lang-overview-expand-btn" title="View full chart" aria-label="Expand charts">⤢ Full View</button></div>
6178 <div id="report-lang-overview" style="margin:0 0 16px;"></div>
6179 <div class="table-shell">
6180 <table id="lang-breakdown-table" data-sort-table class="table-resizable">
6181 <colgroup>
6182 <col><col><col><col><col><col><col><col><col><col><col><col><col><col>
6183 </colgroup>
6184 <thead>
6185 <tr>
6186 <th data-sort-type="text">Language<div class="col-resize-handle"></div></th>
6187 <th data-sort-type="number" class="num-col">Files<div class="col-resize-handle"></div></th>
6188 <th data-sort-type="number" class="num-col">Physical<div class="col-resize-handle"></div></th>
6189 <th data-sort-type="number" class="num-col">Code<div class="col-resize-handle"></div></th>
6190 <th data-sort-type="number" class="num-col">Comments<div class="col-resize-handle"></div></th>
6191 <th data-sort-type="number" class="num-col">Blank<div class="col-resize-handle"></div></th>
6192 <th data-sort-type="number" class="num-col">Mixed<div class="col-resize-handle"></div></th>
6193 <th data-sort-type="number" class="num-col">Functions<div class="col-resize-handle"></div></th>
6194 <th data-sort-type="number" class="num-col">Classes<div class="col-resize-handle"></div></th>
6195 <th data-sort-type="number" class="num-col">Variables<div class="col-resize-handle"></div></th>
6196 <th data-sort-type="number" class="num-col">Imports<div class="col-resize-handle"></div></th>
6197 <th data-sort-type="number" class="num-col">Tests<div class="col-resize-handle"></div></th>
6198 <th data-sort-type="number" class="num-col">Assertions<div class="col-resize-handle"></div></th>
6199 <th data-sort-type="number" class="num-col">Suites<div class="col-resize-handle"></div></th>
6200 </tr>
6201 </thead>
6202 <tbody>
6203 {% for row in language_rows %}
6204 <tr>
6205 <td title="{{ row.language }}">{{ row.language }}</td>
6206 <td class="num-col">{{ row.files|commas }}</td>
6207 <td class="num-col">{{ row.total_physical_lines|commas }}</td>
6208 <td class="num-col">{{ row.code_lines|commas }}</td>
6209 <td class="num-col">{{ row.comment_lines|commas }}</td>
6210 <td class="num-col">{{ row.blank_lines|commas }}</td>
6211 <td class="num-col">{{ row.mixed_lines_separate|commas }}</td>
6212 <td class="num-col">{{ row.functions|commas }}</td>
6213 <td class="num-col">{{ row.classes|commas }}</td>
6214 <td class="num-col">{{ row.variables|commas }}</td>
6215 <td class="num-col">{{ row.imports|commas }}</td>
6216 <td class="num-col">{{ row.test_count|commas }}</td>
6217 <td class="num-col">{{ row.test_assertion_count|commas }}</td>
6218 <td class="num-col">{{ row.test_suite_count|commas }}</td>
6219 </tr>
6220 {% endfor %}
6221 </tbody>
6222 </table>
6223 </div>
6224 </div>
6225 </section>
6226
6227 <section class="panel stack">
6228 <div class="toolbar"><div class="toolbar-left"><h2>Per-file detail</h2><input id="per-file-search" class="search" type="search" placeholder="Filter files, languages, status, warnings..." /><div class="page-size-row"><label class="page-size-label" for="per-file-page-size">Show:</label><select id="per-file-page-size" class="page-size-select"><option value="20" selected>20</option><option value="50">50</option><option value="100">100</option><option value="all">All</option></select><span id="per-file-count-label" class="page-count-label"></span></div></div><div class="pill-row"><span class="pill good">Counts shown as analyzed by the selected policy</span><div class="export-group"><button class="export-btn" data-reset-table title="Reset scroll and column layout">↻ Reset</button><button class="export-btn" data-export-csv>↓ CSV</button><button class="export-btn" data-export-xls>↓ Excel</button></div></div></div>
6229 <div class="table-shell table-shell-clip">
6230 <div id="per-file-shell">
6231 <table id="per-file-table" data-sort-table class="table-resizable">
6232 <colgroup>
6233 <col><col><col><col><col><col><col><col><col><col><col><col><col><col>
6234 </colgroup>
6235 <thead>
6236 <tr>
6237 <th data-sort-type="text">File<div class="col-resize-handle"></div></th>
6238 <th data-sort-type="text">Language<div class="col-resize-handle"></div></th>
6239 <th data-sort-type="number" class="num-col">Physical<div class="col-resize-handle"></div></th>
6240 <th data-sort-type="number" class="num-col">Code<div class="col-resize-handle"></div></th>
6241 <th data-sort-type="number" class="num-col">Comments<div class="col-resize-handle"></div></th>
6242 <th data-sort-type="number" class="num-col">Blank<div class="col-resize-handle"></div></th>
6243 <th data-sort-type="number" class="num-col">Mixed<div class="col-resize-handle"></div></th>
6244 <th data-sort-type="number" class="num-col">Functions<div class="col-resize-handle"></div></th>
6245 <th data-sort-type="number" class="num-col">Classes<div class="col-resize-handle"></div></th>
6246 <th data-sort-type="number" class="num-col">Variables<div class="col-resize-handle"></div></th>
6247 <th data-sort-type="number" class="num-col">Imports<div class="col-resize-handle"></div></th>
6248 <th data-sort-type="number" class="num-col">Tests<div class="col-resize-handle"></div></th>
6249 <th data-sort-type="number" class="num-col">Assertions<div class="col-resize-handle"></div></th>
6250 <th data-sort-type="number" class="num-col">Suites<div class="col-resize-handle"></div></th>
6251 {% if has_coverage_data %}<th data-sort-type="text" class="num-col">Line Cov %<div class="col-resize-handle"></div></th><th data-sort-type="text" class="num-col">Fn Cov %<div class="col-resize-handle"></div></th>{% endif %}
6252 </tr>
6253 </thead>
6254 <tbody>
6255 {% for row in file_rows %}
6256 <tr>
6257 <td class="mono" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
6258 <td title="{{ row.language }}">{{ row.language }}</td>
6259 <td class="num-col">{{ row.total_physical_lines }}</td>
6260 <td class="num-col">{{ row.code_lines }}</td>
6261 <td class="num-col">{{ row.comment_lines }}</td>
6262 <td class="num-col">{{ row.blank_lines }}</td>
6263 <td class="num-col">{{ row.mixed_lines_separate }}</td>
6264 <td class="num-col">{{ row.functions }}</td>
6265 <td class="num-col">{{ row.classes }}</td>
6266 <td class="num-col">{{ row.variables }}</td>
6267 <td class="num-col">{{ row.imports }}</td>
6268 <td class="num-col">{{ row.test_count }}</td>
6269 <td class="num-col">{{ row.test_assertion_count }}</td>
6270 <td class="num-col">{{ row.test_suite_count }}</td>
6271 {% if has_coverage_data %}<td class="num-col">{{ row.line_cov_pct }}</td><td class="num-col">{{ row.fn_cov_pct }}</td>{% endif %}
6272 </tr>
6273 {% endfor %}
6274 </tbody>
6275 </table>
6276 </div>
6277 </div>
6278 <div id="per-file-pagination" class="pagination-bar">
6279 <button id="pf-first" class="pager-btn pager-edge" disabled title="First page">⇤ First</button>
6280 <button id="pf-prev" class="pager-btn" disabled>← Prev</button>
6281 <span class="pager-jump-wrap">Page <input id="pf-page-jump" class="pager-jump" type="number" min="1" value="1" title="Jump to page"> of <span id="pf-page-total">—</span></span>
6282 <span id="pf-page-info" class="pager-info"></span>
6283 <button id="pf-next" class="pager-btn">Next →</button>
6284 <button id="pf-last" class="pager-btn pager-edge" title="Last page">Last ⇥</button>
6285 </div>
6286 </section>
6287
6288 <section class="panel stack">
6289 <div class="toolbar"><div class="toolbar-left"><h2>Skipped files</h2><input id="skipped-search" class="search" type="search" placeholder="Filter skipped files, reasons, warnings..." /><div class="page-size-row"><label class="page-size-label" for="skipped-page-size">Show:</label><select id="skipped-page-size" class="page-size-select"><option value="10" selected>10</option><option value="20">20</option><option value="50">50</option><option value="100">100</option><option value="all">All</option></select><span id="skipped-count-label" class="page-count-label"></span></div></div><div class="export-group"><button class="export-btn" id="skipped-export-csv">↓ CSV</button><button class="export-btn" id="skipped-export-xls">↓ Excel</button></div></div>
6290 <div class="table-shell table-shell-clip" style="margin-top:6px;">
6291 <div id="skipped-shell">
6292 <table id="skipped-table" data-sort-table class="table-resizable">
6293 <thead>
6294 <tr>
6295 <th data-sort-type="text" style="width:42%">File</th>
6296 <th data-sort-type="text" style="width:20%">Status</th>
6297 <th data-sort-type="text" style="width:38%">Warnings</th>
6298 </tr>
6299 </thead>
6300 <tbody>
6301 {% for row in skipped_rows %}
6302 <tr>
6303 <td class="mono" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
6304 <td><span class="status-tag status-{{ row.status_class }}">{{ row.status }}</span></td>
6305 <td class="small" title="{{ row.warnings }}">{{ row.warnings }}</td>
6306 </tr>
6307 {% endfor %}
6308 </tbody>
6309 </table>
6310 </div>
6311 </div>
6312 <div id="skipped-pagination" class="pagination-bar">
6313 <button id="sk-first" class="pager-btn pager-edge" disabled title="First page">⇤ First</button>
6314 <button id="sk-prev" class="pager-btn" disabled>← Prev</button>
6315 <span class="pager-jump-wrap">Page <input id="sk-page-jump" class="pager-jump" type="number" min="1" value="1" title="Jump to page"> of <span id="sk-page-total">—</span></span>
6316 <span id="sk-page-info" class="pager-info"></span>
6317 <button id="sk-next" class="pager-btn">Next →</button>
6318 <button id="sk-last" class="pager-btn pager-edge" title="Last page">Last ⇥</button>
6319 </div>
6320 </section>
6321
6322 <section class="panel stack">
6323 <div>
6324 <div class="toolbar">
6325 <div class="toolbar-left"><h2>Diagnostics & Configuration</h2></div>
6326 {% if !is_sub_report && has_run_warnings %}<div class="pill-row"><span class="pill info" style="font-size:11px;min-height:26px;">{{ warning_count }} total warnings</span></div>{% endif %}
6327 </div>
6328 <p class="effective-config-note">Warning summary, support improvement opportunities, raw diagnostic output, and the exact configuration in effect for this scan.</p>
6329 </div>
6330
6331 {% if !is_sub_report %}
6332 <div style="margin-top:-14px;">
6333 <h3 style="margin:0 0 4px;">Warnings overview</h3>
6334 <p class="support-note">Warning categories produced during the scan. Each row shows the warning type, how many files were affected, and what it means for your results.</p>
6335 {% if !has_run_warnings %}
6336 <div class="pill good">No top-level warnings.</div>
6337 {% else %}
6338 <div class="table-shell">
6339 <table class="support-table">
6340 <thead>
6341 <tr><th style="width:30%;">Category</th><th style="width:8%;">Count</th><th>What this means</th></tr>
6342 </thead>
6343 <tbody>
6344 {% for row in warning_summary_rows %}
6345 <tr class="{{ row.tone_class }}">
6346 <td style="font-weight:700;" title="{{ row.label }}">{{ row.label }}</td>
6347 <td class="warning-count" style="font-weight:800;">{{ row.count }}</td>
6348 <td class="small" style="color:var(--muted);">{{ row.detail }}</td>
6349 </tr>
6350 {% endfor %}
6351 </tbody>
6352 </table>
6353 </div>
6354 {% endif %}
6355 </div>
6356
6357 <div>
6358 <h3 style="margin:0 0 4px;">Skipped file categories</h3>
6359 <p class="support-note">Files that were not analyzed, grouped by category. Each row shows what the files are, how many were skipped, example file names, and how to silence or fix the warning.</p>
6360 {% if warning_opportunity_rows.is_empty() %}
6361 <div class="pill good">No unsupported text-format buckets detected.</div>
6362 {% else %}
6363 <div class="table-shell">
6364 <table class="support-table">
6365 <thead>
6366 <tr><th style="width:20%;">Category</th><th style="width:6%;">Count</th><th style="width:24%;">What these files are</th><th>Example files & how to fix</th></tr>
6367 </thead>
6368 <tbody>
6369 {% for row in warning_opportunity_rows %}
6370 <tr>
6371 <td style="font-weight:700;" title="{{ row.label }}">{{ row.label }}</td>
6372 <td style="font-weight:800;color:var(--oxide);">{{ row.count }}</td>
6373 <td class="small" style="color:var(--muted);">{{ row.bucket_description }}</td>
6374 <td>
6375 {% if !row.example_files.is_empty() %}
6376 <div style="margin-bottom:6px;">
6377 {% for f in row.example_files %}<span class="support-example-file">{{ f }}</span> {% endfor %}
6378 {% if row.count > row.example_files.len() %}<span style="font-size:11px;color:var(--muted);font-style:italic;">+{{ row.count - row.example_files.len() }} more</span>{% endif %}
6379 </div>
6380 {% endif %}
6381 <p class="support-recommendation">{{ row.recommendation }}</p>
6382 </td>
6383 </tr>
6384 {% endfor %}
6385 </tbody>
6386 </table>
6387 </div>
6388 {% endif %}
6389 </div>
6390
6391 <div>
6392 <details open class="warnings-details">
6393 <summary>Detailed run warnings ({{ warning_count }})</summary>
6394 <div>
6395 <p style="font-size:13px;color:var(--muted);margin:0 0 10px;">Raw warning messages emitted during the scan — unsupported file formats, encoding fallbacks, binary detections, and per-file parse issues. Scroll to see all warnings. High counts typically indicate many non-code assets (JSON configs, docs, lockfiles) in the scanned directory.</p>
6396 {% if !has_run_warnings %}
6397 <div class="pill good">No top-level warnings.</div>
6398 {% else %}
6399 <div class="code-block-toolbar">
6400 <button type="button" class="code-copy-btn" id="warning-console-copy-btn" aria-label="Copy warnings">Copy</button>
6401 </div>
6402 <pre class="warning-console" id="warning-console-full" style="max-height:210px;">{{ warning_console_full }}</pre>
6403 {% endif %}
6404 </div>
6405 </details>
6406 </div>
6407 {% endif %}
6408
6409 <div>
6410 <details open>
6411 <summary>Effective configuration</summary>
6412 <div>
6413 <div style="display:flex;gap:8px;margin-bottom:10px;">
6414 <button type="button" class="export-btn" data-copy-config>Copy</button>
6415 <button type="button" class="export-btn" data-download-config>Download</button>
6416 </div>
6417 <p style="font-size:13px;color:var(--muted);margin:0 0 10px;">The merged, fully-resolved configuration snapshot used for this scan — includes all CLI overrides applied on top of the base config file. Use this to replay the exact run or verify what settings were active.</p>
6418 <div class="config-pre-wrap">
6419 <div class="code-block-toolbar">
6420 <button type="button" class="code-copy-btn" id="config-inline-copy-btn" aria-label="Copy configuration">Copy</button>
6421 </div>
6422 <pre class="config-pre" id="config-json-block">{{ config_json }}</pre>
6423 </div>
6424 </div>
6425 </details>
6426 </div>
6427 </section>
6428 </div>
6429 </div>
6430
6431 <div id="r-tt" aria-hidden="true"></div>
6432 <script nonce="{{ nonce }}">
6433 // Hide "View PDF" button and block brand-link navigation when opened as a local file
6434 (function () {
6435 var pdfBtn = document.getElementById('nav-view-pdf-btn');
6436 if (pdfBtn && window.location.protocol === 'file:') {
6437 pdfBtn.style.display = 'none';
6438 }
6439 var brand = document.querySelector('a[data-local-brand]');
6440 if (brand && window.location.protocol === 'file:') {
6441 brand.addEventListener('click', function (e) { e.preventDefault(); });
6442 }
6443 })();
6444
6445 (function () {
6446 var body = document.body;
6447 var storageKey = 'oxide-sloc-theme';
6448 var themeToggle = document.querySelector('[data-theme-toggle]');
6449 var copyLinkButtons = Array.prototype.slice.call(document.querySelectorAll('[data-copy-link]'));
6450 var shareButtons = Array.prototype.slice.call(document.querySelectorAll('[data-share-report]'));
6451 var printButtons = Array.prototype.slice.call(document.querySelectorAll('[data-print-report]'));
6452
6453 function applyTheme(theme) {
6454 body.classList.toggle('dark-theme', theme === 'dark');
6455 }
6456
6457 function currentTheme() {
6458 return body.classList.contains('dark-theme') ? 'dark' : 'light';
6459 }
6460
6461 try {
6462 var saved = localStorage.getItem(storageKey);
6463 if (saved === 'dark' || saved === 'light') {
6464 applyTheme(saved);
6465 }
6466 } catch (e) {}
6467
6468 if (themeToggle) {
6469 themeToggle.addEventListener('click', function () {
6470 var next = currentTheme() === 'dark' ? 'light' : 'dark';
6471 applyTheme(next);
6472 try { localStorage.setItem(storageKey, next); } catch (e) {}
6473 });
6474 }
6475
6476 function copyText(value) {
6477 if (!value) return;
6478 if (navigator.clipboard && navigator.clipboard.writeText) {
6479 navigator.clipboard.writeText(value).catch(function () {});
6480 }
6481 }
6482
6483 copyLinkButtons.forEach(function (button) {
6484 button.addEventListener('click', function () {
6485 copyText(window.location.href);
6486 });
6487 });
6488
6489 shareButtons.forEach(function (button) {
6490 button.addEventListener('click', function () {
6491 if (navigator.share) {
6492 navigator.share({ title: document.title, url: window.location.href }).catch(function () {});
6493 } else {
6494 copyText(window.location.href);
6495 }
6496 });
6497 });
6498
6499 printButtons.forEach(function (button) {
6500 button.addEventListener('click', function () {
6501 window.print();
6502 });
6503 });
6504
6505 // "View PDF" nav button.
6506 // Priority order:
6507 // 1. data-standalone-pdf attr — pre-generated PDF in the same directory
6508 // (set when oxide-sloc CLI was invoked with both --html-out and
6509 // --pdf-out). Opens the file directly; works in Jenkins HTML Publisher.
6510 // 2. Server route (/runs/pdf/<id>) — oxide-sloc web server generates
6511 // the PDF on demand via headless Chrome. Checked via HEAD request.
6512 // 3. Neither available — inform the user how to generate a PDF via CLI.
6513 var pdfNavBtn = document.getElementById('nav-view-pdf-btn');
6514 if (pdfNavBtn) {
6515 pdfNavBtn.addEventListener('click', function (e) {
6516 e.preventDefault();
6517 var standaloneUrl = pdfNavBtn.getAttribute('data-standalone-pdf');
6518 if (standaloneUrl) {
6519 window.open(standaloneUrl, '_blank', 'noopener');
6520 return;
6521 }
6522 var serverUrl = pdfNavBtn.getAttribute('href');
6523 var xhr = new XMLHttpRequest();
6524 xhr.open('HEAD', serverUrl, true);
6525 xhr.onreadystatechange = function () {
6526 if (xhr.readyState === 4) {
6527 if (xhr.status >= 200 && xhr.status < 300) {
6528 window.open(serverUrl, '_blank', 'noopener');
6529 } else {
6530 alert('PDF not available.\n\nTo generate one, run:\n oxide-sloc report result.json --pdf-out report.pdf\n\nOr enable GENERATE_PDF in your Jenkins pipeline.');
6531 }
6532 }
6533 };
6534 xhr.onerror = function () {
6535 alert('PDF not available.\n\nTo generate one, run:\n oxide-sloc report result.json --pdf-out report.pdf\n\nOr enable GENERATE_PDF in your Jenkins pipeline.');
6536 };
6537 xhr.send();
6538 });
6539 }
6540
6541 var copyConfigBtn = document.querySelector('[data-copy-config]');
6542 var downloadConfigBtn = document.querySelector('[data-download-config]');
6543 var configBlock = document.getElementById('config-json-block');
6544 var inlineCopyBtn = document.getElementById('config-inline-copy-btn');
6545 function handleConfigCopy(btn) {
6546 if (!btn || !configBlock) return;
6547 btn.addEventListener('click', function (e) {
6548 e.stopPropagation();
6549 copyText(configBlock.textContent);
6550 var orig = btn.textContent;
6551 btn.textContent = 'Copied!';
6552 setTimeout(function () { btn.textContent = orig; }, 1600);
6553 });
6554 }
6555 handleConfigCopy(copyConfigBtn);
6556 handleConfigCopy(inlineCopyBtn);
6557
6558 var warnCopyBtn = document.getElementById('warning-console-copy-btn');
6559 var warnBlock = document.getElementById('warning-console-full');
6560 if (warnCopyBtn && warnBlock) {
6561 warnCopyBtn.addEventListener('click', function () {
6562 copyText(warnBlock.textContent);
6563 var orig = warnCopyBtn.textContent;
6564 warnCopyBtn.textContent = 'Copied!';
6565 setTimeout(function () { warnCopyBtn.textContent = orig; }, 1600);
6566 });
6567 }
6568
6569 if (downloadConfigBtn && configBlock) {
6570 downloadConfigBtn.addEventListener('click', function (e) {
6571 e.stopPropagation();
6572 var blob = new Blob([configBlock.textContent], { type: 'application/json' });
6573 var url = URL.createObjectURL(blob);
6574 var a = document.createElement('a');
6575 a.href = url; a.download = 'effective-config.json';
6576 document.body.appendChild(a); a.click();
6577 document.body.removeChild(a);
6578 setTimeout(function () { URL.revokeObjectURL(url); }, 200);
6579 });
6580 }
6581
6582 function detectType(value) {
6583 // Strip thousands separators so comma-formatted numbers (e.g. "121,542")
6584 // still sort numerically rather than lexicographically.
6585 var v = value.trim().replace(/,/g, '');
6586 return /^-?\d+(?:\.\d+)?$/.test(v) ? parseFloat(v) : value.trim().toLowerCase();
6587 }
6588
6589 document.querySelectorAll('[data-sort-table]').forEach(function (table) {
6590 var headers = Array.prototype.slice.call(table.querySelectorAll('th'));
6591 var allMarkers = [];
6592 headers.forEach(function (th, idx) {
6593 var direction = 1;
6594 var marker = document.createElement('span');
6595 marker.className = 'sort-indicator';
6596 marker.textContent = ' \u2195';
6597 th.style.cursor = 'pointer';
6598 th.appendChild(marker);
6599 allMarkers.push(marker);
6600 th.addEventListener('click', function (e) {
6601 if (e.target.closest && e.target.closest('.col-resize-handle')) return;
6602 var tbody = table.tBodies[0];
6603 var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
6604 rows.sort(function (a, b) {
6605 var av = detectType((a.children[idx].textContent || '').trim());
6606 var bv = detectType((b.children[idx].textContent || '').trim());
6607 if (av < bv) return -1 * direction;
6608 if (av > bv) return 1 * direction;
6609 return 0;
6610 });
6611 rows.forEach(function (row) { tbody.appendChild(row); });
6612 allMarkers.forEach(function(m) { m.textContent = ' \u2195'; });
6613 direction = direction * -1;
6614 marker.textContent = direction === -1 ? ' \u2191' : ' \u2193';
6615 table.dispatchEvent(new CustomEvent('sloc-sorted'));
6616 });
6617 });
6618 });
6619
6620 // ── Column resize for all table-resizable tables ──────────────────────────
6621 (function() {
6622 document.querySelectorAll('.table-resizable').forEach(function(table) {
6623 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
6624 var ths = Array.prototype.slice.call(table.querySelectorAll('thead th'));
6625 ths.forEach(function(th, i) {
6626 var handle = th.querySelector('.col-resize-handle');
6627 if (!handle || !cols[i]) return;
6628 var startX, startW;
6629 handle.addEventListener('mousedown', function(e) {
6630 e.stopPropagation(); e.preventDefault();
6631 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
6632 handle.classList.add('dragging');
6633 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
6634 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
6635 document.addEventListener('mousemove', onMove);
6636 document.addEventListener('mouseup', onUp);
6637 });
6638 });
6639 });
6640 })();
6641
6642 document.querySelectorAll('[data-table-filter]').forEach(function (input) {
6643 var table = document.getElementById(input.getAttribute('data-table-filter'));
6644 if (!table) return;
6645 var filterTimer = null;
6646 var rowCache = null;
6647 input.addEventListener('input', function () {
6648 clearTimeout(filterTimer);
6649 var q = input.value.toLowerCase();
6650 filterTimer = setTimeout(function () {
6651 if (!rowCache) {
6652 rowCache = Array.prototype.map.call(table.tBodies[0].rows, function (row) {
6653 return { row: row, text: row.textContent.toLowerCase() };
6654 });
6655 }
6656 rowCache.forEach(function (item) {
6657 item.row.style.display = q === '' || item.text.indexOf(q) >= 0 ? '' : 'none';
6658 });
6659 }, 200);
6660 });
6661 });
6662
6663 // ── Per-file table pagination ────────────────────────────────────────────
6664 (function () {
6665 var table = document.getElementById('per-file-table');
6666 if (!table) return;
6667 var tbody = table.tBodies[0];
6668 var searchInput = document.getElementById('per-file-search');
6669 var pageSizeSelect = document.getElementById('per-file-page-size');
6670 var firstBtn = document.getElementById('pf-first');
6671 var prevBtn = document.getElementById('pf-prev');
6672 var nextBtn = document.getElementById('pf-next');
6673 var lastBtn = document.getElementById('pf-last');
6674 var pageInfo = document.getElementById('pf-page-info');
6675 var jumpInput = document.getElementById('pf-page-jump');
6676 var pageTotal = document.getElementById('pf-page-total');
6677 var countLabel = document.getElementById('per-file-count-label');
6678 var filteredRows = [];
6679 var currentPage = 1;
6680 var totalAll = tbody.rows.length;
6681
6682 function getPageSize() {
6683 var v = pageSizeSelect ? pageSizeSelect.value : '20';
6684 return v === 'all' ? Infinity : parseInt(v, 10);
6685 }
6686
6687 function applyFilter() {
6688 var q = searchInput ? searchInput.value.toLowerCase() : '';
6689 var rows = Array.prototype.slice.call(tbody.rows);
6690 filteredRows = q === '' ? rows : rows.filter(function (row) {
6691 return row.textContent.toLowerCase().indexOf(q) >= 0;
6692 });
6693 currentPage = 1;
6694 render();
6695 }
6696
6697 function render() {
6698 var ps = getPageSize();
6699 var total = filteredRows.length;
6700 var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(total / ps));
6701 if (currentPage > totalPages) currentPage = totalPages;
6702 if (currentPage < 1) currentPage = 1;
6703 var start = ps === Infinity ? 0 : (currentPage - 1) * ps;
6704 var end = ps === Infinity ? total : Math.min(start + ps, total);
6705 Array.prototype.forEach.call(tbody.rows, function (row) { row.style.display = 'none'; });
6706 for (var i = start; i < end; i++) { filteredRows[i].style.display = ''; }
6707 if (pageInfo) {
6708 if (total === 0) {
6709 pageInfo.textContent = 'No results';
6710 } else if (ps === Infinity) {
6711 pageInfo.textContent = 'All ' + total.toLocaleString() + ' files';
6712 } else {
6713 pageInfo.textContent = (start + 1) + '\u2013' + end + ' of ' + total.toLocaleString() + ' files';
6714 }
6715 }
6716 if (countLabel) {
6717 countLabel.textContent = (total < totalAll && total > 0) ? '(' + total.toLocaleString() + ' matching)' : '';
6718 }
6719 var edgeDisabled = ps === Infinity;
6720 if (firstBtn) firstBtn.disabled = currentPage <= 1 || edgeDisabled;
6721 if (prevBtn) prevBtn.disabled = currentPage <= 1 || edgeDisabled;
6722 if (nextBtn) nextBtn.disabled = currentPage >= totalPages || edgeDisabled;
6723 if (lastBtn) lastBtn.disabled = currentPage >= totalPages || edgeDisabled;
6724 if (jumpInput) { jumpInput.value = currentPage; jumpInput.max = totalPages; jumpInput.disabled = edgeDisabled; }
6725 if (pageTotal) pageTotal.textContent = totalPages.toLocaleString();
6726 }
6727
6728 if (searchInput) {
6729 var filterTimer = null;
6730 searchInput.addEventListener('input', function () {
6731 clearTimeout(filterTimer);
6732 filterTimer = setTimeout(applyFilter, 200);
6733 });
6734 }
6735 if (pageSizeSelect) {
6736 pageSizeSelect.addEventListener('change', function () { currentPage = 1; render(); });
6737 }
6738 if (firstBtn) {
6739 firstBtn.addEventListener('click', function () { currentPage = 1; render(); });
6740 }
6741 if (prevBtn) {
6742 prevBtn.addEventListener('click', function () { if (currentPage > 1) { currentPage--; render(); } });
6743 }
6744 if (nextBtn) {
6745 nextBtn.addEventListener('click', function () {
6746 var ps = getPageSize();
6747 var totalPages = ps === Infinity ? 1 : Math.ceil(filteredRows.length / ps);
6748 if (currentPage < totalPages) { currentPage++; render(); }
6749 });
6750 }
6751 if (lastBtn) {
6752 lastBtn.addEventListener('click', function () {
6753 var ps = getPageSize();
6754 currentPage = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
6755 render();
6756 });
6757 }
6758 if (jumpInput) {
6759 function pfJump() {
6760 var ps = getPageSize();
6761 var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
6762 var v = parseInt(jumpInput.value, 10);
6763 if (!isNaN(v)) { currentPage = Math.max(1, Math.min(v, totalPages)); render(); }
6764 }
6765 jumpInput.addEventListener('change', pfJump);
6766 jumpInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') pfJump(); });
6767 }
6768 table.addEventListener('sloc-sorted', function () { applyFilter(); });
6769 window._pfPaginationReset = function () { currentPage = 1; applyFilter(); };
6770 applyFilter();
6771 })();
6772
6773 // ── Skipped-files table pagination ───────────────────────────────────────
6774 (function () {
6775 var table = document.getElementById('skipped-table');
6776 if (!table) return;
6777 var tbody = table.tBodies[0];
6778 var searchInput = document.getElementById('skipped-search');
6779 var pageSizeSelect = document.getElementById('skipped-page-size');
6780 var firstBtn = document.getElementById('sk-first');
6781 var prevBtn = document.getElementById('sk-prev');
6782 var nextBtn = document.getElementById('sk-next');
6783 var lastBtn = document.getElementById('sk-last');
6784 var pageInfo = document.getElementById('sk-page-info');
6785 var jumpInput = document.getElementById('sk-page-jump');
6786 var pageTotal = document.getElementById('sk-page-total');
6787 var countLabel = document.getElementById('skipped-count-label');
6788 var filteredRows = [];
6789 var currentPage = 1;
6790 var totalAll = tbody.rows.length;
6791
6792 function getPageSize() {
6793 var v = pageSizeSelect ? pageSizeSelect.value : '10';
6794 return v === 'all' ? Infinity : parseInt(v, 10);
6795 }
6796
6797 function applyFilter() {
6798 var q = searchInput ? searchInput.value.toLowerCase() : '';
6799 var rows = Array.prototype.slice.call(tbody.rows);
6800 filteredRows = q === '' ? rows : rows.filter(function (row) {
6801 return row.textContent.toLowerCase().indexOf(q) >= 0;
6802 });
6803 currentPage = 1;
6804 render();
6805 }
6806
6807 function render() {
6808 var ps = getPageSize();
6809 var total = filteredRows.length;
6810 var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(total / ps));
6811 if (currentPage > totalPages) currentPage = totalPages;
6812 if (currentPage < 1) currentPage = 1;
6813 var start = ps === Infinity ? 0 : (currentPage - 1) * ps;
6814 var end = ps === Infinity ? total : Math.min(start + ps, total);
6815 Array.prototype.forEach.call(tbody.rows, function (row) { row.style.display = 'none'; });
6816 for (var i = start; i < end; i++) { filteredRows[i].style.display = ''; }
6817 if (pageInfo) {
6818 if (total === 0) {
6819 pageInfo.textContent = 'No results';
6820 } else if (ps === Infinity) {
6821 pageInfo.textContent = 'All ' + total.toLocaleString() + ' files';
6822 } else {
6823 pageInfo.textContent = (start + 1) + '\u2013' + end + ' of ' + total.toLocaleString() + ' files';
6824 }
6825 }
6826 if (countLabel) {
6827 countLabel.textContent = (total < totalAll && total > 0) ? '(' + total.toLocaleString() + ' matching)' : '';
6828 }
6829 var edgeDisabled = ps === Infinity;
6830 if (firstBtn) firstBtn.disabled = currentPage <= 1 || edgeDisabled;
6831 if (prevBtn) prevBtn.disabled = currentPage <= 1 || edgeDisabled;
6832 if (nextBtn) nextBtn.disabled = currentPage >= totalPages || edgeDisabled;
6833 if (lastBtn) lastBtn.disabled = currentPage >= totalPages || edgeDisabled;
6834 if (jumpInput) { jumpInput.value = currentPage; jumpInput.max = totalPages; jumpInput.disabled = edgeDisabled; }
6835 if (pageTotal) pageTotal.textContent = totalPages.toLocaleString();
6836 }
6837
6838 if (searchInput) {
6839 var filterTimer = null;
6840 searchInput.addEventListener('input', function () {
6841 clearTimeout(filterTimer);
6842 filterTimer = setTimeout(applyFilter, 200);
6843 });
6844 }
6845 if (pageSizeSelect) {
6846 pageSizeSelect.addEventListener('change', function () { currentPage = 1; render(); });
6847 }
6848 if (firstBtn) {
6849 firstBtn.addEventListener('click', function () { currentPage = 1; render(); });
6850 }
6851 if (prevBtn) {
6852 prevBtn.addEventListener('click', function () { if (currentPage > 1) { currentPage--; render(); } });
6853 }
6854 if (nextBtn) {
6855 nextBtn.addEventListener('click', function () {
6856 var ps = getPageSize();
6857 var totalPages = ps === Infinity ? 1 : Math.ceil(filteredRows.length / ps);
6858 if (currentPage < totalPages) { currentPage++; render(); }
6859 });
6860 }
6861 if (lastBtn) {
6862 lastBtn.addEventListener('click', function () {
6863 var ps = getPageSize();
6864 currentPage = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
6865 render();
6866 });
6867 }
6868 if (jumpInput) {
6869 function skJump() {
6870 var ps = getPageSize();
6871 var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
6872 var v = parseInt(jumpInput.value, 10);
6873 if (!isNaN(v)) { currentPage = Math.max(1, Math.min(v, totalPages)); render(); }
6874 }
6875 jumpInput.addEventListener('change', skJump);
6876 jumpInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') skJump(); });
6877 }
6878 table.addEventListener('sloc-sorted', function () { applyFilter(); });
6879 applyFilter();
6880 })();
6881
6882 // ── Hotspots table pagination ────────────────────────────────────────────
6883 (function () {
6884 var table = document.getElementById('hotspots-table');
6885 if (!table) return;
6886 var tbody = table.tBodies[0];
6887 var searchInput = document.getElementById('hotspots-search');
6888 var pageSizeSelect = document.getElementById('hotspots-page-size');
6889 var firstBtn = document.getElementById('hs-first');
6890 var prevBtn = document.getElementById('hs-prev');
6891 var nextBtn = document.getElementById('hs-next');
6892 var lastBtn = document.getElementById('hs-last');
6893 var pageInfo = document.getElementById('hs-page-info');
6894 var jumpInput = document.getElementById('hs-page-jump');
6895 var pageTotal = document.getElementById('hs-page-total');
6896 var countLabel = document.getElementById('hotspots-count-label');
6897 var filteredRows = [];
6898 var currentPage = 1;
6899 var totalAll = tbody.rows.length;
6900
6901 function getPageSize() {
6902 var v = pageSizeSelect ? pageSizeSelect.value : '15';
6903 return v === 'all' ? Infinity : parseInt(v, 10);
6904 }
6905
6906 function applyFilter() {
6907 var q = searchInput ? searchInput.value.toLowerCase() : '';
6908 var rows = Array.prototype.slice.call(tbody.rows);
6909 filteredRows = q === '' ? rows : rows.filter(function (row) {
6910 return row.textContent.toLowerCase().indexOf(q) >= 0;
6911 });
6912 currentPage = 1;
6913 render();
6914 }
6915
6916 function render() {
6917 var ps = getPageSize();
6918 var total = filteredRows.length;
6919 var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(total / ps));
6920 if (currentPage > totalPages) currentPage = totalPages;
6921 if (currentPage < 1) currentPage = 1;
6922 var start = ps === Infinity ? 0 : (currentPage - 1) * ps;
6923 var end = ps === Infinity ? total : Math.min(start + ps, total);
6924 Array.prototype.forEach.call(tbody.rows, function (row) { row.style.display = 'none'; });
6925 for (var i = start; i < end; i++) { filteredRows[i].style.display = ''; }
6926 if (pageInfo) {
6927 if (total === 0) {
6928 pageInfo.textContent = 'No results';
6929 } else if (ps === Infinity) {
6930 pageInfo.textContent = 'All ' + total.toLocaleString() + ' files';
6931 } else {
6932 pageInfo.textContent = (start + 1) + '-' + end + ' of ' + total.toLocaleString() + ' files';
6933 }
6934 }
6935 if (countLabel) {
6936 countLabel.textContent = (total < totalAll && total > 0) ? '(' + total.toLocaleString() + ' matching)' : '';
6937 }
6938 var edgeDisabled = ps === Infinity;
6939 if (firstBtn) firstBtn.disabled = currentPage <= 1 || edgeDisabled;
6940 if (prevBtn) prevBtn.disabled = currentPage <= 1 || edgeDisabled;
6941 if (nextBtn) nextBtn.disabled = currentPage >= totalPages || edgeDisabled;
6942 if (lastBtn) lastBtn.disabled = currentPage >= totalPages || edgeDisabled;
6943 if (jumpInput) { jumpInput.value = currentPage; jumpInput.max = totalPages; jumpInput.disabled = edgeDisabled; }
6944 if (pageTotal) pageTotal.textContent = totalPages.toLocaleString();
6945 }
6946
6947 if (searchInput) {
6948 var filterTimer = null;
6949 searchInput.addEventListener('input', function () {
6950 clearTimeout(filterTimer);
6951 filterTimer = setTimeout(applyFilter, 200);
6952 });
6953 }
6954 if (pageSizeSelect) {
6955 pageSizeSelect.addEventListener('change', function () { currentPage = 1; render(); });
6956 }
6957 if (firstBtn) {
6958 firstBtn.addEventListener('click', function () { currentPage = 1; render(); });
6959 }
6960 if (prevBtn) {
6961 prevBtn.addEventListener('click', function () { if (currentPage > 1) { currentPage--; render(); } });
6962 }
6963 if (nextBtn) {
6964 nextBtn.addEventListener('click', function () {
6965 var ps = getPageSize();
6966 var totalPages = ps === Infinity ? 1 : Math.ceil(filteredRows.length / ps);
6967 if (currentPage < totalPages) { currentPage++; render(); }
6968 });
6969 }
6970 if (lastBtn) {
6971 lastBtn.addEventListener('click', function () {
6972 var ps = getPageSize();
6973 currentPage = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
6974 render();
6975 });
6976 }
6977 if (jumpInput) {
6978 function hsJump() {
6979 var ps = getPageSize();
6980 var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
6981 var v = parseInt(jumpInput.value, 10);
6982 if (!isNaN(v)) { currentPage = Math.max(1, Math.min(v, totalPages)); render(); }
6983 }
6984 jumpInput.addEventListener('change', hsJump);
6985 jumpInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') hsJump(); });
6986 }
6987 table.addEventListener('sloc-sorted', function () { applyFilter(); });
6988 applyFilter();
6989 })();
6990 })();
6991
6992 (function randomizeWatermarks() {
6993 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
6994 if (!wms.length) return;
6995 var placed = [];
6996 function tooClose(t, l) {
6997 for (var i = 0; i < placed.length; i++) {
6998 var dt = Math.abs(placed[i][0] - t);
6999 var dl = Math.abs(placed[i][1] - l);
7000 if (dt < 18 && dl < 18) return true;
7001 }
7002 return false;
7003 }
7004 function pick(leftBias) {
7005 for (var attempt = 0; attempt < 40; attempt++) {
7006 var t = Math.random() * 90;
7007 var l = leftBias ? Math.random() * 50 : 40 + Math.random() * 55;
7008 if (!tooClose(t, l)) { placed.push([t, l]); return [t, l]; }
7009 }
7010 var fb = [Math.random() * 90, Math.random() * 95];
7011 placed.push(fb);
7012 return fb;
7013 }
7014 var half = Math.floor(wms.length / 2);
7015 wms.forEach(function (img, i) {
7016 var pos = pick(i < half);
7017 var sz = Math.floor(Math.random() * 80 + 110);
7018 var rot = (Math.random() * 360).toFixed(1);
7019 var op = (Math.random() * 0.07 + 0.10).toFixed(2);
7020 img.style.cssText = 'width:' + sz + 'px;top:' + pos[0].toFixed(1) + '%;left:' + pos[1].toFixed(1) + '%;transform:rotate(' + rot + 'deg);opacity:' + op + ';';
7021 });
7022 })();
7023
7024 (function spawnCodeParticles() {
7025 var container = document.getElementById('code-particles');
7026 if (!container) return;
7027 var snippets = ['1,247 sloc', 'fn analyze()', 'code_lines', '0 mixed', 'blanks: 312', '// comment', 'pub fn run', 'use std::fs', 'Result<()>', 'let mut n = 0', 'git main', '#[derive]', 'impl Scan', '3,841 physical', 'files: 60', '450 comments', 'cargo build', 'Ok(run)', 'Vec<String>', 'match lang', 'fn main() {', '.rs .go .py', 'sloc_core', 'render_html', '2,163 code'];
7028 for (var i = 0; i < 38; i++) {
7029 (function (idx) {
7030 var el = document.createElement('span');
7031 el.className = 'code-particle';
7032 el.textContent = snippets[idx % snippets.length];
7033 var left = Math.random() * 94 + 2;
7034 var top = Math.random() * 88 + 6;
7035 var dur = (Math.random() * 10 + 9).toFixed(1);
7036 var delay = (Math.random() * 18).toFixed(1);
7037 var rot = (Math.random() * 26 - 13).toFixed(1);
7038 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
7039 el.style.left = left.toFixed(1) + '%';
7040 el.style.top = top.toFixed(1) + '%';
7041 el.style.setProperty('--rot', rot + 'deg');
7042 el.style.setProperty('--op', op);
7043 el.style.animationDuration = dur + 's';
7044 el.style.animationDelay = '-' + delay + 's';
7045 container.appendChild(el);
7046 })(i);
7047 }
7048 })();
7049 // ── Metric number formatting ─────────────────────────────────────────────
7050 (function () {
7051 function fmtBig(n) {
7052 if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
7053 if (n >= 1e4) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
7054 return n.toLocaleString();
7055 }
7056 function fmtExact(n) { return n.toLocaleString(); }
7057 document.querySelectorAll('[data-metric-value]').forEach(function (el) {
7058 var n = parseInt(el.getAttribute('data-metric-value'), 10);
7059 if (isNaN(n)) return;
7060 var big = el.querySelector('.metric-big');
7061 var exact = el.querySelector('.metric-exact');
7062 if (big) big.textContent = fmtBig(n);
7063 if (exact) exact.textContent = n >= 1e4 ? fmtExact(n) : '';
7064 });
7065 var densityCard = document.querySelector('[data-metric-density]');
7066 if (densityCard) {
7067 var phys = 0, code = 0;
7068 document.querySelectorAll('[data-metric-value]').forEach(function (el) {
7069 var lbl = el.querySelector('.metric-label');
7070 if (!lbl) return;
7071 var t = lbl.textContent.trim().toLowerCase();
7072 var v = parseInt(el.getAttribute('data-metric-value'), 10) || 0;
7073 if (t === 'physical lines') phys = v;
7074 if (t === 'code') code = v;
7075 });
7076 var pct = phys > 0 ? (code / phys * 100) : 0;
7077 var big = densityCard.querySelector('.metric-big');
7078 var exact = densityCard.querySelector('.metric-exact');
7079 if (big) big.textContent = pct.toFixed(1) + '%';
7080 if (exact) exact.textContent = '';
7081 }
7082 (function(){
7083 var g=document.querySelector('.summary-grid');if(!g)return;
7084 var pad=g.querySelector('.metric-pad');
7085 var real=Array.prototype.slice.call(g.querySelectorAll('.metric')).filter(function(el){return el!==pad;});
7086 if(!real.length)return;
7087 function upd(){
7088 // Pad the strip to an EVEN card count so a true CSS grid lays it out as
7089 // exactly two full rows with every column aligned and every card the
7090 // same size. When the real-card count is odd, reveal the reserve
7091 // "Assertions" pad card; otherwise keep it hidden.
7092 var n=real.length;
7093 if(pad){ if(n%2===1){pad.style.display='';n++;} else {pad.style.display='none';} }
7094 var perRow=window.innerWidth<=640?2:Math.ceil(n/2);
7095 g.style.gridTemplateColumns='repeat('+perRow+',minmax(0,1fr))';
7096 }
7097 upd();window.addEventListener('resize',upd);
7098 })();
7099 (function(){if(typeof window.__rptFinish==='function'){window.__rptFinish();return;}var ov=document.getElementById('rpt-loading-overlay');if(ov){ov.classList.add('fade-out');setTimeout(function(){if(ov.parentNode)ov.parentNode.removeChild(ov);},450);}})();
7100 })();
7101 // ── Info chip interactivity ───────────────────────────────────────────────
7102 (function() {
7103 document.querySelectorAll('.run-id-chip[data-copy]').forEach(function(chip) {
7104 chip.addEventListener('click', function() {
7105 var val = chip.getAttribute('data-copy');
7106 var tt = chip.querySelector('.chip-tooltip');
7107 var orig = tt ? tt.textContent : '';
7108 if (!navigator.clipboard) return;
7109 navigator.clipboard.writeText(val).then(function() {
7110 chip.classList.add('chip-copied-flash');
7111 if (tt) tt.textContent = 'Copied!';
7112 setTimeout(function() {
7113 chip.classList.remove('chip-copied-flash');
7114 if (tt) tt.textContent = orig;
7115 }, 1100);
7116 });
7117 });
7118 });
7119 document.querySelectorAll('.run-id-chip[data-author]').forEach(function(chip) {
7120 var author = chip.getAttribute('data-author');
7121 var el = chip.querySelector('.author-handle');
7122 if (el) el.textContent = '/' + author.replace(/\s+/g, '');
7123 });
7124 })();
7125 // ── Export helpers ────────────────────────────────────────────────────────
7126 function _slocUnh(s){var e=document.createElement('div');e.innerHTML=s;return e.textContent;}
7127 var _SLOC_META={runId:"{{ run.tool.run_id }}",gitCommit:"{% if let Some(c) = run.git_commit_long %}{{ c }}{% else %}(not detected){% endif %}",branch:_slocUnh("{% if let Some(b) = run.git_branch %}{{ b }}{% else %}(not detected){% endif %}"),lastCommitBy:_slocUnh("{% if let Some(a) = run.git_commit_author %}{{ a }}{% else %}(not detected){% endif %}"),scanBy:_slocUnh("{{ scan_performed_by }}"),scanned:"{{ scan_time_pst }}",os:"{{ run.environment.operating_system }} / {{ run.environment.architecture }}",filesAnalyzed:{{ run.summary_totals.files_analyzed }},filesSkipped:{{ run.summary_totals.files_skipped }},physicalLines:{{ run.summary_totals.total_physical_lines }},codeLines:{{ run.summary_totals.code_lines }},commentLines:{{ run.summary_totals.comment_lines }},blankLines:{{ run.summary_totals.blank_lines }},mixedSeparate:{{ run.summary_totals.mixed_lines_separate }},functions:{{ run.summary_totals.functions }},classes:{{ run.summary_totals.classes }},variables:{{ run.summary_totals.variables }},imports:{{ run.summary_totals.imports }},tests:{{ run.summary_totals.test_count }},toolVersion:"{{ tool_version }}"};
7128 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
7129 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
7130 function slocDownload(data,name,mime){var b=new Blob([data],{type:mime});var u=URL.createObjectURL(b);var a=document.createElement('a');a.href=u;a.download=name;document.body.appendChild(a);a.click();document.body.removeChild(a);setTimeout(function(){URL.revokeObjectURL(u);},200);}
7131 function slocCsv(fname,hdrs,rows){slocDownload([hdrs.map(slocEscCsv).join(',')].concat(rows.map(function(r){return r.map(slocEscCsv).join(',');})).join('\r\n'),fname,'text/csv;charset=utf-8;');}
7132 function slocXls(fname,sheet,hdrs,rows){var enc=new TextEncoder();var CT=[];for(var _n=0;_n<256;_n++){var _c=_n;for(var _k=0;_k<8;_k++)_c=_c&1?0xEDB88320^(_c>>>1):_c>>>1;CT[_n]=_c;}function crc32(d){var v=0xFFFFFFFF;for(var i=0;i<d.length;i++)v=CT[(v^d[i])&0xFF]^(v>>>8);return(v^0xFFFFFFFF)>>>0;}function u2(n){return[n&0xFF,(n>>8)&0xFF];}function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}var ss=[],si={};function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}function colRef(c,r){var s='',n=c+1;while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s+r;}var rx='<row r="1">';hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});rx+='</row>';rows.forEach(function(row,ri){var rn=ri+2;rx+='<row r="'+rn+'">';row.forEach(function(cell,c){var ref=colRef(c,rn);var num=c>=2&&cell!==''&&cell!=null&&!isNaN(Number(cell));rx+=num?'<c r="'+ref+'" s="2"><v>'+xe(cell)+'</v></c>':'<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';});rx+='</row>';});var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><sst xmlns="'+sns+'" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';var wsh='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'"><sheetViews><sheetView workbookViewId="0"/></sheetViews><sheetFormatPr defaultRowHeight="15"/><sheetData>'+rx+'</sheetData></worksheet>';var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><sz val="11"/><b/><name val="Calibri"/></font></fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="3"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0" applyAlignment="1"><alignment horizontal="right"/></xf></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>';var F={'[Content_Types].xml':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="'+pns+'content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/><Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/></Types>','_rels/.rels':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships"><Relationship Id="rId1" Type="'+ons+'relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>','xl/workbook.xml':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><sheets><sheet name="'+xe(sheet)+'" sheetId="1" r:id="rId1"/></sheets></workbook>','xl/_rels/workbook.xml.rels':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships"><Relationship Id="rId1" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="'+ons+'relationships/styles" Target="styles.xml"/><Relationship Id="rId3" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/></Relationships>','xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':wsh};var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml'];var zparts=[],zcds=[],zoff=0,znf=0;order.forEach(function(name){var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);var lha=[0x50,0x4B,0x03,0x04,0x14,0,0,0,0,0,0,0,0,0].concat(u4(cr)).concat(u4(sz)).concat(u4(sz)).concat(u2(nb.length)).concat([0,0]);var entry=new Uint8Array(lha.length+nb.length+sz);entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);zparts.push(entry);var cda=[0x50,0x4B,0x01,0x02,0x14,0,0x14,0,0,0,0,0,0,0,0,0].concat(u4(cr)).concat(u4(sz)).concat(u4(sz)).concat(u2(nb.length)).concat([0,0,0,0,0,0,0,0,0,0,0,0]).concat(u4(zoff));var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);zoff+=entry.length;znf++;});var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);var ea=[0x50,0x4B,0x05,0x06,0,0,0,0].concat(u2(znf)).concat(u2(znf)).concat(u4(cdSz)).concat(u4(zoff)).concat([0,0]);var tot=zoff+cdSz+ea.length,zout=new Uint8Array(tot),zpos=0;zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});zout.set(new Uint8Array(ea),zpos);slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');}
7133 function slocXlsMulti(fname,sheets){
7134 var enc=new TextEncoder();
7135 var CT=[];
7136 for(var _n=0;_n<256;_n++){var _c=_n;for(var _k=0;_k<8;_k++)_c=_c&1?0xEDB88320^(_c>>>1):_c>>>1;CT[_n]=_c;}
7137 function crc32(d){var v=0xFFFFFFFF;for(var i=0;i<d.length;i++)v=CT[(v^d[i])&0xFF]^(v>>>8);return(v^0xFFFFFFFF)>>>0;}
7138 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
7139 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
7140 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
7141 var ss=[],si={};
7142 function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
7143 function colRef(c,r){var s='',n=c+1;while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s+r;}
7144 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
7145 // Style indices: 0=normal 1=col-header(orange-fill/white-bold) 2=number(#,##0/right) 3=section(cream-fill/orange-bold) 4=bold-label 5=number(#,##0/left) 6=text(@)
7146 var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
7147 +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
7148 +'<fonts count="3">'
7149 +'<font><sz val="11"/><name val="Calibri"/></font>'
7150 +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
7151 +'<font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font>'
7152 +'</fonts>'
7153 +'<fills count="4">'
7154 +'<fill><patternFill patternType="none"/></fill>'
7155 +'<fill><patternFill patternType="gray125"/></fill>'
7156 +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
7157 +'<fill><patternFill patternType="solid"><fgColor rgb="FFFAF0E6"/><bgColor indexed="64"/></patternFill></fill>'
7158 +'</fills>'
7159 +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
7160 +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
7161 +'<cellXfs count="7">'
7162 +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
7163 +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
7164 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
7165 +'<xf numFmtId="0" fontId="2" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
7166 +'<xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
7167 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="left"/></xf>'
7168 +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
7169 +'</cellXfs>'
7170 +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
7171 +'</styleSheet>';
7172 var wsXmls=[],tableCounter=0,tableXmls={},wsRelsXmls={};
7173 function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
7174 sheets.forEach(function(sh,sheetIdx){
7175 var rx='<row r="1">';
7176 sh.hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
7177 rx+='</row>';
7178 var rn=2;
7179 sh.rows.forEach(function(row){
7180 if(!row||row.length===0){rx+='<row r="'+rn+'"/>';rn++;return;}
7181 if(row.length===1&&row[0]&&typeof row[0]==='object'&&row[0]._sec){
7182 rx+='<row r="'+rn+'">';
7183 rx+='<c r="'+colRef(0,rn)+'" t="s" s="3"><v>'+S(row[0].v)+'</v></c>';
7184 for(var ec=1;ec<sh.hdrs.length;ec++){rx+='<c r="'+colRef(ec,rn)+'" s="3"/>';}
7185 rx+='</row>';rn++;return;
7186 }
7187 rx+='<row r="'+rn+'">';
7188 row.forEach(function(cell,c){
7189 var ref=colRef(c,rn);
7190 if(cell===null||cell===undefined||cell===''){rx+='<c r="'+ref+'"/>';return;}
7191 if(typeof cell==='object'&&cell!==null){
7192 var cv=cell.v,cs=cell.s!=null?cell.s:0;
7193 if(typeof cv==='number'){rx+='<c r="'+ref+'" s="'+cs+'"><v>'+xe(cv)+'</v></c>';}
7194 else{rx+='<c r="'+ref+'" t="s" s="'+cs+'"><v>'+S(cv)+'</v></c>';}
7195 return;
7196 }
7197 if(typeof cell==='number'){rx+='<c r="'+ref+'" s="2"><v>'+xe(cell)+'</v></c>';return;}
7198 rx+='<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';
7199 });
7200 rx+='</row>';rn++;
7201 });
7202 var cw='';
7203 if(sh.colWidths&&sh.colWidths.length>0){
7204 cw='<cols>';
7205 sh.colWidths.forEach(function(w,i){cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';});
7206 cw+='</cols>';
7207 }
7208 var tblParts='';
7209 if(!sh.isKv&&sh.hdrs.length>0&&sh.rows.length>0){
7210 tableCounter++;
7211 var tc=tableCounter,colCount=sh.hdrs.length,rowCount=sh.rows.length+1;
7212 var tRef='A1:'+colNm(colCount)+rowCount;
7213 tableXmls['xl/tables/table'+tc+'.xml']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
7214 +'<table xmlns="'+sns+'" id="'+tc+'" name="Table'+tc+'" displayName="Table'+tc+'" ref="'+tRef+'" totalsRowShown="0">'
7215 +'<autoFilter ref="'+tRef+'"/>'
7216 +'<tableColumns count="'+colCount+'">'
7217 +sh.hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
7218 +'</tableColumns>'
7219 +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
7220 +'</table>';
7221 wsRelsXmls['xl/worksheets/_rels/sheet'+(sheetIdx+1)+'.xml.rels']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
7222 +'<Relationships xmlns="'+pns+'relationships">'
7223 +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table'+tc+'.xml"/>'
7224 +'</Relationships>';
7225 tblParts='<tableParts count="1"><tablePart r:id="rId1"/></tableParts>';
7226 }
7227 wsXmls.push('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
7228 +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
7229 +'<sheetFormatPr defaultRowHeight="15"/>'+cw+'<sheetData>'+rx+'</sheetData>'+tblParts+'</worksheet>');
7230 });
7231 var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><sst xmlns="'+sns+'" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
7232 var ctOver=sheets.map(function(_,i){return'<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}).join('');
7233 var ctTable=Object.keys(tableXmls).map(function(k){return'<Override PartName="/'+k+'" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';}).join('');
7234 var ctXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="'+pns+'content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'+ctOver+ctTable+'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/><Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/></Types>';
7235 var wbSh=sheets.map(function(sh,i){return'<sheet name="'+xe(sh.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}).join('');
7236 var wbXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><sheets>'+wbSh+'</sheets></workbook>';
7237 var wbR=sheets.map(function(_,i){return'<Relationship Id="rId'+(i+1)+'" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}).join('');
7238 wbR+='<Relationship Id="rId'+(sheets.length+1)+'" Type="'+ons+'relationships/styles" Target="styles.xml"/>'
7239 +'<Relationship Id="rId'+(sheets.length+2)+'" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/>';
7240 var wbRXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships">'+wbR+'</Relationships>';
7241 var F={'[Content_Types].xml':ctXml,'_rels/.rels':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships"><Relationship Id="rId1" Type="'+ons+'relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>','xl/workbook.xml':wbXml,'xl/_rels/workbook.xml.rels':wbRXml,'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml};
7242 var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml'];
7243 sheets.forEach(function(_,i){var k='xl/worksheets/sheet'+(i+1)+'.xml';F[k]=wsXmls[i];order.push(k);});
7244 Object.keys(wsRelsXmls).forEach(function(k){F[k]=wsRelsXmls[k];order.push(k);});
7245 Object.keys(tableXmls).forEach(function(k){F[k]=tableXmls[k];order.push(k);});
7246 var zparts=[],zcds=[],zoff=0,znf=0;
7247 order.forEach(function(name){var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);var lha=[0x50,0x4B,0x03,0x04,0x14,0,0,0,0,0,0,0,0,0].concat(u4(cr)).concat(u4(sz)).concat(u4(sz)).concat(u2(nb.length)).concat([0,0]);var entry=new Uint8Array(lha.length+nb.length+sz);entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);zparts.push(entry);var cda=[0x50,0x4B,0x01,0x02,0x14,0,0x14,0,0,0,0,0,0,0,0,0].concat(u4(cr)).concat(u4(sz)).concat(u4(sz)).concat(u2(nb.length)).concat([0,0,0,0,0,0,0,0,0,0,0,0]).concat(u4(zoff));var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);zoff+=entry.length;znf++;});
7248 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
7249 var ea=[0x50,0x4B,0x05,0x06,0,0,0,0].concat(u2(znf)).concat(u2(znf)).concat(u4(cdSz)).concat(u4(zoff)).concat([0,0]);
7250 var tot=zoff+cdSz+ea.length,zout=new Uint8Array(tot),zpos=0;
7251 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
7252 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
7253 zout.set(new Uint8Array(ea),zpos);
7254 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
7255 }
7256 window.resetPerFileTable = function() {
7257 var tbl = document.getElementById('per-file-table');
7258 if (!tbl) return;
7259 var shell = tbl.closest('.table-shell');
7260 if (shell) shell.scrollLeft = 0;
7261 Array.prototype.slice.call(tbl.querySelectorAll('th')).forEach(function(th) { th.style.width = ''; });
7262 Array.prototype.slice.call(tbl.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
7263 if (window._pfPaginationReset) window._pfPaginationReset();
7264 var si = document.getElementById('per-file-search');
7265 if (si) si.value = '';
7266 };
7267 var _rh=['File','Language','Physical Lines','Code Lines','Comments','Blank','Mixed Separate','Functions','Classes','Variables','Imports'];
7268 var _titleSlug="{{ title }}".replace(/[^a-zA-Z0-9\-]/g,'_').replace(/_+/g,'_').replace(/^_+|_+$/g,'');
7269 var _commitSlug="{% if let Some(c) = run.git_commit_short %}{{ c }}{% endif %}";
7270 var _exportSlug='per-file_'+_titleSlug+(_commitSlug?'_'+_commitSlug:'');
7271 function getReportExportRows(){var r=[];document.querySelectorAll('#per-file-table tbody tr').forEach(function(tr){var tds=tr.querySelectorAll('td');if(tds.length<11)return;r.push([tds[0].textContent.trim(),tds[1].textContent.trim(),tds[2].textContent.trim(),tds[3].textContent.trim(),tds[4].textContent.trim(),tds[5].textContent.trim(),tds[6].textContent.trim(),tds[7].textContent.trim(),tds[8].textContent.trim(),tds[9].textContent.trim(),tds[10].textContent.trim()]);});return r;}
7272 window.exportReportCsv=function(){slocCsv(_exportSlug+'.csv',_rh,getReportExportRows());};
7273 window.exportReportXls=function(){
7274 var fname='report_'+_titleSlug+(_commitSlug?'_'+_commitSlug:'')+'.xlsx';
7275 function sec(v){return[{_sec:true,v:v}];}
7276 function B(v){return{v:v,s:4};}
7277 function N(v){return{v:typeof v==='number'?v:Number(v),s:5};}
7278 // Table cells render with thousands separators (1,656,153) via the |commas
7279 // filter; Number() on that string is NaN, which would store the value as text
7280 // (green-triangle warning, left-aligned). Strip separators so numeric cells
7281 // become real numbers and align correctly. Non-numeric text is left untouched.
7282 function numify(v){var s=String(v==null?'':v).trim();if(s==='')return s;var t=s.replace(/,/g,'');return /^-?\d+(\.\d+)?$/.test(t)?Number(t):v;}
7283 function pnum(v){var t=String(v==null?'':v).replace(/,/g,'').trim();return /^-?\d+(\.\d+)?$/.test(t)?Number(t):0;}
7284 var dens=_SLOC_META.physicalLines>0?(_SLOC_META.codeLines/_SLOC_META.physicalLines*100).toFixed(1)+'%':'0%';
7285 var sumRows=[
7286 sec('RUN INFORMATION'),
7287 [B('Run ID'),_SLOC_META.runId,''],
7288 [B('Git Commit'),_SLOC_META.gitCommit,''],
7289 [B('Branch'),_SLOC_META.branch,''],
7290 [B('Last Commit By'),_SLOC_META.lastCommitBy,''],
7291 [B('Scan By'),_SLOC_META.scanBy,''],
7292 [B('Scanned'),_SLOC_META.scanned,''],
7293 [B('OS'),_SLOC_META.os,''],
7294 [B('Files Analyzed'),N(_SLOC_META.filesAnalyzed),'Total source files included in this analysis'],
7295 [B('Files Skipped'),N(_SLOC_META.filesSkipped),'Files excluded (binary, unsupported, or policy-filtered)'],
7296 [],
7297 sec('CODE METRICS'),
7298 [B('Physical Lines'),N(_SLOC_META.physicalLines),'Total lines including code, comments, and blanks'],
7299 [B('Code Lines'),N(_SLOC_META.codeLines),'Lines containing executable source code'],
7300 [B('Comments'),N(_SLOC_META.commentLines),'Lines consisting entirely of comments or documentation'],
7301 [B('Blank Lines'),N(_SLOC_META.blankLines),'Empty or whitespace-only lines'],
7302 [B('Mixed Separate'),N(_SLOC_META.mixedSeparate),'Lines with both code and trailing comment, counted separately'],
7303 [B('Functions'),N(_SLOC_META.functions),'Best-effort count of function/method definitions'],
7304 [B('Classes / Types'),N(_SLOC_META.classes),'Best-effort count of class, struct, interface definitions'],
7305 [B('Variables'),N(_SLOC_META.variables),'Best-effort count of variable and constant declarations'],
7306 [B('Imports'),N(_SLOC_META.imports),'Best-effort count of import, include, module-use statements'],
7307 [B('Tests'),N(_SLOC_META.tests),'Best-effort count of test cases (GTest, PyTest, JUnit, etc.)'],
7308 [B('Code Density'),{v:dens,s:6},'Percentage of physical lines that contain executable source code'],
7309 [B('Tool Version'),'oxide-sloc '+_SLOC_META.toolVersion,''],
7310 ];
7311 var langHdrs=['Language','Files','Physical Lines','Code Lines','Comments','Blank Lines','Mixed','Functions','Classes','Variables','Imports','Tests','Assertions','Suites'];
7312 var langRows=[];
7313 document.querySelectorAll('#lang-breakdown-table tbody tr').forEach(function(tr){
7314 var tds=tr.querySelectorAll('td');
7315 var row=[];
7316 Array.prototype.forEach.call(tds,function(td,i){var v=td.textContent.trim();row.push(i>0?numify(v):v);});
7317 langRows.push(row);
7318 });
7319 var pfHdrs=['File','Language','Physical Lines','Code Lines','Comments','Blank','Mixed','Functions','Classes','Variables','Imports','Tests','Assertions','Suites'];
7320 var pfRows=[];
7321 document.querySelectorAll('#per-file-table tbody tr').forEach(function(tr){
7322 var tds=tr.querySelectorAll('td');
7323 if(tds.length<11)return;
7324 var row=[];
7325 Array.prototype.forEach.call(tds,function(td,i){var v=td.textContent.trim();row.push(i>=2?numify(v):v);});
7326 pfRows.push(row);
7327 });
7328 var skHdrs=['File','Status','Warnings'];
7329 var skRows=[];
7330 document.querySelectorAll('#skipped-table tbody tr').forEach(function(tr){
7331 var tds=tr.querySelectorAll('td');
7332 if(tds.length<3)return;
7333 skRows.push([tds[0].textContent.trim(),tds[1].textContent.trim(),tds[2].textContent.trim()]);
7334 });
7335 var covHdrs=['Language','Files','Physical Lines','Code Lines','Code Density','Functions','Classes','Variables','Imports','Tests','Assertions','Test Suites'];
7336 var covRows=[];
7337 document.querySelectorAll('#lang-breakdown-table tbody tr').forEach(function(tr){
7338 var tds=tr.querySelectorAll('td');
7339 if(tds.length<4)return;
7340 var phys=pnum(tds[2].textContent);
7341 var code=pnum(tds[3].textContent);
7342 var densStr=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
7343 var row=[tds[0].textContent.trim(),pnum(tds[1].textContent),phys,code,{v:densStr,s:6}];
7344 for(var i=7;i<Math.min(tds.length,14);i++){row.push(numify(tds[i].textContent.trim()));}
7345 covRows.push(row);
7346 });
7347 slocXlsMulti(fname,[
7348 {name:'Summary',hdrs:['Field / Metric','Value','Description'],rows:sumRows,colWidths:[22,45,55],isKv:true},
7349 {name:'Language Breakdown',hdrs:langHdrs,rows:langRows,colWidths:[16,8,14,12,12,12,8,10,10,10,10,8,10,8]},
7350 {name:'Per-File Detail',hdrs:pfHdrs,rows:pfRows,colWidths:[50,12,12,12,12,10,8,10,10,10,10,8,10,8]},
7351 {name:'Code Coverage',hdrs:covHdrs,rows:covRows,colWidths:[18,7,14,12,13,11,10,10,10,8,11,12]},
7352 {name:'Skipped Files',hdrs:skHdrs,rows:skRows,colWidths:[60,25,50]}
7353 ]);
7354 };
7355 Array.prototype.slice.call(document.querySelectorAll('[data-export-csv]')).forEach(function(btn){btn.addEventListener('click',function(){slocCsv(_exportSlug+'.csv',_rh,getReportExportRows());});});
7356 Array.prototype.slice.call(document.querySelectorAll('[data-export-xls]')).forEach(function(btn){btn.addEventListener('click',window.exportReportXls);});
7357 Array.prototype.slice.call(document.querySelectorAll('[data-reset-table]')).forEach(function(btn){btn.addEventListener('click',window.resetPerFileTable);});
7358 var _skippedRh=['File','Status','Warnings'];
7359 var _skippedSlug='skipped_'+_titleSlug+(_commitSlug?'_'+_commitSlug:'');
7360 function getSkippedExportRows(){var r=[];document.querySelectorAll('#skipped-table tbody tr').forEach(function(tr){var tds=tr.querySelectorAll('td');if(tds.length<3)return;r.push([tds[0].textContent.trim(),tds[1].textContent.trim(),tds[2].textContent.trim()]);});return r;}
7361 (function(){var b=document.getElementById('skipped-export-csv');if(b)b.addEventListener('click',function(){slocCsv(_skippedSlug+'.csv',_skippedRh,getSkippedExportRows());});})();
7362 (function(){var b=document.getElementById('skipped-export-xls');if(b)b.addEventListener('click',function(){slocXls(_skippedSlug+'.xlsx','Skipped Files',_skippedRh,getSkippedExportRows());});})();
7363 // ── Chart.js initialization ───────────────────────────────────────────────
7364 // Deferred so the browser can repaint (dismiss the loading overlay) before
7365 // the canvas/SVG chart work blocks the main thread.
7366 requestAnimationFrame(function() {
7367 try {
7368 (function() {
7369 var D = {{ lang_chart_json|safe }};
7370 var SUB_D = {{ submodule_chart_json|safe }};
7371 var SCAT_D = {{ scatter_chart_json|safe }};
7372 var SEM_D = {{ semantic_chart_json|safe }};
7373 var HIST_D = {{ file_size_histogram_json|safe }};
7374 if (!D || !D.length) return;
7375
7376 var PALETTE = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030',
7377 '#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082',
7378 '#D0743C','#5BA8A0','#8B3A8B','#3D7A3D','#AA5500','#005599'];
7379 var OX = '#C45C10', GN = '#2A6846', GY = '#BBBBBB';
7380 var ALL_CHARTS = [];
7381 function hexAlpha(hex, a) {
7382 var r=parseInt(hex.slice(1,3),16),g=parseInt(hex.slice(3,5),16),b=parseInt(hex.slice(5,7),16);
7383 return 'rgba('+r+','+g+','+b+','+a+')';
7384 }
7385
7386 function fmt(n) {
7387 var v = Number(n), a = Math.abs(v);
7388 if (a >= 1e6) return (v/1e6).toFixed(1).replace(/\.0$/,'') + 'M';
7389 if (a >= 1e4) return Math.round(v/1e3) + 'K';
7390 return v.toLocaleString();
7391 }
7392 function isDark() { return document.body.classList.contains('dark-theme'); }
7393 function clr() {
7394 return isDark()
7395 ? { text: '#d4c5b8', grid: 'rgba(255,255,255,0.10)' }
7396 : { text: '#43342d', grid: '#e6d0bf' };
7397 }
7398 // Legend-highlight alpha for a dataset's drawn label / marker. `chart.$hiDs` is
7399 // set by legend hover (see attachScatterLegend); when it is null every dataset
7400 // draws at full strength. Once a language is hovered, the others fade so the
7401 // hovered one's marker, name and number stay readable through overlapping
7402 // neighbours. Applied globally to every value-labelled Chart.js plot.
7403 function hiAlpha(chart, di) {
7404 var h = chart.$hiDs;
7405 if (h == null) return 1;
7406 return di === h ? 1 : 0.1;
7407 }
7408 // Inline Chart.js plugin: draws a permanent value label on each bar / bubble.
7409 // fmtFn(rawValue, datasetIndex, pointIndex) → string | null
7410 // anchor: 'top' = above vertical bar, 'end' = right of horizontal bar, 'bubble' = above bubble
7411 function makeDlPlugin(fmtFn, anchor) {
7412 return {
7413 afterDatasetsDraw: function(chart) {
7414 var ctx = chart.ctx;
7415 var tc = clr().text;
7416 chart.data.datasets.forEach(function(ds, di) {
7417 var meta = chart.getDatasetMeta(di);
7418 meta.data.forEach(function(el, idx) {
7419 var label = fmtFn(ds.data[idx], di, idx);
7420 if (label == null || label === '') return;
7421 ctx.save();
7422 ctx.globalAlpha = hiAlpha(chart, di);
7423 ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
7424 ctx.fillStyle = tc;
7425 if (anchor === 'top') {
7426 ctx.textAlign = 'center';
7427 ctx.textBaseline = 'bottom';
7428 ctx.fillText(String(label), el.x, el.y - 3);
7429 } else if (anchor === 'end') {
7430 ctx.textAlign = 'left';
7431 ctx.textBaseline = 'middle';
7432 ctx.fillText(String(label), el.x + 5, el.y);
7433 } else {
7434 ctx.textAlign = 'center';
7435 ctx.textBaseline = 'bottom';
7436 var r = (el.options && el.options.radius) ? el.options.radius : 10;
7437 ctx.fillText(String(label), el.x, el.y - r - 3);
7438 }
7439 ctx.restore();
7440 });
7441 });
7442 }
7443 };
7444 }
7445 // Bubble-chart value labels (language name + code lines above each bubble).
7446 // Shared by the dashboard card and the Full View modal. Honours the legend
7447 // highlight: non-hovered languages' labels fade and the hovered one is drawn
7448 // last so its name + number sit on top of any overlapping neighbours — the
7449 // clustered bubbles at the origin are otherwise an unreadable pile of text.
7450 function scatterLabelPlugin() {
7451 return { afterDatasetsDraw: function(chart) {
7452 var ctx = chart.ctx, tc = clr().text, hi = chart.$hiDs;
7453 function drawOne(di) {
7454 var d = SCAT_D[di]; if (!d) return;
7455 var meta = chart.getDatasetMeta(di), a = hiAlpha(chart, di);
7456 meta.data.forEach(function(el) {
7457 var r = (el.options && el.options.radius) ? el.options.radius : 10;
7458 var ty2 = Math.max(14, el.y - r - 3), ty1 = Math.max(1, ty2 - 14);
7459 ctx.save();
7460 ctx.globalAlpha = a; ctx.fillStyle = tc;
7461 ctx.textBaseline = 'bottom'; ctx.textAlign = 'center';
7462 ctx.font = '800 11px Inter,ui-sans-serif,sans-serif';
7463 ctx.fillText(d.lang, el.x, ty1);
7464 ctx.font = '700 10px Inter,ui-sans-serif,sans-serif';
7465 ctx.fillText(fmt(d.code), el.x, ty2);
7466 ctx.restore();
7467 });
7468 }
7469 chart.data.datasets.forEach(function(_, di) { if (hi == null || di !== hi) drawOne(di); });
7470 if (hi != null && hi >= 0) drawOne(hi);
7471 } };
7472 }
7473 function makeStackedEndPlugin(fmtFn) {
7474 return {
7475 afterDatasetsDraw: function(chart) {
7476 var ctx = chart.ctx;
7477 var tc = clr().text;
7478 var nDs = chart.data.datasets.length;
7479 if (nDs === 0) return;
7480 var lastMeta = chart.getDatasetMeta(nDs - 1);
7481 lastMeta.data.forEach(function(el, idx) {
7482 var total = 0;
7483 chart.data.datasets.forEach(function(ds) { total += ds.data[idx] || 0; });
7484 var label = fmtFn(total, idx);
7485 if (label == null || label === '') return;
7486 ctx.save();
7487 ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
7488 ctx.fillStyle = tc;
7489 ctx.textAlign = 'left';
7490 ctx.textBaseline = 'middle';
7491 ctx.fillText(String(label), el.x + 5, el.y);
7492 ctx.restore();
7493 });
7494 }
7495 };
7496 }
7497
7498 function wireDonutLegend(svg) {
7499 if(!svg) return;
7500 // Every donut element carries data-lang: slices (path/circle), leader lines,
7501 // outside labels + % labels (text) and legend rows (g). Hovering any one of
7502 // them emphasises that language across all of them and fades the rest, so the
7503 // slice, its leader line, its label and its legend row move as one picture.
7504 var items=svg.querySelectorAll('[data-lang]');
7505 function emph(el,st){ // st: 1 = highlight, -1 = fade, 0 = reset
7506 var tag=el.tagName.toLowerCase();
7507 if(tag==='path'||tag==='circle'){
7508 if(st===1){el.style.opacity='1';el.style.filter='brightness(1.15) drop-shadow(0 3px 9px rgba(0,0,0,.28))';el.style.transform='scale(1.06)';}
7509 else if(st===-1){el.style.opacity='0.24';el.style.filter='none';el.style.transform='none';}
7510 else{el.style.opacity='';el.style.filter='';el.style.transform='';}
7511 }else if(tag==='line'){
7512 if(st===1){el.style.opacity='1';el.style.strokeWidth='1.8';}
7513 else if(st===-1){el.style.opacity='0.1';el.style.strokeWidth='';}
7514 else{el.style.opacity='';el.style.strokeWidth='';}
7515 }else if(tag==='text'){
7516 if(st===1){el.style.opacity='1';el.style.fontWeight='800';}
7517 else if(st===-1){el.style.opacity='0.18';el.style.fontWeight='';}
7518 else{el.style.opacity='';el.style.fontWeight='';}
7519 }else{ // legend group
7520 if(st===1){el.style.opacity='1';}
7521 else if(st===-1){el.style.opacity='0.4';}
7522 else{el.style.opacity='';}
7523 }
7524 }
7525 function hl(lang){for(var i=0;i<items.length;i++){emph(items[i],items[i].getAttribute('data-lang')===lang?1:-1);}}
7526 function rst(){for(var i=0;i<items.length;i++){emph(items[i],0);}}
7527 svg.addEventListener('mouseover',function(e){var t=e.target;while(t&&t!==svg){var l=t.getAttribute&&t.getAttribute('data-lang');if(l){hl(l);return;}t=t.parentNode;}rst();});
7528 svg.addEventListener('mousemove',function(e){var t=e.target;while(t&&t!==svg){if(t.getAttribute&&t.getAttribute('data-lang'))return;t=t.parentNode;}rst();});
7529 svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
7530 }
7531 function wireMixLegend(svg) {
7532 if(!svg) return;
7533 var legGs=svg.querySelectorAll('g[data-kind]');
7534 var allRects=svg.querySelectorAll('rect[data-kind]');
7535 if(!legGs.length) return;
7536 function hlKind(kind) {
7537 for(var i=0;i<allRects.length;i++){var r=allRects[i];if(r.getAttribute('data-kind')===kind){r.style.opacity='1';r.style.filter='brightness(1.18) drop-shadow(0 2px 6px rgba(0,0,0,.22))';}else{r.style.opacity='0.18';r.style.filter='none';}}
7538 for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-kind')===kind?'1':'0.45';}
7539 }
7540 function rst(){for(var i=0;i<allRects.length;i++){allRects[i].style.opacity='';allRects[i].style.filter='';}for(var j=0;j<legGs.length;j++){legGs[j].style.opacity='';}}
7541 for(var k=0;k<legGs.length;k++){(function(g){g.addEventListener('mouseenter',function(){hlKind(g.getAttribute('data-kind'));});g.addEventListener('mouseleave',rst);})(legGs[k]);}
7542 }
7543
7544 // ── Language overview: SVG donut + horizontal stacked bars ───────────────
7545 (function() {
7546 var el = document.getElementById('report-lang-overview');
7547 if (!el || !D || !D.length) return;
7548 var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
7549 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
7550 function px(n){return Math.round(n);}
7551 function tt(label,val){return ' class="rchit" data-ttl="'+String(label).replace(/&/g,'&').replace(/"/g,'"')+'" data-ttv="'+String(val).replace(/&/g,'&').replace(/"/g,'"')+'"';}
7552 var tot = D.reduce(function(a,d){return a+d.code;},0)||1;
7553 // Donut — height matches the stacked-bar chart so both panels align
7554 var rHb_d=28;
7555 var DH=Math.max(220,D.length*rHb_d+32);
7556 var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48,legX=208,DW=395;
7557 var legCount=D.length;
7558 var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
7559 var legYStart=Math.round((DH-legCount*legSpacing)/2);
7560 var ds='<svg id="dnt-svg" viewBox="0 0 '+DW+' '+DH+'" width="'+DW+'" height="'+DH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
7561 // One shared transition on every donut element so slices, leader lines,
7562 // outside labels, % labels and the legend all animate together as a single
7563 // picture when a language is hovered. Slices scale from the donut centre.
7564 ds+='<style>#dnt-svg path,#dnt-svg circle,#dnt-svg line,#dnt-svg text,#dnt-svg g{transition:opacity .22s ease,filter .22s ease,transform .22s ease,stroke-width .22s ease;}#dnt-svg path,#dnt-svg circle{transform-origin:'+cx+'px '+cy+'px;}</style>';
7565 if(D.length===1){
7566 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
7567 ds+='<circle'+tt(D[0].lang,fmt(D[0].code)+' code lines')+' data-lang="'+esc(D[0].lang)+'" cx="'+cx+'" cy="'+cy+'" r="'+rm+'" fill="none" stroke="'+PALETTE[0]+'" stroke-width="'+rsw+'"/>';
7568 } else {
7569 var smalls=[];
7570 var ang=-Math.PI/2;
7571 D.forEach(function(d,i){
7572 var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
7573 var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
7574 var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
7575 var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
7576 var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
7577 var pct=Math.round(d.code/tot*100);
7578 ds+='<path'+tt(d.lang,fmt(d.code)+' code lines ('+pct+'%)')+' data-lang="'+esc(d.lang)+'" d="M'+px(x1)+','+px(y1)+' A'+Ro+','+Ro+' 0 '+(sw>Math.PI?1:0)+',1 '+px(x2)+','+px(y2)+' L'+px(xi1)+','+px(yi1)+' A'+Ri+','+Ri+' 0 '+(sw>Math.PI?1:0)+',0 '+px(xi2)+','+px(yi2)+' Z" fill="'+(PALETTE[i%PALETTE.length])+'" stroke="white" stroke-width="2"/>';
7579 if(pct>=5){var mAng=ang+sw/2,mR=(Ro+Ri)/2;ds+='<text data-lang="'+esc(d.lang)+'" x="'+px(cx+mR*Math.cos(mAng))+'" y="'+px(cy+mR*Math.sin(mAng))+'" text-anchor="middle" dominant-baseline="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="white" style="pointer-events:none;">'+pct+'%</text>';}else if(pct>0){smalls.push({mAng:ang+sw/2,pct:pct,lang:d.lang,col:PALETTE[i%PALETTE.length]});}
7580 ang+=sw;
7581 });
7582 // Small slices (<5%) get outside labels positioned near each slice's own
7583 // angular position (a slice on the left gets its label/leader on the left),
7584 // then nudged apart horizontally so text never overlaps. Leader lines point
7585 // from each slice to its label. Horizontal text keeps long names legible;
7586 // the whole SVG scales up in Full View so these stay readable there too.
7587 if(smalls.length){
7588 smalls.sort(function(a,b){return a.mAng-b.mAng;});
7589 var sPad=6,sRowY=11;
7590 smalls.forEach(function(sm){sm.txt=sm.lang+' '+sm.pct+'%';sm.w=sm.txt.length*5+8;sm.x=Math.max(sPad+sm.w/2,Math.min(DW-sPad-sm.w/2,cx+(Ro+14)*Math.cos(sm.mAng)));});
7591 for(var si=1;si<smalls.length;si++){var mnX=smalls[si-1].x+smalls[si-1].w/2+smalls[si].w/2+3;if(smalls[si].x<mnX)smalls[si].x=mnX;}
7592 var sLast=smalls[smalls.length-1],sOver=sLast.x+sLast.w/2-(DW-sPad);
7593 if(sOver>0)smalls.forEach(function(sm){sm.x-=sOver;});
7594 smalls.forEach(function(sm){
7595 var axx=cx+Ro*Math.cos(sm.mAng),ayy=cy+Ro*Math.sin(sm.mAng);
7596 ds+='<line data-lang="'+esc(sm.lang)+'" x1="'+px(axx)+'" y1="'+px(ayy)+'" x2="'+px(sm.x)+'" y2="'+px(sRowY+4)+'" stroke="'+sm.col+'" stroke-width="1" opacity="0.5" style="pointer-events:none;"/>';
7597 ds+='<text data-lang="'+esc(sm.lang)+'" x="'+px(sm.x)+'" y="'+px(sRowY)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" font-weight="700" fill="'+sm.col+'" style="cursor:pointer;">'+esc(sm.txt)+'</text>';
7598 });
7599 }
7600 }
7601 ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
7602 ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
7603 D.forEach(function(d,i){
7604 var ly=legYStart+i*legSpacing;
7605 var pctL=Math.round(d.code/tot*100);
7606 var ttL=String(d.lang).replace(/&/g,'&').replace(/"/g,'"');
7607 var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&').replace(/"/g,'"');
7608 ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
7609 ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
7610 ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(PALETTE[i%PALETTE.length])+'"/>';
7611 ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
7612 ds+='<text x="'+(legX+100)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(10,legSpacing-3)+'" font-weight="700" fill="#7b675b">'+fmt(d.code)+' ('+pctL+'%)</text>';
7613 ds+='</g>';
7614 });
7615 ds+='</svg>';
7616 // Horizontal stacked-bar chart
7617 var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
7618 var LW=108,BW=260,svgW=LW+BW+68;
7619 var barRhb=Math.min(48,Math.max(28,Math.floor((DH-32)/D.length)));
7620 var barBH=Math.min(32,Math.round(barRhb*0.7));
7621 var SH=DH;
7622 var barTopPad=Math.max(6,Math.round((SH-D.length*barRhb-18)/2));
7623 var bs='<svg viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
7624 // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if
7625 // it cannot fit legibly even at the 6.5 floor (labels shrink to fit instead
7626 // of disappearing; the SVG scales up in Full View so small fonts stay legible).
7627 function fitFs(t,w){var fs=Math.min(10,(w-4)/((String(t).length||1)*0.58));return fs>=6.5?Math.round(fs*10)/10:0;}
7628 D.forEach(function(d,i){
7629 var y=barTopPad+i*barRhb,x=LW;
7630 var phys=d.physical||d.code+d.comments+d.blanks;
7631 var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
7632 var lmid=y+barBH/2+4;
7633 var ttv='Code: '+fmt(d.code)+'\nComments: '+fmt(d.comments)+'\nBlank: '+fmt(d.blanks)+'\nTotal: '+fmt(phys);
7634 bs+='<g class="lang-bar-row">';
7635 // Hit area ends just past the total label so empty space to the right of the
7636 // bar does not trigger the tooltip — only the name, bar and total are hot.
7637 var hitW=px(LW+phys/maxT*BW+8+(String(fmt(phys)).length*6.8)+6);
7638 bs+='<rect'+tt(d.lang,ttv)+' x="0" y="'+y+'" width="'+hitW+'" height="'+barBH+'" fill="transparent" style="cursor:pointer;"/>';
7639 bs+='<text'+tt(d.lang,ttv)+' x="'+(LW-6)+'" y="'+lmid+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="#43342d" style="cursor:pointer;">'+esc(d.lang)+'</text>';
7640 if(cW>0.5){bs+='<rect'+tt(d.lang+' Code',fmt(d.code)+' lines')+' data-kind="code" x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+barBH+'" fill="'+OX+'"/>';var _fc=fitFs(fmt(d.code),cW);if(_fc)bs+='<text x="'+px(x+cW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fc+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.code)+'</text>';x+=cW;}
7641 if(cmW>0.5){bs+='<rect'+tt(d.lang+' Comments',fmt(d.comments)+' lines')+' data-kind="comment" x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+barBH+'" fill="'+GN+'"/>';var _fm=fitFs(fmt(d.comments),cmW);if(_fm)bs+='<text x="'+px(x+cmW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fm+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.comments)+'</text>';x+=cmW;}
7642 if(blW>0.5){bs+='<rect'+tt(d.lang+' Blank',fmt(d.blanks)+' lines')+' data-kind="blank" x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+barBH+'" fill="'+GY+'"/>';var _fb=fitFs(fmt(d.blanks),blW);if(_fb)bs+='<text x="'+px(x+blW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fb+'" font-weight="700" fill="#555" style="pointer-events:none;">'+fmt(d.blanks)+'</text>';}
7643 bs+='<text'+tt(d.lang,ttv)+' x="'+px(LW+phys/maxT*BW+8)+'" y="'+lmid+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="#7b675b" style="cursor:pointer;">'+fmt(phys)+'</text>';
7644 bs+='</g>';
7645 });
7646 var ly=SH-14;
7647 var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
7648 var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
7649 var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
7650 var totAll=totC+totCm+totBl||1;
7651 function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
7652 var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
7653 var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
7654 var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
7655 var legSt=LW+Math.max(0,Math.round((BW-194)/2));
7656 bs+='<g data-kind="code" style="cursor:pointer;">'
7657 +'<rect x="'+legSt+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC+'/>'
7658 +'<rect x="'+legSt+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
7659 +'<text x="'+(legSt+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
7660 +'</g>';
7661 bs+='<g data-kind="comment" style="cursor:pointer;">'
7662 +'<rect x="'+(legSt+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm+'/>'
7663 +'<rect x="'+(legSt+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
7664 +'<text x="'+(legSt+71)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
7665 +'</g>';
7666 bs+='<g data-kind="blank" style="cursor:pointer;">'
7667 +'<rect x="'+(legSt+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
7668 +'<rect x="'+(legSt+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
7669 +'<text x="'+(legSt+158)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
7670 +'</g>';
7671 bs+='</svg>';
7672 el.innerHTML='<div class="r-lang-overview">'+
7673 '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
7674 '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
7675 '</div>';
7676 wireDonutLegend(el.querySelector('svg'));
7677 wireMixLegend(el.querySelectorAll('svg')[1]);
7678 })();
7679
7680 // Shared cursor helper: pointer over data elements, default elsewhere.
7681 // Added to options.onHover on every Chart.js instance.
7682 // Legend items handled separately via legend.onHover / legend.onLeave.
7683 function chartCursor(e, els) {
7684 var t = e.native && e.native.target;
7685 if (t) t.style.cursor = els.length ? 'pointer' : 'default';
7686 }
7687 function legendCursorOn(e) { var t=e.native&&e.native.target; if(t)t.style.cursor='pointer'; }
7688 function legendCursorOff(e){ var t=e.native&&e.native.target; if(t)t.style.cursor='default'; }
7689 // Pushes a right-positioned legend away from the plot by `gap` px. Chart.js
7690 // (v4) places a right legend flush against the plot area: fit() reserves the
7691 // legend box width and _draw() lays items out from `this.left + padding`, so
7692 // the column hugs the bubbles. We reserve `gap` extra width in fit() (which
7693 // shrinks the plot by `gap`), then translate the canvas right by `gap` while
7694 // the legend draws so the column lands in that reserved space — clear of the
7695 // plot. The legendHitBoxes (used only for hover hit-testing, not drawing) are
7696 // shifted by the same `gap` so hover targets stay aligned with what's drawn.
7697 function legendGapPlugin(gap) {
7698 return {
7699 id: 'legendGap',
7700 beforeInit: function(chart) {
7701 var lg = chart.legend; if (!lg) return;
7702 var origFit = lg.fit, origDraw = lg.draw;
7703 lg.fit = function() { origFit.call(this); this.width += gap; this._needGap = true; };
7704 lg.draw = function() {
7705 if (this._needGap && this.legendHitBoxes) {
7706 this.legendHitBoxes.forEach(function(h){ h.left += gap; });
7707 this._needGap = false;
7708 }
7709 var ctx = this.ctx;
7710 ctx.save();
7711 ctx.translate(gap, 0);
7712 origDraw.call(this);
7713 ctx.restore();
7714 };
7715 }
7716 };
7717 }
7718
7719 // ── Project Overview bar ─────────────────────────────────────────────────
7720 var projChart = null;
7721 (function() {
7722 var ySel = document.getElementById('overview-y-axis');
7723 var xSel = document.getElementById('overview-x-mode');
7724 var el = document.getElementById('overview-chart');
7725 var lockedEl = document.getElementById('overview-chart-locked');
7726 var wrap = document.getElementById('canvas-proj-wrap');
7727 var canvas = document.getElementById('canvas-proj');
7728 if (!canvas || !ySel || !xSel) return;
7729 var Y_LABELS = { code:'Code Lines', comments:'Comment Lines', blanks:'Blank Lines',
7730 physical:'Physical Lines', files:'Files', comment:'Comment Lines', blank:'Blank Lines' };
7731 function getData() {
7732 var yKey = ySel.value, mode = xSel.value;
7733 var src = mode === 'submodules' ? SUB_D : D;
7734 var lKey = mode === 'submodules' ? 'name' : 'lang';
7735 var sorted = src.slice().sort(function(a,b){ return (b[yKey]||0)-(a[yKey]||0); });
7736 return { sorted: sorted, lKey: lKey, yKey: yKey, yLabel: Y_LABELS[yKey]||yKey };
7737 }
7738 function renderOverview() {
7739 var mode = xSel.value, isHist = mode.indexOf('history') === 0;
7740 if (el) el.style.display = isHist ? 'none' : 'block';
7741 if (lockedEl) lockedEl.style.display = isHist ? 'block' : 'none';
7742 if (isHist) return;
7743 var r = getData();
7744 var c = clr();
7745 if (wrap) wrap.style.height = Math.max(200, Math.min(432, r.sorted.length * 29 + 60)) + 'px';
7746 if (projChart) {
7747 projChart.data.labels = r.sorted.map(function(d){return d[r.lKey];});
7748 projChart.data.datasets[0].data = r.sorted.map(function(d){return d[r.yKey]||0;});
7749 projChart.data.datasets[0].backgroundColor = r.sorted.map(function(_,i){return PALETTE[i%PALETTE.length];});
7750 projChart.data.datasets[0].label = r.yLabel;
7751 projChart.options.scales.x.title.text = r.yLabel;
7752 projChart.update('none'); return;
7753 }
7754 projChart = new Chart(canvas, {
7755 type: 'bar',
7756 data: {
7757 labels: r.sorted.map(function(d){return d[r.lKey];}),
7758 datasets: [{ label: r.yLabel,
7759 data: r.sorted.map(function(d){return d[r.yKey]||0;}),
7760 backgroundColor: r.sorted.map(function(_,i){return PALETTE[i%PALETTE.length];}),
7761 borderRadius: 3 }]
7762 },
7763 options: {
7764 indexAxis: 'y', responsive: true, maintainAspectRatio: false,
7765 onHover: chartCursor,
7766 animation: { duration: 500, easing: 'easeOutQuart' },
7767 layout: { padding: { right: 64 } },
7768 scales: {
7769 x: { grid: { color: c.grid }, ticks: { color: c.text, callback: function(v){return fmt(v);} },
7770 title: { display: true, text: r.yLabel, color: c.text } },
7771 y: { grid: { display: false }, ticks: { color: c.text } }
7772 },
7773 plugins: {
7774 legend: { display: false },
7775 tooltip: {
7776 callbacks: {
7777 title: function(items) { return items.length ? items[0].label : ''; },
7778 label: function(ctx) {
7779 return ' ' + ctx.dataset.label + ': ' + Number(ctx.parsed.x).toLocaleString();
7780 }
7781 }
7782 }
7783 }
7784 },
7785 plugins: [makeDlPlugin(function(v){ return fmt(v||0); }, 'end')]
7786 });
7787 ALL_CHARTS.push(projChart);
7788 }
7789 ySel.addEventListener('change', renderOverview);
7790 xSel.addEventListener('change', renderOverview);
7791 renderOverview();
7792
7793 var overviewExpandBtn = document.getElementById('overview-expand-btn');
7794 if (overviewExpandBtn) {
7795 overviewExpandBtn.addEventListener('click', function() {
7796 var r = getData();
7797 var n = r.sorted.length || 1;
7798 var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
7799 var modalH = Math.min(Math.max(480, n * 29 + 96), maxH);
7800 var overlay = document.createElement('div');
7801 overlay.className = 'chart-modal-overlay';
7802 overlay.innerHTML = '<div class="chart-modal" style="max-width:1320px;">'
7803 + '<button class="chart-modal-close" aria-label="Close">×</button>'
7804 + '<div class="chart-modal-header">'
7805 + '<span class="chart-modal-title">Project Overview \u2014 Full View</span>'
7806 + '<label style="font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:6px;flex-shrink:0;">Y Axis:'
7807 + '<select id="ov-modal-y" class="chart-select">'
7808 + '<option value="code">Code Lines</option>'
7809 + '<option value="comments">Comment Lines</option>'
7810 + '<option value="blanks">Blank Lines</option>'
7811 + '<option value="physical">Total Physical Lines</option>'
7812 + '<option value="files">File Count</option>'
7813 + '</select></label>'
7814 + (SUB_D && SUB_D.length ? '<label style="font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:6px;">X Axis:'
7815 + '<select id="ov-modal-x" class="chart-select">'
7816 + '<option value="languages">Languages</option>'
7817 + '<option value="submodules">Submodules</option>'
7818 + '</select></label>' : '')
7819 + '</div>'
7820 + '<div style="position:relative;height:' + modalH + 'px;width:100%;"><canvas id="canvas-proj-modal"></canvas></div></div>';
7821 document.body.appendChild(overlay);
7822 overlay.querySelector('.chart-modal-close').addEventListener('click', function() { document.body.removeChild(overlay); });
7823 overlay.addEventListener('click', function(e) { if (e.target === overlay) document.body.removeChild(overlay); });
7824 var Y_LABELS = { code:'Code Lines', comments:'Comment Lines', blanks:'Blank Lines', physical:'Physical Lines', files:'Files' };
7825 var modalYSel = document.getElementById('ov-modal-y');
7826 var modalXSel = document.getElementById('ov-modal-x');
7827 if (modalYSel) modalYSel.value = ySel ? ySel.value : 'code';
7828 if (modalXSel && xSel) modalXSel.value = (xSel.value === 'languages' || xSel.value === 'submodules') ? xSel.value : 'languages';
7829 var modalCanvas = document.getElementById('canvas-proj-modal');
7830 if (!modalCanvas) return;
7831 var c = clr();
7832 function getModalData() {
7833 var yKey = modalYSel ? modalYSel.value : 'code';
7834 var mode = modalXSel ? modalXSel.value : 'languages';
7835 var src = mode === 'submodules' ? SUB_D : D;
7836 var lKey = mode === 'submodules' ? 'name' : 'lang';
7837 var sorted = src.slice().sort(function(a,b){ return (b[yKey]||0)-(a[yKey]||0); });
7838 return { sorted: sorted, lKey: lKey, yKey: yKey, yLabel: Y_LABELS[yKey]||yKey };
7839 }
7840 var ovModalChart = null;
7841 function renderOverviewModal() {
7842 var r2 = getModalData();
7843 if (ovModalChart) {
7844 ovModalChart.data.labels = r2.sorted.map(function(d){return d[r2.lKey];});
7845 ovModalChart.data.datasets[0].data = r2.sorted.map(function(d){return d[r2.yKey]||0;});
7846 ovModalChart.data.datasets[0].backgroundColor = r2.sorted.map(function(_,i){return PALETTE[i%PALETTE.length];});
7847 ovModalChart.data.datasets[0].label = r2.yLabel;
7848 ovModalChart.options.scales.x.title.text = r2.yLabel;
7849 ovModalChart.update('none'); return;
7850 }
7851 ovModalChart = new Chart(modalCanvas, {
7852 type: 'bar',
7853 data: {
7854 labels: r2.sorted.map(function(d){return d[r2.lKey];}),
7855 datasets: [{ label: r2.yLabel,
7856 data: r2.sorted.map(function(d){return d[r2.yKey]||0;}),
7857 backgroundColor: r2.sorted.map(function(_,i){return PALETTE[i%PALETTE.length];}),
7858 borderRadius: 3 }]
7859 },
7860 options: {
7861 indexAxis: 'y', responsive: true, maintainAspectRatio: false,
7862 onHover: chartCursor,
7863 animation: { duration: 500, easing: 'easeOutQuart' },
7864 layout: { padding: { right: 64 } },
7865 scales: {
7866 x: { grid:{color:c.grid}, ticks:{color:c.text, callback:function(v){return fmt(v);}},
7867 title:{display:true, text:r2.yLabel, color:c.text} },
7868 y: { grid:{display:false}, ticks:{color:c.text} }
7869 },
7870 plugins: {
7871 legend:{display:false},
7872 tooltip:{callbacks:{
7873 title:function(items){return items.length?items[0].label:'';},
7874 label:function(ctx){return ' '+ctx.dataset.label+': '+Number(ctx.parsed.x).toLocaleString();}
7875 }}
7876 }
7877 },
7878 plugins: [makeDlPlugin(function(v){ return fmt(v||0); }, 'end')]
7879 });
7880 }
7881 renderOverviewModal();
7882 if (modalYSel) modalYSel.addEventListener('change', renderOverviewModal);
7883 if (modalXSel) modalXSel.addEventListener('change', renderOverviewModal);
7884 });
7885 }
7886 })();
7887
7888 // ── Language Composition (SVG — matches /runs/result behaviour) ──────────
7889 (function() {
7890 var el = document.getElementById('comp-svg-container');
7891 if (!el || !D || !D.length) return;
7892 var cData = D.slice(0, 15);
7893 var cMode = 'absolute';
7894 var CX = OX, CG = GN, CB = '#BBBBBB';
7895 var CFONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
7896 function cEsc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
7897 function cPx(n){return Math.round(n);}
7898 function cTT(l,v){return ' class="rchit" data-ttl="'+String(l).replace(/&/g,'&').replace(/"/g,'"')+'" data-ttv="'+String(v).replace(/&/g,'&').replace(/"/g,'"')+'"';}
7899 // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if it
7900 // cannot fit legibly even at the 6.5 floor (labels shrink to fit rather than
7901 // disappear; the SVG scales up in Full View so small fonts stay legible).
7902 function cFitFs(t,w){var fs=Math.min(10,(w-4)/((String(t).length||1)*0.58));return fs>=6.5?Math.round(fs*10)/10:0;}
7903 function cLT(l,v){return ' data-ttl="'+l+'" data-ttv="'+v.replace(/"/g,'"')+'"';}
7904 function renderCompSVG() {
7905 var isPct = cMode === 'pct';
7906 var totC=cData.reduce(function(a,d){return a+(d.code||0);},0);
7907 var totCm=cData.reduce(function(a,d){return a+(d.comments||0);},0);
7908 var totBl=cData.reduce(function(a,d){return a+(d.blanks||0);},0);
7909 var totAll=totC+totCm+totBl||1;
7910 var svgW=Math.max(320,el.offsetWidth||540);
7911 var LW=108,legendH=24,topPad=4;
7912 var MIN_SVG_H=220;
7913 var rHb=Math.min(80,Math.max(26,Math.floor((MIN_SVG_H-legendH-topPad-10)/cData.length)));
7914 var bH=Math.min(38,Math.round(rHb*0.68));
7915 var BW=Math.max(120,svgW-LW-84);
7916 var SH=Math.max(MIN_SVG_H,cData.length*rHb+legendH+topPad+10);
7917 var s='<svg viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
7918 if(isPct){
7919 cData.forEach(function(d,i){
7920 var t2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
7921 var cW=(d.code||0)/t2*BW,cmW=(d.comments||0)/t2*BW,blW=(d.blanks||0)/t2*BW;
7922 var y=topPad+i*rHb+Math.floor((rHb-bH)/2),x=LW;
7923 var lmid=y+Math.floor(bH/2)+4;
7924 var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||t2);
7925 s+='<text'+cTT(d.lang,ttvc)+' x="'+(LW-5)+'" y="'+lmid+'" text-anchor="end" font-family="'+CFONT+'" font-size="11" fill="#43342d" style="cursor:pointer;">'+cEsc(d.lang)+'</text>';
7926 if(cW>0.5){s+='<rect'+cTT(d.lang+' Code',fmt(d.code||0)+' lines')+' data-kind="code" x="'+cPx(x)+'" y="'+y+'" width="'+cPx(cW)+'" height="'+bH+'" fill="'+CX+'"/>';var _fc=cFitFs(fmt(d.code||0),cW);if(_fc)s+='<text x="'+cPx(x+cW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+CFONT+'" font-size="'+_fc+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.code||0)+'</text>';x+=cW;}
7927 if(cmW>0.5){s+='<rect'+cTT(d.lang+' Comments',fmt(d.comments||0)+' lines')+' data-kind="comment" x="'+cPx(x)+'" y="'+y+'" width="'+cPx(cmW)+'" height="'+bH+'" fill="'+CG+'"/>';var _fm=cFitFs(fmt(d.comments||0),cmW);if(_fm)s+='<text x="'+cPx(x+cmW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+CFONT+'" font-size="'+_fm+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.comments||0)+'</text>';x+=cmW;}
7928 if(blW>0.5){s+='<rect'+cTT(d.lang+' Blank',fmt(d.blanks||0)+' lines')+' data-kind="blank" x="'+cPx(x)+'" y="'+y+'" width="'+cPx(blW)+'" height="'+bH+'" fill="'+CB+'"/>';var _fb=cFitFs(fmt(d.blanks||0),blW);if(_fb)s+='<text x="'+cPx(x+blW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+CFONT+'" font-size="'+_fb+'" font-weight="700" fill="#555" style="pointer-events:none;">'+fmt(d.blanks||0)+'</text>';}
7929 s+='<text'+cTT(d.lang,ttvc)+' x="'+(LW+BW+4)+'" y="'+lmid+'" font-family="'+CFONT+'" font-size="11" font-weight="700" fill="#7b675b" style="cursor:pointer;">'+Math.round((d.code||0)/t2*100)+'%</text>';
7930 });
7931 } else {
7932 var maxT=Math.max.apply(null,cData.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);})) || 1;
7933 cData.forEach(function(d,i){
7934 var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
7935 var y=topPad+i*rHb+Math.floor((rHb-bH)/2),x=LW;
7936 var lmid=y+Math.floor(bH/2)+4;
7937 var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||(d.code||0)+(d.comments||0)+(d.blanks||0));
7938 s+='<text'+cTT(d.lang,ttvc)+' x="'+(LW-5)+'" y="'+lmid+'" text-anchor="end" font-family="'+CFONT+'" font-size="11" fill="#43342d" style="cursor:pointer;">'+cEsc(d.lang)+'</text>';
7939 if(cW>0.5){s+='<rect'+cTT(d.lang+' Code',fmt(d.code||0)+' lines')+' data-kind="code" x="'+cPx(x)+'" y="'+y+'" width="'+cPx(cW)+'" height="'+bH+'" fill="'+CX+'"/>';var _fc=cFitFs(fmt(d.code||0),cW);if(_fc)s+='<text x="'+cPx(x+cW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+CFONT+'" font-size="'+_fc+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.code||0)+'</text>';x+=cW;}
7940 if(cmW>0.5){s+='<rect'+cTT(d.lang+' Comments',fmt(d.comments||0)+' lines')+' data-kind="comment" x="'+cPx(x)+'" y="'+y+'" width="'+cPx(cmW)+'" height="'+bH+'" fill="'+CG+'"/>';var _fm=cFitFs(fmt(d.comments||0),cmW);if(_fm)s+='<text x="'+cPx(x+cmW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+CFONT+'" font-size="'+_fm+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.comments||0)+'</text>';x+=cmW;}
7941 if(blW>0.5){s+='<rect'+cTT(d.lang+' Blank',fmt(d.blanks||0)+' lines')+' data-kind="blank" x="'+cPx(x)+'" y="'+y+'" width="'+cPx(blW)+'" height="'+bH+'" fill="'+CB+'"/>';var _fb=cFitFs(fmt(d.blanks||0),blW);if(_fb)s+='<text x="'+cPx(x+blW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+CFONT+'" font-size="'+_fb+'" font-weight="700" fill="#555" style="pointer-events:none;">'+fmt(d.blanks||0)+'</text>';}
7942 var phys=d.physical||(d.code||0)+(d.comments||0)+(d.blanks||0);
7943 s+='<text'+cTT(d.lang,ttvc)+' x="'+(LW+cW+cmW+blW+4)+'" y="'+lmid+'" font-family="'+CFONT+'" font-size="11" font-weight="700" fill="#7b675b" style="cursor:pointer;">'+fmt(phys)+'</text>';
7944 });
7945 }
7946 var ly=SH-legendH+4;
7947 var ttC=cLT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
7948 var ttCm=cLT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
7949 var ttBl=cLT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
7950 var legSt=LW+Math.max(0,Math.round((BW-194)/2));
7951 s+='<g data-kind="code" style="cursor:pointer;"><rect x="'+legSt+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC+'/><rect x="'+legSt+'" y="'+ly+'" width="9" height="9" fill="'+CX+'"'+ttC+'/><text x="'+(legSt+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+CFONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text></g>';
7952 s+='<g data-kind="comment" style="cursor:pointer;"><rect x="'+(legSt+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm+'/><rect x="'+(legSt+58)+'" y="'+ly+'" width="9" height="9" fill="'+CG+'"'+ttCm+'/><text x="'+(legSt+71)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+CFONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text></g>';
7953 s+='<g data-kind="blank" style="cursor:pointer;"><rect x="'+(legSt+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/><rect x="'+(legSt+145)+'" y="'+ly+'" width="9" height="9" fill="'+CB+'"'+ttBl+'/><text x="'+(legSt+158)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+CFONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text></g>';
7954 s+='</svg>';
7955 el.innerHTML=s;
7956 wireMixLegend(el.querySelector('svg'));
7957 }
7958 document.querySelectorAll('[data-comp-tab]').forEach(function(btn){
7959 btn.addEventListener('click', function(){
7960 document.querySelectorAll('[data-comp-tab]').forEach(function(b){b.classList.remove('active');});
7961 btn.classList.add('active');
7962 cMode=btn.getAttribute('data-comp-tab');
7963 renderCompSVG();
7964 });
7965 });
7966 renderCompSVG();
7967 window.addEventListener('resize', renderCompSVG);
7968 })();
7969
7970 // Custom HTML legend for the bubble chart: balanced columns (~15 rows max
7971 // per column, evenly distributed) placed to the right of the canvas. Chart.js'
7972 // native right-legend fills one column to full height then dumps the rest into
7973 // a tiny second column (and clips when space is tight) — this gives even columns
7974 // and never hides languages. Hover mirrors the old dim/highlight behaviour.
7975 //
7976 // Must run BEFORE `new Chart(canvas, …)` so Chart.js' resize observer binds to
7977 // the inner wrapper (not the full-width host). Returns a holder whose `.chart`
7978 // field the caller assigns once the chart exists, so hover can drive it.
7979 // expandBtnId: when set (compact card), the legend is capped to what fits in
7980 // 2 columns at the available height and a trailing "+N more" row links to Full
7981 // View. When null (Full View itself) every language is shown across (up to) 2
7982 // tall columns. Languages are ordered by code lines so the compact view keeps
7983 // the biggest ones; colours/hover still key off each language's original index.
7984 function attachScatterLegend(canvas, expandBtnId) {
7985 var holder = { chart: null };
7986 var host = canvas && canvas.parentNode;
7987 if (!host) return holder;
7988 host.style.display = 'flex';
7989 host.style.alignItems = 'center';
7990 host.style.gap = '12px';
7991 var cwrap = document.createElement('div');
7992 cwrap.style.cssText = 'position:relative;flex:1 1 auto;min-width:0;height:100%;';
7993 host.insertBefore(cwrap, canvas);
7994 cwrap.appendChild(canvas);
7995
7996 var n = SCAT_D.length;
7997 var availH = Math.max(120, host.clientHeight || 224);
7998 var rowsFit = Math.max(2, Math.floor(availH / 18)); // readable pitch
7999 // Never more than 2 columns; compact view truncates to fit, Full View shows all.
8000 var truncated = expandBtnId ? (n > 2 * rowsFit) : false;
8001 var realShown = truncated ? (2 * rowsFit - 1) : n;
8002 var totalItems = truncated ? (2 * rowsFit) : n;
8003 // Split into 2 equal columns once a single column would exceed ~18 rows, even
8004 // when the (tall) Full-View modal could fit them all in one column.
8005 var cols = totalItems > Math.min(rowsFit, 18) ? 2 : 1;
8006 var perCol = Math.ceil(totalItems / cols);
8007 var rowH = Math.max(14, Math.min(30, Math.floor(availH / perCol)));
8008
8009 // Order by code lines desc so the compact view keeps the biggest languages.
8010 var order = SCAT_D.map(function(_, i){ return i; })
8011 .sort(function(a, b){ return (SCAT_D[b].code || 0) - (SCAT_D[a].code || 0); });
8012
8013 var leg = document.createElement('div');
8014 leg.style.cssText = 'flex:0 0 auto;display:grid;grid-auto-flow:column;'
8015 + 'grid-template-rows:repeat(' + perCol + ',' + rowH + 'px);column-gap:18px;'
8016 + 'align-content:center;font-size:12px;line-height:1;';
8017 function setHi(idx) {
8018 var chart = holder.chart; if (!chart) return;
8019 chart.$hiDs = idx; // read by scatterLabelPlugin so labels fade in step
8020 chart.data.datasets.forEach(function(ds, i) {
8021 var b = PALETTE[i % PALETTE.length];
8022 ds.backgroundColor = i === idx ? b + 'b8' : b + '20';
8023 ds.borderColor = i === idx ? b : b + '30';
8024 });
8025 chart.setActiveElements([{ datasetIndex: idx, index: 0 }]);
8026 chart.update();
8027 }
8028 function clearHi() {
8029 var chart = holder.chart; if (!chart) return;
8030 chart.$hiDs = null;
8031 chart.data.datasets.forEach(function(ds, i) {
8032 var b = PALETTE[i % PALETTE.length];
8033 ds.backgroundColor = b + 'b8';
8034 ds.borderColor = b;
8035 });
8036 chart.setActiveElements([]);
8037 chart.update('none');
8038 }
8039 function addItem(swColor, label, idx, isMore) {
8040 var it = document.createElement('div');
8041 it.style.cssText = 'display:flex;align-items:center;gap:7px;white-space:nowrap;'
8042 + ((idx != null || isMore) ? 'cursor:pointer;' : '');
8043 var sw = document.createElement('span');
8044 sw.style.cssText = 'width:22px;height:12px;border-radius:2px;flex:0 0 auto;background:'
8045 + swColor + ';' + (isMore ? 'opacity:0.45;' : '');
8046 var tx = document.createElement('span');
8047 tx.textContent = label;
8048 if (isMore) { tx.style.fontStyle = 'italic'; tx.style.opacity = '0.8'; }
8049 it.appendChild(sw); it.appendChild(tx);
8050 if (idx != null) {
8051 it.addEventListener('mouseenter', function(){ setHi(idx); });
8052 it.addEventListener('mouseleave', clearHi);
8053 }
8054 if (isMore) {
8055 it.addEventListener('click', function(){
8056 var b = document.getElementById(expandBtnId); if (b) b.click();
8057 });
8058 }
8059 leg.appendChild(it);
8060 }
8061 for (var k = 0; k < realShown; k++) {
8062 var oi = order[k];
8063 addItem(PALETTE[oi % PALETTE.length], SCAT_D[oi].lang, oi, false);
8064 }
8065 if (truncated) addItem('#9a8c82', '+' + (n - realShown) + ' more — Full View', null, true);
8066 host.appendChild(leg);
8067 return holder;
8068 }
8069
8070 // ── Scatter / Bubble chart ────────────────────────────────────────────────
8071 (function() {
8072 var canvas = document.getElementById('canvas-scatter');
8073 if (!canvas || !SCAT_D || !SCAT_D.length) return;
8074 var maxP = Math.max.apply(null, SCAT_D.map(function(d){return d.physical;})) || 1;
8075 var maxFx = Math.max.apply(null, SCAT_D.map(function(d){return d.files;})) || 1;
8076 var c = clr();
8077 var legHolder = attachScatterLegend(canvas, 'scatter-expand-btn');
8078 var chart = new Chart(canvas, {
8079 type: 'bubble',
8080 data: {
8081 datasets: SCAT_D.map(function(d, i) {
8082 return {
8083 label: d.lang,
8084 data: [{ x: d.files, y: d.code, r: Math.max(5, Math.round(Math.sqrt(d.physical/maxP)*20)) }],
8085 backgroundColor: PALETTE[i % PALETTE.length] + 'b8',
8086 borderColor: PALETTE[i % PALETTE.length], borderWidth: 1,
8087 hoverBorderWidth: 2
8088 };
8089 })
8090 },
8091 options: {
8092 responsive: true, maintainAspectRatio: false,
8093 onHover: chartCursor,
8094 animation: { duration: 500, easing: 'easeOutQuart' },
8095 layout: { padding: { top: 44, right: 12 } },
8096 scales: {
8097 x: { type: 'logarithmic', min: 0.8, max: maxFx * 2.6,
8098 grid: { color: c.grid },
8099 ticks: { color: c.text, font: { size: 11 }, maxTicksLimit: 6, callback: function(v){ return fmt(v); } },
8100 title: { display: true, text: 'Files Analyzed', color: c.text, font: { size: 11 } } },
8101 y: { grid: { color: c.grid }, ticks: { color: c.text, font: { size: 11 }, callback: function(v){return fmt(v);} },
8102 title: { display: true, text: 'Code Lines', color: c.text, font: { size: 11 } } }
8103 },
8104 plugins: {
8105 legend: { display: false },
8106 tooltip: {
8107 callbacks: {
8108 title: function(items) { return items.length ? items[0].dataset.label : ''; },
8109 label: function(ctx){
8110 var d = SCAT_D[ctx.datasetIndex];
8111 return [
8112 ' Files analyzed: ' + fmt(d.files),
8113 ' Code lines: ' + Number(d.code).toLocaleString(),
8114 ' Physical lines: ' + Number(d.physical).toLocaleString()
8115 ];
8116 }
8117 }
8118 }
8119 }
8120 },
8121 plugins: [scatterLabelPlugin()]
8122 });
8123 ALL_CHARTS.push(chart);
8124 legHolder.chart = chart;
8125 })();
8126
8127 // ── Submodule breakdown ──────────────────────────────────────────────────
8128 // No-op plugins: hover row-dimming was removed because the flashing row
8129 // background looked out of place vs. every other chart. Kept as empty stubs
8130 // so the (inline + Full View) chart configs that reference them stay valid.
8131 var rowDimPlugin = {};
8132 var barJumpPlugin = {};
8133 var subChart = null;
8134 (function() {
8135 if (!SUB_D || !SUB_D.length) return;
8136 var subYSel = document.getElementById('sub-y-axis');
8137 var subSortSel = document.getElementById('sub-sort');
8138 var wrap = document.getElementById('canvas-sub-wrap');
8139 var canvas = document.getElementById('canvas-sub');
8140 if (!canvas) return;
8141 var Y_LABELS = { code:'Code Lines', comment:'Comment Lines', blank:'Blank Lines',
8142 physical:'Physical Lines', files:'Files' };
8143 var SUB_COLS = { code:OX, comment:GN, blank:GY, physical:'#4472C4', files:'#805099' };
8144 function renderSubmodule() {
8145 var yKey = subYSel ? subYSel.value : 'code';
8146 var sortMode = subSortSel ? subSortSel.value : 'desc';
8147 var data = SUB_D.slice();
8148 if (sortMode==='desc') data.sort(function(a,b){return (b[yKey]||0)-(a[yKey]||0);});
8149 else if (sortMode==='asc') data.sort(function(a,b){return (a[yKey]||0)-(b[yKey]||0);});
8150 else data.sort(function(a,b){return a.name.localeCompare(b.name);});
8151 data = data.slice(0, 30);
8152 var c = clr();
8153 var col = SUB_COLS[yKey] || OX;
8154 if (wrap) wrap.style.height = Math.max(200, Math.min(540, data.length * 28 + 60)) + 'px';
8155 if (subChart) {
8156 subChart.data.labels = data.map(function(d){return d.name;});
8157 subChart.data.datasets[0].data = data.map(function(d){return d[yKey]||0;});
8158 subChart.data.datasets[0].backgroundColor = col;
8159 subChart.data.datasets[0].label = Y_LABELS[yKey]||yKey;
8160 subChart.options.scales.x.title.text = Y_LABELS[yKey]||yKey;
8161 subChart.update('none'); return;
8162 }
8163 subChart = new Chart(canvas, {
8164 type: 'bar',
8165 data: {
8166 labels: data.map(function(d){return d.name;}),
8167 datasets: [{ label: Y_LABELS[yKey]||yKey,
8168 data: data.map(function(d){return d[yKey]||0;}),
8169 backgroundColor: col, hoverBackgroundColor: col === OX ? '#d97020' : col,
8170 borderRadius: 3 }]
8171 },
8172 options: {
8173 indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8174 onHover: chartCursor,
8175 animation: { duration: 500, easing: 'easeOutQuart' },
8176 transitions: { active: { animation: { duration: 180, easing: 'easeOutQuart' } } },
8177 layout: { padding: { right: 64 } },
8178 scales: {
8179 x: { grid: { color: c.grid }, ticks: { color: c.text, callback: function(v){return fmt(v);} },
8180 title: { display: true, text: Y_LABELS[yKey]||yKey, color: c.text } },
8181 y: { grid: { display: false }, ticks: { color: c.text } }
8182 },
8183 plugins: {
8184 legend: { display: false },
8185 tooltip: {
8186 callbacks: {
8187 title: function(items) { return items.length ? items[0].label : ''; },
8188 label: function(ctx){
8189 var d = data[ctx.dataIndex] || {};
8190 return [
8191 ' Code: ' + Number(d.code||0).toLocaleString(),
8192 ' Comments: ' + Number(d.comment||0).toLocaleString(),
8193 ' Blanks: ' + Number(d.blank||0).toLocaleString(),
8194 ' Physical: ' + Number(d.physical||0).toLocaleString(),
8195 ' Files: ' + fmt(d.files||0)
8196 ];
8197 }
8198 }
8199 }
8200 }
8201 },
8202 plugins: [makeDlPlugin(function(v){ return fmt(v||0); }, 'end'), barJumpPlugin]
8203 });
8204 ALL_CHARTS.push(subChart);
8205 }
8206 if (subYSel) subYSel.addEventListener('change', renderSubmodule);
8207 if (subSortSel) subSortSel.addEventListener('change', renderSubmodule);
8208 renderSubmodule();
8209 })();
8210
8211 // ── Submodule composition: stacked horizontal bar (Chart.js) ─────────────
8212 var subCompChart = null;
8213 // Plugin: draw value label inside each visible segment of a stacked horizontal bar.
8214 var segLabelPlugin = {
8215 afterDatasetsDraw: function(chart) {
8216 var ctx = chart.ctx, nDs = chart.data.datasets.length;
8217 var tc = clr().text;
8218 for (var di = 0; di < nDs; di++) {
8219 var meta = chart.getDatasetMeta(di);
8220 if (meta.hidden) continue;
8221 meta.data.forEach(function(el, idx) {
8222 var v = chart.data.datasets[di].data[idx] || 0;
8223 if (!v) return;
8224 var w = Math.abs(el.x - el.base);
8225 if (w < 28) return; // too narrow to show label
8226 ctx.save();
8227 ctx.font = '600 10px Inter,ui-sans-serif,sans-serif';
8228 ctx.fillStyle = di === 0 ? '#fff' : (di === 1 ? '#fff' : '#555');
8229 ctx.textAlign = 'center';
8230 ctx.textBaseline = 'middle';
8231 ctx.fillText(fmt(v), el.base + w / 2, el.y);
8232 ctx.restore();
8233 });
8234 }
8235 }
8236 };
8237 (function() {
8238 var el = document.getElementById('submodule-donut');
8239 if (!el || !SUB_D || !SUB_D.length) return;
8240 var data = SUB_D.slice().sort(function(a,b){
8241 return ((b.code||0)+(b.comment||0)+(b.blank||0))-((a.code||0)+(a.comment||0)+(a.blank||0));
8242 }).slice(0, 15);
8243 var h = Math.max(150, Math.min(540, data.length * 40 + 90));
8244 el.style.height = h + 'px';
8245 el.style.position = 'relative';
8246 var cv = document.createElement('canvas');
8247 cv.id = 'canvas-sub-comp';
8248 el.innerHTML = '';
8249 el.appendChild(cv);
8250 var c = clr();
8251 subCompChart = new Chart(cv, {
8252 type: 'bar',
8253 data: {
8254 labels: data.map(function(d){ return d.name; }),
8255 datasets: [
8256 { label: 'Code', data: data.map(function(d){ return d.code||0; }), backgroundColor: OX, hoverBackgroundColor: '#d97020', borderRadius: 0, borderSkipped: false },
8257 { label: 'Comments', data: data.map(function(d){ return d.comment||0; }), backgroundColor: GN, hoverBackgroundColor: '#3a8a5e', borderRadius: 0, borderSkipped: false },
8258 { label: 'Blank', data: data.map(function(d){ return d.blank||0; }), backgroundColor: GY, hoverBackgroundColor: '#999', borderRadius: 0, borderSkipped: false }
8259 ]
8260 },
8261 options: {
8262 indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8263 onHover: chartCursor,
8264 animation: { duration: 500, easing: 'easeOutQuart' },
8265 transitions: { active: { animation: { duration: 180, easing: 'easeOutQuart' } } },
8266 layout: { padding: { right: 56 } },
8267 scales: {
8268 x: { stacked: true, grid: { color: c.grid }, ticks: { color: c.text, callback: function(v){ return fmt(v); } } },
8269 y: { stacked: true, grid: { display: false }, ticks: { color: c.text } }
8270 },
8271 plugins: {
8272 legend: {
8273 position: 'bottom',
8274 labels: { color: c.text, usePointStyle: true, pointStyle: 'rect', font: { size: 11, weight: '700' }, padding: 16 },
8275 onHover: function(e, item, leg) {
8276 legendCursorOn(e);
8277 var ch = leg.chart, di = item.datasetIndex;
8278 var orig = [OX, GN, GY], hov = ['#d97020','#3a8a5e','#999'];
8279 ch.data.datasets.forEach(function(ds, i) {
8280 ds.backgroundColor = i===di ? orig[i] : hexAlpha(orig[i], 0.15);
8281 ds.hoverBackgroundColor = i===di ? hov[i] : hexAlpha(orig[i], 0.15);
8282 });
8283 // show tooltip on first bar row with all datasets (index mode)
8284 var n = ch.data.datasets.length, ae = [];
8285 for (var ii = 0; ii < n; ii++) { ae.push({ datasetIndex: ii, index: 0 }); }
8286 var fp = ch.getDatasetMeta(di).data[0];
8287 ch.setActiveElements([{ datasetIndex: di, index: 0 }]);
8288 ch.tooltip.setActiveElements(ae, fp ? { x: fp.x, y: fp.y } : { x: 0, y: 0 });
8289 ch.update();
8290 },
8291 onLeave: function(e, item, leg) {
8292 legendCursorOff(e);
8293 var ch = leg.chart;
8294 var orig = [OX, GN, GY], hov = ['#d97020','#3a8a5e','#999'];
8295 ch.data.datasets.forEach(function(ds, i) { ds.backgroundColor = orig[i]; ds.hoverBackgroundColor = hov[i]; });
8296 ch.setActiveElements([]);
8297 ch.tooltip.setActiveElements([], {});
8298 ch.update('none');
8299 }
8300 },
8301 tooltip: {
8302 mode: 'index',
8303 callbacks: {
8304 title: function(items){ return items.length ? items[0].label : ''; },
8305 label: function(ctx){
8306 var v = ctx.parsed.x || 0;
8307 return ' ' + ctx.dataset.label + ': ' + Number(v).toLocaleString();
8308 },
8309 footer: function(items){
8310 var tot = items.reduce(function(s,i){ return s + (i.parsed.x||0); }, 0);
8311 return 'Total: ' + Number(tot).toLocaleString();
8312 }
8313 }
8314 }
8315 }
8316 },
8317 plugins: [makeStackedEndPlugin(function(v){ return fmt(v); }), segLabelPlugin, rowDimPlugin]
8318 });
8319 ALL_CHARTS.push(subCompChart);
8320 })();
8321
8322 // ── Semantic Metrics ─────────────────────────────────────────────────────
8323 (function() {
8324 if (!SEM_D || !SEM_D.length) return;
8325 var semSel = document.getElementById('semantic-metric');
8326 var canvas = document.getElementById('canvas-semantic');
8327 if (!canvas) return;
8328 var SEM_LABELS = { functions:'Functions', classes:'Classes / Types', variables:'Variables',
8329 imports:'Imports', tests:'Tests' };
8330 var SEM_COLS = { functions:OX, classes:'#4472C4', variables:GN, imports:'#805099', tests:'#B23030' };
8331 var SEM_HCOLS = { functions:'#d97020', classes:'#5a8ad8', variables:'#3a8a5e', imports:'#9a68b3', tests:'#cc4545' };
8332 var semChart = null;
8333 function renderSemantic() {
8334 var mKey = semSel ? semSel.value : 'functions';
8335 var data = SEM_D.slice().sort(function(a,b){return (b[mKey]||0)-(a[mKey]||0);}).slice(0,15);
8336 var c = clr();
8337 var col = SEM_COLS[mKey] || OX;
8338 var hCol = SEM_HCOLS[mKey] || '#d97020';
8339 if (semChart) {
8340 semChart.data.labels = data.map(function(d){return d.lang;});
8341 semChart.data.datasets[0].data = data.map(function(d){return d[mKey]||0;});
8342 semChart.data.datasets[0].backgroundColor = col;
8343 semChart.data.datasets[0].hoverBackgroundColor = hCol;
8344 semChart.data.datasets[0].label = SEM_LABELS[mKey]||mKey;
8345 semChart.update('none'); return;
8346 }
8347 semChart = new Chart(canvas, {
8348 type: 'bar',
8349 data: {
8350 labels: data.map(function(d){return d.lang;}),
8351 datasets: [{ label: SEM_LABELS[mKey]||mKey,
8352 data: data.map(function(d){return d[mKey]||0;}),
8353 backgroundColor: col, hoverBackgroundColor: hCol,
8354 borderRadius: 4, borderWidth: 0, hoverBorderWidth: 0 }]
8355 },
8356 options: {
8357 responsive: true, maintainAspectRatio: false,
8358 onHover: chartCursor,
8359 animation: { duration: 500, easing: 'easeOutQuart' },
8360 transitions: { active: { animation: { duration: 200, easing: 'easeOutQuart' } } },
8361 layout: { padding: { top: 18 } },
8362 scales: {
8363 x: { grid: { display: false }, ticks: { color: c.text } },
8364 y: { grid: { color: c.grid }, ticks: { color: c.text, callback: function(v){return fmt(v);} } }
8365 },
8366 plugins: {
8367 legend: { display: false },
8368 tooltip: {
8369 callbacks: {
8370 title: function(items) { return items.length ? items[0].label : ''; },
8371 label: function(ctx) {
8372 var d = data[ctx.dataIndex] || {};
8373 var lines = [' ' + (SEM_LABELS[mKey]||mKey) + ': ' + Number(ctx.parsed.y).toLocaleString()];
8374 var others = Object.keys(SEM_LABELS).filter(function(k){ return k !== mKey && (d[k]||0) > 0; });
8375 others.forEach(function(k) {
8376 lines.push(' ' + SEM_LABELS[k] + ': ' + Number(d[k]||0).toLocaleString());
8377 });
8378 return lines;
8379 }
8380 }
8381 }
8382 }
8383 },
8384 plugins: [makeDlPlugin(function(v) { return fmt(v || 0); }, 'top')]
8385 });
8386 ALL_CHARTS.push(semChart);
8387 }
8388 if (semSel) semSel.addEventListener('change', renderSemantic);
8389 renderSemantic();
8390
8391 var semExpandBtn = document.getElementById('semantic-expand-btn');
8392 if (semExpandBtn) {
8393 semExpandBtn.addEventListener('click', function() {
8394 var mKey = semSel ? semSel.value : 'functions';
8395 var n = SEM_D.length || 1;
8396 var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
8397 var modalH = Math.min(Math.max(400, n * 46 + 96), maxH);
8398 var overlay = document.createElement('div');
8399 overlay.className = 'chart-modal-overlay';
8400 var semOptHtml = '<option value="functions">Functions</option>'
8401 + '<option value="classes">Classes / Types</option>'
8402 + '<option value="variables">Variables</option>'
8403 + '<option value="imports">Imports</option>'
8404 + '<option value="tests">Tests</option>';
8405 var hdr = '<div class="chart-modal-header"><span class="chart-modal-title">Semantic Metrics \u2014 Full View</span>'
8406 + '<select class="chart-select" id="sem-modal-metric">' + semOptHtml + '</select></div>';
8407 overlay.innerHTML = '<div class="chart-modal" style="max-width:1320px;"><button class="chart-modal-close" aria-label="Close">×</button>' + hdr + '<div style="position:relative;height:' + modalH + 'px;width:100%;"><canvas id="canvas-semantic-modal"></canvas></div></div>';
8408 document.body.appendChild(overlay);
8409 overlay.querySelector('.chart-modal-close').addEventListener('click', function() { document.body.removeChild(overlay); });
8410 overlay.addEventListener('click', function(e) { if (e.target === overlay) document.body.removeChild(overlay); });
8411 var modalSel = document.getElementById('sem-modal-metric');
8412 if (modalSel) modalSel.value = mKey;
8413 var modalCanvas = document.getElementById('canvas-semantic-modal');
8414 var semModalChart = null;
8415 function renderSemModal(key) {
8416 if (semModalChart) { semModalChart.destroy(); semModalChart = null; }
8417 if (!modalCanvas) return;
8418 var data = SEM_D.slice().sort(function(a,b){return (b[key]||0)-(a[key]||0);});
8419 var c = clr();
8420 var col = SEM_COLS[key] || OX;
8421 var hcol = SEM_HCOLS[key] || '#d97020';
8422 semModalChart = new Chart(modalCanvas, {
8423 type: 'bar',
8424 data: {
8425 labels: data.map(function(d){return d.lang;}),
8426 datasets: [{ label: SEM_LABELS[key]||key, data: data.map(function(d){return d[key]||0;}),
8427 backgroundColor: col, hoverBackgroundColor: hcol,
8428 borderRadius: 4, borderWidth: 0, hoverBorderWidth: 0 }]
8429 },
8430 options: {
8431 responsive: true, maintainAspectRatio: false,
8432 onHover: chartCursor,
8433 animation: { duration: 500, easing: 'easeOutQuart' },
8434 transitions: { active: { animation: { duration: 200, easing: 'easeOutQuart' } } },
8435 layout: { padding: { top: 18 } },
8436 scales: {
8437 x: { grid: { display: false }, ticks: { color: c.text } },
8438 y: { grid: { color: c.grid }, ticks: { color: c.text, callback: function(v){return fmt(v);} } }
8439 },
8440 plugins: { legend: { display: false }, tooltip: { callbacks: {
8441 title: function(items){return items.length?items[0].label:'';},
8442 label: function(ctx){
8443 var d = data[ctx.dataIndex] || {};
8444 var lines = [' '+(SEM_LABELS[key]||key)+': '+Number(ctx.parsed.y).toLocaleString()];
8445 var others = Object.keys(SEM_LABELS).filter(function(k){ return k !== key && (d[k]||0) > 0; });
8446 others.forEach(function(k){ lines.push(' '+SEM_LABELS[k]+': '+Number(d[k]||0).toLocaleString()); });
8447 return lines;
8448 }
8449 }}}
8450 },
8451 plugins: [makeDlPlugin(function(v) { return fmt(v || 0); }, 'top')]
8452 });
8453 }
8454 renderSemModal(mKey);
8455 if (modalSel) modalSel.addEventListener('change', function() { renderSemModal(this.value); });
8456 });
8457 }
8458 })();
8459
8460 // ── Comment Density: comments / (code + comments) per language ──────────
8461 (function() {
8462 var canvas = document.getElementById('canvas-density');
8463 if (!canvas || !D || !D.length) return;
8464 var data = D.slice().sort(function(a,b){
8465 var da=(a.comments||0)/Math.max((a.code||0)+(a.comments||0),1);
8466 var db=(b.comments||0)/Math.max((b.code||0)+(b.comments||0),1);
8467 return db-da;
8468 });
8469 var labels = data.map(function(d){return d.lang;});
8470 var densities = data.map(function(d){
8471 var sig=(d.code||0)+(d.comments||0);
8472 return sig>0?Math.round((d.comments||0)/sig*1000)/10:0;
8473 });
8474 var wrap = canvas.parentElement;
8475 if (wrap) wrap.style.height = Math.max(150, Math.min(500, data.length*29+36))+'px';
8476 var c = clr();
8477 var densChart = new Chart(canvas, {
8478 type: 'bar',
8479 data: {
8480 labels: labels,
8481 datasets: [{ label: 'Comment %',
8482 data: densities,
8483 backgroundColor: data.map(function(_,i){return PALETTE[i%PALETTE.length];}),
8484 borderRadius: 4
8485 }]
8486 },
8487 options: {
8488 indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8489 onHover: chartCursor,
8490 animation: { duration: 500, easing: 'easeOutQuart' },
8491 layout: { padding: { right: 42 } },
8492 scales: {
8493 x: { min: 0, max: 100,
8494 grid: { color: c.grid },
8495 ticks: { color: c.text, callback: function(v){return v+'%';} },
8496 title: { display: true, text: 'Comment %', color: c.text } },
8497 y: { grid: { display: false }, ticks: { color: c.text } }
8498 },
8499 plugins: {
8500 legend: { display: false },
8501 tooltip: { callbacks: {
8502 title: function(items){return items.length?items[0].label:'';},
8503 label: function(ctx){
8504 var d=data[ctx.dataIndex]||{};
8505 var sig=(d.code||0)+(d.comments||0);
8506 return [' Comment ratio: '+ctx.parsed.x+'%',
8507 ' Comments: '+Number(d.comments||0).toLocaleString(),
8508 ' Significant lines: '+Number(sig).toLocaleString()];
8509 }
8510 }}
8511 }
8512 },
8513 plugins: [makeDlPlugin(function(v) { return (v || 0) + '%'; }, 'end')]
8514 });
8515 ALL_CHARTS.push(densChart);
8516 })();
8517
8518 // ── File Size Distribution histogram ──────────────────────────────────────
8519 (function() {
8520 var canvas = document.getElementById('canvas-filesize');
8521 if (!canvas || !HIST_D || !HIST_D.length) return;
8522 var labels = HIST_D.map(function(d){return d.label;});
8523 var counts = HIST_D.map(function(d){return d.count||0;});
8524 var total = counts.reduce(function(a,b){return a+b;},0);
8525 var c = clr();
8526 var fsBg = ['#2A6846','#4472C4','#C45C10','#D4A017','#B23030'];
8527 var fsHv = ['#3a8a5e','#5a8ad8','#d97020','#e8b520','#cc4545'];
8528 var fsChart = new Chart(canvas, {
8529 type: 'bar',
8530 data: {
8531 labels: labels,
8532 datasets: [{ label: 'Files',
8533 data: counts,
8534 backgroundColor: fsBg,
8535 hoverBackgroundColor: fsHv,
8536 borderRadius: 6,
8537 borderWidth: 0,
8538 hoverBorderWidth: 0
8539 }]
8540 },
8541 options: {
8542 responsive: true, maintainAspectRatio: false,
8543 onHover: chartCursor,
8544 animation: { duration: 500, easing: 'easeOutQuart' },
8545 transitions: { active: { animation: { duration: 200, easing: 'easeOutQuart' } } },
8546 layout: { padding: { top: 18 } },
8547 scales: {
8548 x: { grid: { display: false }, ticks: { color: c.text, font: { size: 11 } } },
8549 y: { beginAtZero: true,
8550 grid: { color: c.grid },
8551 ticks: { color: c.text, precision: 0 },
8552 title: { display: true, text: 'File Count', color: c.text } }
8553 },
8554 plugins: {
8555 legend: { display: false },
8556 tooltip: { callbacks: {
8557 label: function(ctx) {
8558 var n = ctx.parsed.y;
8559 var pct = total > 0 ? Math.round(n/total*1000)/10 : 0;
8560 return [' Files: '+n, ' Share: '+pct+'%'];
8561 }
8562 }}
8563 }
8564 },
8565 plugins: [makeDlPlugin(function(v) { return fmt(v || 0); }, 'top')]
8566 });
8567 ALL_CHARTS.push(fsChart);
8568 })();
8569
8570 // ── Expand button handlers ────────────────────────────────────────────────
8571 (function() {
8572 function makeOverlay(title, h, subtitle, ctrlHtml) {
8573 var overlay = document.createElement('div');
8574 overlay.className = 'chart-modal-overlay';
8575 var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
8576 var hAttr = 'height:' + Math.min(h || 696, maxH) + 'px;';
8577 var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
8578 var hdr = '<div class="chart-modal-header"><span class="chart-modal-title">' + title + '</span>' + (ctrlHtml || '') + '</div>';
8579 overlay.innerHTML = '<div class="chart-modal" style="max-width:1320px;"><button class="chart-modal-close" aria-label="Close">×</button>' + hdr + subHtml + '<div style="position:relative;width:100%;' + hAttr + '"><canvas id="modal-expand-canvas"></canvas></div></div>';
8580 document.body.appendChild(overlay);
8581 overlay.querySelector('.chart-modal-close').addEventListener('click', function(){ document.body.removeChild(overlay); });
8582 overlay.addEventListener('click', function(e){ if(e.target === overlay) document.body.removeChild(overlay); });
8583 return document.getElementById('modal-expand-canvas');
8584 }
8585
8586 // Language Composition
8587 (function(){
8588 var btn = document.getElementById('comp-expand-btn');
8589 if(!btn) return;
8590 btn.addEventListener('click', function(){
8591 var activeTab = document.querySelector('[data-comp-tab].active');
8592 var compMode = activeTab ? activeTab.getAttribute('data-comp-tab') : 'absolute';
8593 var ctrlHtml = '<select class="chart-select" id="comp-modal-mode">'
8594 + '<option value="absolute">Absolute Lines</option>'
8595 + '<option value="pct">100% Normalized</option>'
8596 + '</select>';
8597 var canvas = makeOverlay('Language Composition \u2014 Full View', undefined, null, ctrlHtml);
8598 if(!canvas) return;
8599 var modalMode = document.getElementById('comp-modal-mode');
8600 if(modalMode) modalMode.value = compMode;
8601 var compModalChart = null;
8602 function renderCompModal(mode) {
8603 if(compModalChart) { compModalChart.destroy(); compModalChart = null; }
8604 var data = D.slice(0, 15);
8605 var c = clr(), isPct = mode === 'pct';
8606 var tot = function(d){ return (d.code||0)+(d.comments||0)+(d.blanks||0)||1; };
8607 var codeD = data.map(function(d){ return isPct ? (d.code||0)/tot(d)*100 : d.code||0; });
8608 var cmD = data.map(function(d){ return isPct ? (d.comments||0)/tot(d)*100 : d.comments||0; });
8609 var blD = data.map(function(d){ return isPct ? (d.blanks||0)/tot(d)*100 : d.blanks||0; });
8610 var tickCb = isPct ? function(v){return v.toFixed(0)+'%';} : function(v){return fmt(v);};
8611 compModalChart = new Chart(canvas, {
8612 type: 'bar',
8613 data: {
8614 labels: data.map(function(d){ return d.lang; }),
8615 datasets: [
8616 { label:'Code', data: codeD, backgroundColor: OX, borderRadius: 3 },
8617 { label:'Comments', data: cmD, backgroundColor: GN, borderRadius: 3 },
8618 { label:'Blanks', data: blD, backgroundColor: GY, borderRadius: 3 }
8619 ]
8620 },
8621 options: {
8622 indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8623 layout: { padding: { right: 64 } },
8624 scales: {
8625 x: { stacked: true, grid: { color: c.grid }, ticks: { color: c.text, callback: tickCb } },
8626 y: { stacked: true, grid: { display: false }, ticks: { color: c.text } }
8627 },
8628 plugins: { legend: { position: 'bottom', labels: { color: c.text } } }
8629 },
8630 plugins: [makeStackedEndPlugin(function(total, idx) {
8631 if (isPct) return '';
8632 var d = data[idx]; return fmt(Math.round(d && d.physical ? d.physical : total));
8633 })]
8634 });
8635 }
8636 renderCompModal(compMode);
8637 if(modalMode) modalMode.addEventListener('change', function(){ renderCompModal(this.value); });
8638 });
8639 })();
8640
8641 // Files vs Code Lines (Scatter)
8642 (function(){
8643 var btn = document.getElementById('scatter-expand-btn');
8644 if(!btn || !SCAT_D || !SCAT_D.length) return;
8645 btn.addEventListener('click', function(){
8646 var canvas = makeOverlay('Files vs Code Lines \u2014 Full View', undefined, 'File count vs SLOC per language');
8647 if(!canvas) return;
8648 var maxP = Math.max.apply(null, SCAT_D.map(function(d){return d.physical;})) || 1;
8649 var maxFx = Math.max.apply(null, SCAT_D.map(function(d){return d.files;})) || 1;
8650 var c = clr();
8651 var scLegHolder = attachScatterLegend(canvas);
8652 var scExpand = new Chart(canvas, {
8653 type: 'bubble',
8654 data: {
8655 datasets: SCAT_D.map(function(d, i) {
8656 return {
8657 label: d.lang,
8658 data: [{ x: d.files, y: d.code, r: Math.max(5, Math.round(Math.sqrt(d.physical/maxP)*20)) }],
8659 backgroundColor: PALETTE[i % PALETTE.length] + 'b8',
8660 borderColor: PALETTE[i % PALETTE.length], borderWidth: 1,
8661 hoverBorderWidth: 2
8662 };
8663 })
8664 },
8665 options: {
8666 responsive: true, maintainAspectRatio: false,
8667 onHover: chartCursor,
8668 animation: { duration: 500, easing: 'easeOutQuart' },
8669 layout: { padding: { top: 44, right: 12 } },
8670 scales: {
8671 x: { type: 'logarithmic', min: 0.8, max: maxFx * 2.6, grid: { color: c.grid }, ticks: { color: c.text, font: { size: 11 }, maxTicksLimit: 6, callback: function(v){ return fmt(v); } }, title: { display: true, text: 'Files Analyzed', color: c.text, font: { size: 11 } } },
8672 y: { grid: { color: c.grid }, ticks: { color: c.text, font: { size: 11 }, callback: function(v){return fmt(v);} }, title: { display: true, text: 'Code Lines', color: c.text, font: { size: 11 } } }
8673 },
8674 plugins: {
8675 legend: { display: false },
8676 tooltip: { callbacks: {
8677 title: function(items){ return items.length ? items[0].dataset.label : ''; },
8678 label: function(ctx){
8679 var d = SCAT_D[ctx.datasetIndex];
8680 return [' Files analyzed: '+fmt(d.files), ' Code lines: '+Number(d.code).toLocaleString(), ' Physical lines: '+Number(d.physical).toLocaleString()];
8681 }
8682 }}
8683 }
8684 },
8685 plugins: [scatterLabelPlugin()]
8686 });
8687 scLegHolder.chart = scExpand;
8688 });
8689 })();
8690
8691 // Comment Density
8692 (function(){
8693 var btn = document.getElementById('density-expand-btn');
8694 if(!btn) return;
8695 btn.addEventListener('click', function(){
8696 var data = D.slice().sort(function(a,b){
8697 var da=(a.comments||0)/Math.max((a.code||0)+(a.comments||0),1);
8698 var db=(b.comments||0)/Math.max((b.code||0)+(b.comments||0),1);
8699 return db-da;
8700 });
8701 var h = Math.min(Math.max(672, data.length * 46 + 96), Math.max(400, Math.floor(window.innerHeight * 0.82) - 130));
8702 var canvas = makeOverlay('Comment Density \u2014 Full View', h, 'Comment ratio per language');
8703 if(!canvas) return;
8704 var densities = data.map(function(d){ var sig=(d.code||0)+(d.comments||0); return sig>0?Math.round((d.comments||0)/sig*1000)/10:0; });
8705 var c = clr();
8706 new Chart(canvas, {
8707 type: 'bar',
8708 data: {
8709 labels: data.map(function(d){return d.lang;}),
8710 datasets: [{ label: 'Comment %', data: densities,
8711 backgroundColor: data.map(function(_,i){return PALETTE[i%PALETTE.length];}), borderRadius: 4 }]
8712 },
8713 options: {
8714 indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8715 animation: { duration: 500, easing: 'easeOutQuart' },
8716 layout: { padding: { right: 42 } },
8717 scales: {
8718 x: { min:0, max:100, grid:{color:c.grid}, ticks:{color:c.text, callback:function(v){return v+'%';}} },
8719 y: { grid:{display:false}, ticks:{color:c.text} }
8720 },
8721 plugins: { legend:{display:false}, tooltip:{callbacks:{
8722 title:function(items){return items.length?items[0].label:'';},
8723 label:function(ctx){
8724 var d=data[ctx.dataIndex]||{};
8725 var sig=(d.code||0)+(d.comments||0);
8726 return [' Comment ratio: '+ctx.parsed.x.toFixed(1)+'%',
8727 ' Comments: '+Number(d.comments||0).toLocaleString(),
8728 ' Significant lines: '+Number(sig).toLocaleString()];
8729 }
8730 }}}
8731 },
8732 plugins: [makeDlPlugin(function(v) { return (v || 0) + '%'; }, 'end')]
8733 });
8734 });
8735 })();
8736
8737 // File Size Distribution
8738 (function(){
8739 var btn = document.getElementById('filesize-expand-btn');
8740 if(!btn || !HIST_D || !HIST_D.length) return;
8741 btn.addEventListener('click', function(){
8742 var canvas = makeOverlay('File Size Distribution \u2014 Full View', undefined, 'File count per SLOC bucket');
8743 if(!canvas) return;
8744 var labels = HIST_D.map(function(d){return d.label;});
8745 var counts = HIST_D.map(function(d){return d.count||0;});
8746 var total = counts.reduce(function(a,b){return a+b;},0);
8747 var fsBg = ['#2A6846','#4472C4','#C45C10','#D4A017','#B23030'];
8748 var fsHv = ['#3a8a5e','#5a8ad8','#d97020','#e8b520','#cc4545'];
8749 var c = clr();
8750 new Chart(canvas, {
8751 type: 'bar',
8752 data: {
8753 labels: labels,
8754 datasets: [{ label: 'Files', data: counts,
8755 backgroundColor: fsBg, hoverBackgroundColor: fsHv,
8756 borderRadius: 6, borderWidth: 0, hoverBorderWidth: 0 }]
8757 },
8758 options: {
8759 responsive: true, maintainAspectRatio: false,
8760 animation: { duration: 500, easing: 'easeOutQuart' },
8761 transitions: { active: { animation: { duration: 200, easing: 'easeOutQuart' } } },
8762 layout: { padding: { top: 18 } },
8763 scales: {
8764 x: { grid:{display:false}, ticks:{color:c.text} },
8765 y: { beginAtZero:true, grid:{color:c.grid}, ticks:{color:c.text, precision:0}, title:{display:true, text:'File Count', color:c.text} }
8766 },
8767 plugins: { legend:{display:false}, tooltip:{callbacks:{label:function(ctx){ var pct=total>0?Math.round(ctx.parsed.y/total*1000)/10:0; return [' Files: '+ctx.parsed.y, ' Share: '+pct+'%']; }}} }
8768 },
8769 plugins: [makeDlPlugin(function(v) { return fmt(v || 0); }, 'top')]
8770 });
8771 });
8772 })();
8773
8774 // Submodule Breakdown — Full View with live Y Axis + Sort controls
8775 (function(){
8776 var btn = document.getElementById('sub-expand-btn');
8777 if(!btn || !SUB_D || !SUB_D.length) return;
8778 btn.addEventListener('click', function(){
8779 var subYSel = document.getElementById('sub-y-axis');
8780 var subSortSel = document.getElementById('sub-sort');
8781 var initY = subYSel ? subYSel.value : 'code';
8782 var initSort = subSortSel ? subSortSel.value : 'desc';
8783 var Y_LABELS = { code:'Code Lines', comment:'Comment Lines', blank:'Blank Lines', physical:'Physical Lines', files:'Files' };
8784 var SUB_COLS = { code:OX, comment:GN, blank:GY, physical:'#4472C4', files:'#805099' };
8785 var SUB_HCOLS = { code:'#d97020', comment:'#3a8a5e', blank:'#999', physical:'#5a8ad8', files:'#9a68b3' };
8786 var n = Math.min(SUB_D.length, 30);
8787 var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
8788 var modalH = Math.min(Math.max(480, n * 36 + 96), maxH);
8789 var ctrlHtml = '<label style="font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:6px;flex-shrink:0;">Y Axis:'
8790 + '<select class="chart-select" id="sub-modal-y">'
8791 + '<option value="code">Code Lines</option>'
8792 + '<option value="comment">Comment Lines</option>'
8793 + '<option value="blank">Blank Lines</option>'
8794 + '<option value="physical">Physical Lines</option>'
8795 + '<option value="files">File Count</option>'
8796 + '</select></label>'
8797 + '<label style="font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:6px;flex-shrink:0;">Sort:'
8798 + '<select class="chart-select" id="sub-modal-sort">'
8799 + '<option value="desc">Value \u2193</option>'
8800 + '<option value="asc">Value \u2191</option>'
8801 + '<option value="name">Name A\u2192Z</option>'
8802 + '</select></label>';
8803 var canvas = makeOverlay('Submodule Breakdown \u2014 Full View', modalH, null, ctrlHtml);
8804 if(!canvas) return;
8805 var modalY = document.getElementById('sub-modal-y');
8806 var modalSort = document.getElementById('sub-modal-sort');
8807 if(modalY) modalY.value = initY;
8808 if(modalSort) modalSort.value = initSort;
8809 var subModalChart = null;
8810 function renderSubModal(yKey, sortMode) {
8811 if(subModalChart) { subModalChart.destroy(); subModalChart = null; }
8812 var data = SUB_D.slice();
8813 if(sortMode==='desc') data.sort(function(a,b){return (b[yKey]||0)-(a[yKey]||0);});
8814 else if(sortMode==='asc') data.sort(function(a,b){return (a[yKey]||0)-(b[yKey]||0);});
8815 else data.sort(function(a,b){return a.name.localeCompare(b.name);});
8816 data = data.slice(0, 30);
8817 var c = clr(), col = SUB_COLS[yKey]||OX, hcol = SUB_HCOLS[yKey]||'#d97020';
8818 subModalChart = new Chart(canvas, {
8819 type: 'bar',
8820 data: {
8821 labels: data.map(function(d){return d.name;}),
8822 datasets: [{ label: Y_LABELS[yKey]||yKey,
8823 data: data.map(function(d){return d[yKey]||0;}),
8824 backgroundColor: col, hoverBackgroundColor: hcol, borderRadius: 3 }]
8825 },
8826 options: {
8827 indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8828 onHover: chartCursor,
8829 animation: { duration: 500, easing: 'easeOutQuart' },
8830 transitions: { active: { animation: { duration: 180, easing: 'easeOutQuart' } } },
8831 layout: { padding: { right: 72 } },
8832 scales: {
8833 x: { grid:{color:c.grid}, ticks:{color:c.text, callback:function(v){return fmt(v);}},
8834 title:{display:true, text:Y_LABELS[yKey]||yKey, color:c.text} },
8835 y: { grid:{display:false}, ticks:{color:c.text} }
8836 },
8837 plugins: {
8838 legend:{display:false},
8839 tooltip: { callbacks: {
8840 title: function(items){return items.length?items[0].label:'';},
8841 label: function(ctx){
8842 var d = data[ctx.dataIndex]||{};
8843 return [' Code: '+Number(d.code||0).toLocaleString(),
8844 ' Comments: '+Number(d.comment||0).toLocaleString(),
8845 ' Blanks: '+Number(d.blank||0).toLocaleString(),
8846 ' Physical: '+Number(d.physical||0).toLocaleString(),
8847 ' Files: '+fmt(d.files||0)];
8848 }
8849 }}
8850 }
8851 },
8852 plugins: [makeDlPlugin(function(v){return fmt(v||0);}, 'end'), barJumpPlugin]
8853 });
8854 }
8855 renderSubModal(initY, initSort);
8856 if(modalY) modalY.addEventListener('change', function(){ renderSubModal(this.value, modalSort ? modalSort.value : 'desc'); });
8857 if(modalSort) modalSort.addEventListener('change', function(){ renderSubModal(modalY ? modalY.value : 'code', this.value); });
8858 });
8859 })();
8860
8861 // Submodule Composition — Full View (Chart.js with sort control)
8862 (function(){
8863 var btn = document.getElementById('sub-comp-expand-btn');
8864 if(!btn || !SUB_D || !SUB_D.length) return;
8865 btn.addEventListener('click', function(){
8866 var n = Math.min(SUB_D.length, 20);
8867 var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
8868 var modalH = Math.min(Math.max(400, n * 40 + 90), maxH);
8869 var ctrlHtml = '<label style="font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:6px;flex-shrink:0;">Sort:'
8870 + '<select class="chart-select" id="sub-comp-modal-sort">'
8871 + '<option value="desc">Total Lines \u2193</option>'
8872 + '<option value="asc">Total Lines \u2191</option>'
8873 + '<option value="name">Name A\u2192Z</option>'
8874 + '</select></label>';
8875 var canvas = makeOverlay('Submodule Composition \u2014 Full View', modalH, null, ctrlHtml);
8876 if(!canvas) return;
8877 var modalSort = document.getElementById('sub-comp-modal-sort');
8878 var scModalChart = null;
8879 function renderSCModal(sortMode) {
8880 if(scModalChart) { scModalChart.destroy(); scModalChart = null; }
8881 var data = SUB_D.slice();
8882 if(sortMode==='asc') data.sort(function(a,b){return ((a.code||0)+(a.comment||0)+(a.blank||0))-((b.code||0)+(b.comment||0)+(b.blank||0));});
8883 else if(sortMode==='name') data.sort(function(a,b){return a.name.localeCompare(b.name);});
8884 else data.sort(function(a,b){return ((b.code||0)+(b.comment||0)+(b.blank||0))-((a.code||0)+(a.comment||0)+(a.blank||0));});
8885 data = data.slice(0, 20);
8886 var c = clr();
8887 scModalChart = new Chart(canvas, {
8888 type: 'bar',
8889 data: {
8890 labels: data.map(function(d){ return d.name; }),
8891 datasets: [
8892 { label:'Code', data:data.map(function(d){return d.code||0;}), backgroundColor:OX, hoverBackgroundColor:'#d97020', borderRadius:0, borderSkipped:false },
8893 { label:'Comments', data:data.map(function(d){return d.comment||0;}), backgroundColor:GN, hoverBackgroundColor:'#3a8a5e', borderRadius:0, borderSkipped:false },
8894 { label:'Blank', data:data.map(function(d){return d.blank||0;}), backgroundColor:GY, hoverBackgroundColor:'#999', borderRadius:0, borderSkipped:false }
8895 ]
8896 },
8897 options: {
8898 indexAxis:'y', responsive:true, maintainAspectRatio:false,
8899 onHover: chartCursor,
8900 animation: { duration: 500, easing: 'easeOutQuart' },
8901 transitions: { active: { animation: { duration: 180, easing: 'easeOutQuart' } } },
8902 layout:{ padding:{ right:72 } },
8903 scales: {
8904 x:{ stacked:true, grid:{color:c.grid}, ticks:{color:c.text, callback:function(v){return fmt(v);}} },
8905 y:{ stacked:true, grid:{display:false}, ticks:{color:c.text} }
8906 },
8907 plugins: {
8908 legend:{ position:'bottom', labels:{color:c.text, usePointStyle:true, pointStyle:'rect', font:{size:11,weight:'700'}, padding:16},
8909 onHover:function(e,item,leg){ legendCursorOn(e); var ch=leg.chart,di=item.datasetIndex; var orig=[OX,GN,GY],hov=['#d97020','#3a8a5e','#999']; ch.data.datasets.forEach(function(ds,i){ ds.backgroundColor=i===di?orig[i]:hexAlpha(orig[i],0.15); ds.hoverBackgroundColor=i===di?hov[i]:hexAlpha(orig[i],0.15); }); var n=ch.data.datasets.length,ae=[]; for(var ii=0;ii<n;ii++){ae.push({datasetIndex:ii,index:0});} var fp=ch.getDatasetMeta(di).data[0]; ch.setActiveElements([{datasetIndex:di,index:0}]); ch.tooltip.setActiveElements(ae,fp?{x:fp.x,y:fp.y}:{x:0,y:0}); ch.update(); },
8910 onLeave:function(e,item,leg){ legendCursorOff(e); var ch=leg.chart; var orig=[OX,GN,GY],hov=['#d97020','#3a8a5e','#999']; ch.data.datasets.forEach(function(ds,i){ ds.backgroundColor=orig[i]; ds.hoverBackgroundColor=hov[i]; }); ch.setActiveElements([]); ch.tooltip.setActiveElements([],{}); ch.update('none'); }
8911 },
8912 tooltip:{ mode:'index', callbacks:{
8913 title:function(items){return items.length?items[0].label:'';},
8914 label:function(ctx){return ' '+ctx.dataset.label+': '+Number(ctx.parsed.x||0).toLocaleString();},
8915 footer:function(items){var t=items.reduce(function(s,i){return s+(i.parsed.x||0);},0);return 'Total: '+Number(t).toLocaleString();}
8916 }}
8917 }
8918 },
8919 plugins: [makeStackedEndPlugin(function(v){return fmt(v);}), segLabelPlugin, rowDimPlugin]
8920 });
8921 }
8922 renderSCModal('desc');
8923 if(modalSort) modalSort.addEventListener('change', function(){ renderSCModal(this.value); });
8924 });
8925 })();
8926
8927 // Language overview (donut + line-mix) — clone both SVGs side-by-side
8928 (function(){
8929 var btn = document.getElementById('lang-overview-expand-btn');
8930 if(!btn) return;
8931 btn.addEventListener('click', function(){
8932 var src = document.getElementById('report-lang-overview');
8933 if(!src) return;
8934 var overlay = document.createElement('div');
8935 overlay.className = 'chart-modal-overlay';
8936 overlay.innerHTML = '<div class="chart-modal" style="max-width:1600px;"><button class="chart-modal-close" aria-label="Close">×</button><div class="chart-modal-header"><span class="chart-modal-title">Language Breakdown \u2014 Full View</span></div><div id="lang-overview-modal-wrap" style="width:100%;"></div></div>';
8937 document.body.appendChild(overlay);
8938 overlay.querySelector('.chart-modal-close').addEventListener('click', function(){ document.body.removeChild(overlay); });
8939 overlay.addEventListener('click', function(e){ if(e.target===overlay) document.body.removeChild(overlay); });
8940 var wrap = document.getElementById('lang-overview-modal-wrap');
8941 if(wrap) {
8942 wrap.innerHTML = src.innerHTML;
8943 var svgs = wrap.querySelectorAll('svg');
8944 for(var i=0;i<svgs.length;i++){
8945 svgs[i].removeAttribute('width');
8946 svgs[i].removeAttribute('height');
8947 svgs[i].style.cssText='display:block;width:100%;height:auto;';
8948 }
8949 var ov = wrap.querySelector('.r-lang-overview');
8950 if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
8951 var cells = wrap.querySelectorAll('.r-lang-overview-cell');
8952 if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
8953 if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
8954 wireDonutLegend(wrap.querySelector('svg'));
8955 wireMixLegend(wrap.querySelectorAll('svg')[1]);
8956 requestAnimationFrame(function(){
8957 var ss=wrap.querySelectorAll('svg');
8958 if(ss.length>=2){var bh=ss[1].getBoundingClientRect().height;if(bh>0){ss[0].style.cssText='display:block;height:'+bh+'px;width:auto;max-width:100%;';}}
8959 });
8960 }
8961 });
8962 })();
8963 })();
8964
8965 // ── Dark mode sync ────────────────────────────────────────────────────────
8966 document.querySelectorAll('[data-theme-toggle]').forEach(function(btn) {
8967 btn.addEventListener('click', function() {
8968 setTimeout(function() {
8969 var c = clr();
8970 ALL_CHARTS.forEach(function(chart) {
8971 if (chart.options.scales) {
8972 Object.keys(chart.options.scales).forEach(function(k) {
8973 var ax = chart.options.scales[k];
8974 if (ax.grid) ax.grid.color = c.grid;
8975 if (ax.ticks) ax.ticks.color = c.text;
8976 if (ax.title) ax.title.color = c.text;
8977 });
8978 }
8979 if (chart.options.plugins && chart.options.plugins.legend && chart.options.plugins.legend.labels)
8980 chart.options.plugins.legend.labels.color = c.text;
8981 chart.update('none');
8982 });
8983 }, 60);
8984 });
8985 });
8986
8987 // ── Pre-render all chart variants for PDF export ──────────────────────────
8988 (function() {
8989 var root = document.getElementById('pdf-variants');
8990 if (!root) return;
8991
8992 // Plugin: fill a light background behind every off-screen chart so the PNG
8993 // is opaque — without this Chart.js canvases are transparent and render as
8994 // blank white boxes in print.
8995 var PDF_BG = {
8996 id: 'pdfBg',
8997 beforeDraw: function(ch) {
8998 var ctx = ch.canvas.getContext('2d');
8999 ctx.save();
9000 ctx.globalCompositeOperation = 'destination-over';
9001 ctx.fillStyle = '#faf6f0';
9002 ctx.fillRect(0, 0, ch.canvas.width, ch.canvas.height);
9003 ctx.restore();
9004 }
9005 };
9006
9007 // Off-screen Chart.js render → PNG data-URL → destroy chart
9008 function snap(type, data, opts, w, h) {
9009 var c = document.createElement('canvas');
9010 c.width = w || 900; c.height = h || 280;
9011 var ch = new Chart(c, {
9012 type: type, data: data,
9013 options: Object.assign({}, opts, {
9014 animation: false, responsive: false, devicePixelRatio: 1,
9015 // Breathing room so labels never clip at the canvas edge
9016 layout: { padding: { top: 10, right: 18, bottom: 10, left: 10 } }
9017 }),
9018 plugins: [PDF_BG]
9019 });
9020 var png = c.toDataURL('image/png');
9021 ch.destroy();
9022 return png;
9023 }
9024
9025 function mkPanel(label, imgSrc) {
9026 var d = document.createElement('div'); d.className = 'pdf-variant-panel';
9027 if (label) {
9028 var lbl = document.createElement('div');
9029 lbl.className = 'pdf-variant-label'; lbl.textContent = label;
9030 d.appendChild(lbl);
9031 }
9032 if (imgSrc) {
9033 var img = document.createElement('img');
9034 img.className = 'pdf-variant-img'; img.src = imgSrc;
9035 d.appendChild(img);
9036 }
9037 return d;
9038 }
9039
9040 function mkGroup(title) {
9041 var g = document.createElement('div'); g.className = 'pdf-variant-group';
9042 var h = document.createElement('h2'); h.className = 'pdf-variant-group-title'; h.textContent = title;
9043 g.appendChild(h);
9044 var grid = document.createElement('div'); grid.className = 'pdf-variant-grid';
9045 g.appendChild(grid);
9046 return { group: g, grid: grid };
9047 }
9048
9049 var tc = '#43342d', gc = 'rgba(0,0,0,0.07)';
9050
9051 // ── Project Overview — 4 Y-axis variants ─────────────────────────────────
9052 var pgProj = mkGroup('Project Overview');
9053 var projVariants = [
9054 { label:'Code Lines', fn:function(d){return d.code||0;} },
9055 { label:'Comment Lines', fn:function(d){return d.comments||0;} },
9056 { label:'Physical Lines', fn:function(d){return (d.code||0)+(d.comments||0)+(d.blanks||0);} },
9057 { label:'File Count', fn:function(d){return d.files||0;} }
9058 ];
9059 projVariants.forEach(function(y) {
9060 var sorted = D.slice().sort(function(a,b){return y.fn(b)-y.fn(a);});
9061 var h = Math.max(110, Math.min(360, sorted.length*18+40));
9062 var png = snap('bar', {
9063 labels: sorted.map(function(d){return d.lang;}),
9064 datasets:[{ label:y.label, data:sorted.map(y.fn),
9065 backgroundColor:sorted.map(function(_,i){return PALETTE[i%PALETTE.length];}), borderRadius:3 }]
9066 }, {
9067 indexAxis:'y',
9068 scales:{
9069 x:{grid:{color:gc},ticks:{color:tc,callback:function(v){return fmt(v);}},title:{display:true,text:y.label,color:tc}},
9070 y:{grid:{display:false},ticks:{color:tc}}
9071 },
9072 plugins:{legend:{display:false}}
9073 }, 900, h);
9074 pgProj.grid.appendChild(mkPanel(y.label, png));
9075 });
9076 root.appendChild(pgProj.group);
9077
9078 // ── Language Composition — Absolute Lines + Composition % ────────────────
9079 var pgComp = mkGroup('Language Composition');
9080 var cData = D.slice(0,15);
9081 var totFn = function(d){return (d.code||0)+(d.comments||0)+(d.blanks||0)||1;};
9082 var compH = Math.max(110, Math.min(340, cData.length*18+50));
9083 [{id:'absolute',label:'Absolute Lines',isPct:false},{id:'pct',label:'Composition %',isPct:true}]
9084 .forEach(function(m) {
9085 var pct = m.isPct;
9086 var png = snap('bar', {
9087 labels: cData.map(function(d){return d.lang;}),
9088 datasets:[
9089 {label:'Code', data:cData.map(function(d){return pct?(d.code||0)/totFn(d)*100:d.code||0;}), backgroundColor:OX,borderRadius:3},
9090 {label:'Comments', data:cData.map(function(d){return pct?(d.comments||0)/totFn(d)*100:d.comments||0;}),backgroundColor:GN,borderRadius:3},
9091 {label:'Blanks', data:cData.map(function(d){return pct?(d.blanks||0)/totFn(d)*100:d.blanks||0;}), backgroundColor:GY,borderRadius:3}
9092 ]
9093 }, {
9094 indexAxis:'y',
9095 scales:{
9096 x:{stacked:true,grid:{color:gc},ticks:{color:tc,callback:pct?function(v){return v.toFixed(0)+'%';}:function(v){return fmt(v);}}},
9097 y:{stacked:true,grid:{display:false},ticks:{color:tc}}
9098 },
9099 plugins:{legend:{position:'bottom',labels:{color:tc}}}
9100 }, 900, compH);
9101 pgComp.grid.appendChild(mkPanel(m.label, png));
9102 });
9103 root.appendChild(pgComp.group);
9104
9105 // ── Files vs Code Lines — render off-screen (bubble chart, single-col centred) ─
9106 if (SCAT_D && SCAT_D.length) {
9107 var pgScat = mkGroup('Files vs Code Lines');
9108 pgScat.grid.classList.add('single-col'); // CSS class drives centering in print
9109 var maxP = Math.max.apply(null, SCAT_D.map(function(d){return d.physical||0;})) || 1;
9110 var scatPng = snap('bubble', {
9111 datasets: SCAT_D.map(function(d, i) {
9112 return {
9113 label: d.lang,
9114 data: [{ x: d.files, y: d.code, r: Math.max(5, Math.round(Math.sqrt((d.physical||0)/maxP)*20)) }],
9115 backgroundColor: PALETTE[i % PALETTE.length] + 'b8',
9116 borderColor: PALETTE[i % PALETTE.length], borderWidth: 1
9117 };
9118 })
9119 }, {
9120 scales: {
9121 x: { grid:{color:gc}, ticks:{color:tc}, title:{display:true, text:'Files Analyzed', color:tc} },
9122 y: { grid:{color:gc}, ticks:{color:tc, callback:function(v){return fmt(v);}}, title:{display:true, text:'Code Lines', color:tc} }
9123 },
9124 plugins: { legend:{position:'right', labels:{color:tc, boxWidth:12}} }
9125 }, 900, 260);
9126 pgScat.grid.appendChild(mkPanel('Files \u00d7 Code Lines (bubble size \u221d physical lines)', scatPng));
9127 root.appendChild(pgScat.group);
9128 }
9129
9130 // ── Semantic Metrics — up to 5 metrics, skip empty ones ─────────────────
9131 if (SEM_D && SEM_D.length) {
9132 var pgSem = mkGroup('Semantic Metrics');
9133 var SL={functions:'Functions',classes:'Classes / Types',variables:'Variables',imports:'Imports',tests:'Tests'};
9134 var SC={functions:OX,classes:'#4472C4',variables:GN,imports:'#805099',tests:'#B23030'};
9135 Object.keys(SL).forEach(function(mKey) {
9136 var data = SEM_D.slice().sort(function(a,b){return (b[mKey]||0)-(a[mKey]||0);}).slice(0,15);
9137 if (!data.some(function(d){return (d[mKey]||0)>0;})) return;
9138 var semH = 210; // vertical bar — fixed height; width drives layout, not row count
9139 var png = snap('bar', {
9140 labels: data.map(function(d){return d.lang;}),
9141 datasets:[{label:SL[mKey],data:data.map(function(d){return d[mKey]||0;}),backgroundColor:SC[mKey],borderRadius:4}]
9142 }, {
9143 scales:{
9144 x:{grid:{display:false},ticks:{color:tc}},
9145 y:{grid:{color:gc},ticks:{color:tc,callback:function(v){return fmt(v);}}}
9146 },
9147 plugins:{legend:{display:false}}
9148 }, 900, semH);
9149 pgSem.grid.appendChild(mkPanel(SL[mKey], png));
9150 });
9151 root.appendChild(pgSem.group);
9152 }
9153
9154 // ── Submodule Breakdown — 3 Y-axis variants + donut SVG clone ────────────
9155 if (SUB_D && SUB_D.length) {
9156 var pgSub = mkGroup('Submodule Breakdown');
9157 [{key:'code',label:'Code Lines',col:OX},{key:'comment',label:'Comment Lines',col:GN},{key:'files',label:'File Count',col:'#805099'}]
9158 .forEach(function(y) {
9159 var data = SUB_D.slice().sort(function(a,b){return (b[y.key]||0)-(a[y.key]||0);}).slice(0,30);
9160 if (!data.length) return;
9161 var subH = Math.max(100, Math.min(420, data.length*16+30));
9162 var png = snap('bar', {
9163 labels: data.map(function(d){return d.name;}),
9164 datasets:[{label:y.label,data:data.map(function(d){return d[y.key]||0;}),backgroundColor:y.col,borderRadius:3}]
9165 }, {
9166 indexAxis:'y',
9167 scales:{
9168 x:{grid:{color:gc},ticks:{color:tc,callback:function(v){return fmt(v);}},title:{display:true,text:y.label,color:tc}},
9169 y:{grid:{display:false},ticks:{color:tc}}
9170 },
9171 plugins:{legend:{display:false}}
9172 }, 900, subH);
9173 pgSub.grid.appendChild(mkPanel(y.label, png));
9174 });
9175 var donutEl = document.getElementById('submodule-donut');
9176 if (donutEl && donutEl.innerHTML.trim()) {
9177 var dp = document.createElement('div'); dp.className = 'pdf-variant-panel';
9178 var dl = document.createElement('div'); dl.className = 'pdf-variant-label'; dl.textContent = 'Distribution';
9179 dp.appendChild(dl);
9180 var dw = document.createElement('div'); dw.style.cssText = 'display:flex;justify-content:center;';
9181 dw.innerHTML = donutEl.innerHTML; dp.appendChild(dw);
9182 pgSub.grid.appendChild(dp);
9183 }
9184 root.appendChild(pgSub.group);
9185 }
9186 })();
9187 })();
9188 window.oxSlocChartsReady = true;
9189 } catch(e) { window.oxSlocChartError = String(e); window.oxSlocChartsReady = true; }
9190 }); // end requestAnimationFrame
9191 // Safety net: if rAF never fires (headless browsers throttle it), mark ready
9192 // unconditionally so the PDF capture does not wait the full 15 s.
9193 setTimeout(function() { if (!window.oxSlocChartsReady) window.oxSlocChartsReady = true; }, 3000);
9194 // ── SVG tooltip delegation ───────────────────────────────────────────────
9195 (function(){
9196 var tt = document.getElementById('r-tt');
9197 if (!tt) return;
9198 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
9199 function show(e, html) { tt.innerHTML=html; tt.style.display='block'; move(e); }
9200 function hide() { tt.style.display='none'; }
9201 function move(e) {
9202 var x=e.clientX+16, y=e.clientY-12;
9203 var r=tt.getBoundingClientRect();
9204 if (x+r.width>window.innerWidth-8) x=e.clientX-r.width-8;
9205 if (y+r.height>window.innerHeight-8) y=e.clientY-r.height-8;
9206 tt.style.left=x+'px'; tt.style.top=y+'px';
9207 }
9208 document.addEventListener('mouseover', function(e) {
9209 var t=e.target;
9210 while(t&&t.getAttribute){
9211 var l=t.getAttribute('data-ttl');
9212 if(l!==null){ show(e,'<strong>'+escH(l)+'</strong><br>'+escH(t.getAttribute('data-ttv')||'').replace(/\n/g,'<br>')); return; }
9213 t=t.parentNode;
9214 }
9215 });
9216 document.addEventListener('mouseout', function(e) {
9217 var t=e.target;
9218 while(t&&t.getAttribute){
9219 if(t.getAttribute('data-ttl')!==null){ hide(); return; }
9220 t=t.parentNode;
9221 }
9222 });
9223 document.addEventListener('mousemove', function(e) {
9224 if(tt.style.display!=='none') move(e);
9225 });
9226 window.addEventListener('blur', function() { hide(); });
9227 document.addEventListener('visibilitychange', function() { if(document.hidden) hide(); });
9228 })();
9229 // Auto-populate title on any td that is visually truncated but has no explicit title
9230 requestAnimationFrame(function() {
9231 document.querySelectorAll('td').forEach(function(td) {
9232 if (!td.title && td.scrollWidth > td.clientWidth) {
9233 td.title = td.textContent.trim();
9234 }
9235 });
9236 });
9237
9238 </script>
9239 <script nonce="{{ nonce }}">
9240 (function(){
9241 var S=[{n:'Classic',a:'#b85d33',b:'#7a371b'},{n:'Navy',a:'#283790',b:'#1e1e24'},{n:'Ember',a:'#ce5d3d',b:'#1e1e24'},{n:'Ocean',a:'#1f439b',b:'#1e1e24'},{n:'Royal',a:'#003184',b:'#1e1e24'}];
9242 function ap(s){document.documentElement.style.setProperty('--nav',s.a);document.documentElement.style.setProperty('--nav-2',s.b);try{localStorage.setItem('sloc-ns',JSON.stringify(s));}catch(e){}document.querySelectorAll('.scheme-swatch').forEach(function(x){x.classList.toggle('active',x.dataset.n===s.n);});}
9243 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
9244 function init(){
9245 var btn=document.getElementById('settings-btn');if(!btn)return;
9246 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
9247 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
9248 document.body.appendChild(m);
9249 var g=document.getElementById('scheme-grid');
9250 if(g)S.forEach(function(s){var el=document.createElement('button');el.type='button';el.className='scheme-swatch';el.dataset.n=s.n;el.title=s.n;var p=document.createElement('div');p.className='scheme-preview';p.style.background='linear-gradient(135deg,'+s.a+','+s.b+')';var l=document.createElement('span');l.className='scheme-label';l.textContent=s.n;el.appendChild(p);el.appendChild(l);try{var c=JSON.parse(localStorage.getItem('sloc-ns'));if(c&&c.n===s.n)el.classList.add('active');}catch(e){}el.addEventListener('click',function(){ap(s);});g.appendChild(el);});
9251 var cl=document.getElementById('settings-close');
9252 window.tzAbbr=function(z){return{'America/Los_Angeles':'PT','America/Denver':'MT','America/Chicago':'CT','America/New_York':'ET','America/Anchorage':'AT','Pacific/Honolulu':'HT'}[z]||'PT';};window.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';try{var pts=new Intl.DateTimeFormat('en-US',{timeZone:tz,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:false}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}catch(e){return'';}};window.applyTz=function(tz){try{localStorage.setItem('sloc-tz',tz);}catch(e){}document.querySelectorAll('[data-utc-ms]').forEach(function(el){var ms=parseInt(el.getAttribute('data-utc-ms'),10);if(!isNaN(ms))el.textContent=window.fmtTz(ms,tz);});};var tzSel=document.getElementById('tz-select');var storedTz;try{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){storedTz='America/Los_Angeles';}if(tzSel){tzSel.value=storedTz;tzSel.addEventListener('change',function(){window.applyTz(this.value);});}window.applyTz(storedTz);
9253 btn.addEventListener('click',function(e){e.stopPropagation();var r=btn.getBoundingClientRect();m.style.top=(r.bottom+6)+'px';m.style.right=(window.innerWidth-r.right)+'px';m.classList.toggle('open');});
9254 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
9255 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
9256 }
9257 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
9258 }());
9259 </script>
9260 <script nonce="{{ nonce }}">
9261 (function(){
9262 // Format delta card unmodified-lines value with comma separators
9263 Array.prototype.slice.call(document.querySelectorAll('.delta-card-inline[data-raw] .delta-card-val')).forEach(function(el){
9264 var raw=parseInt(el.parentNode.getAttribute('data-raw'),10);
9265 if(!isNaN(raw))el.textContent=raw.toLocaleString();
9266 });
9267 // Format code-before / code-now numbers in the prev-scan summary line
9268 Array.prototype.slice.call(document.querySelectorAll('.prev-scan-summary [data-raw]')).forEach(function(el){
9269 var raw=parseInt(el.getAttribute('data-raw'),10);
9270 if(!isNaN(raw))el.textContent=raw.toLocaleString();
9271 });
9272 }());
9273 </script>
9274 {% if has_style_data %}
9275 <script nonce="{{ nonce }}">
9276 (function(){
9277 var CHART_DATA = {{ style_chart_json|safe }};
9278 var FILE_DATA = {{ style_file_json|safe }};
9279 var SCORE_THRESHOLD = {{ style_score_threshold }};
9280 var activeLang = CHART_DATA.length ? CHART_DATA[0].family : '';
9281 var sftSortKey = '';
9282 var sftSortDir = 1;
9283 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
9284 // Official style guide URLs — covers every guide produced by the language analysers
9285 var GUIDE_URLS = {
9286 'PEP 8':'https://peps.python.org/pep-0008/',
9287 'PEP 8 (99-col)':'https://peps.python.org/pep-0008/',
9288 'Black':'https://black.readthedocs.io/en/stable/the_black_code_style/current_style.html',
9289 'Google Python':'https://google.github.io/styleguide/pyguide.html',
9290 'Effective Go':'https://go.dev/doc/effective_go',
9291 'Uber Go':'https://github.com/uber-go/guide/blob/master/style.md',
9292 'Google Go':'https://google.github.io/styleguide/go/',
9293 'LLVM':'https://llvm.org/docs/CodingStandards.html',
9294 'Google':'https://google.github.io/styleguide/cppguide.html',
9295 'Mozilla':'https://firefox-source-docs.mozilla.org/code-quality/coding-style/',
9296 'Microsoft':'https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions',
9297 'WebKit':'https://webkit.org/code-style-guidelines/',
9298 'rustfmt defaults':'https://doc.rust-lang.org/rustfmt/',
9299 'Mozilla Rust':'https://firefox-source-docs.mozilla.org/code-quality/coding-style/coding-style-rust.html',
9300 'Rust API Guidelines':'https://rust-lang.github.io/api-guidelines/',
9301 'Relaxed (120-col)':'https://doc.rust-lang.org/rustfmt/',
9302 'Airbnb':'https://airbnb.io/javascript/',
9303 'Google JS':'https://google.github.io/styleguide/jsguide.html',
9304 'Standard.js':'https://standardjs.com/',
9305 'Prettier':'https://prettier.io/docs/en/options.html',
9306 'Airbnb TS':'https://airbnb.io/javascript/',
9307 'Google TS':'https://google.github.io/styleguide/tsguide.html',
9308 'Angular':'https://angular.dev/style-guide',
9309 'Microsoft TS':'https://github.com/Microsoft/TypeScript/wiki/Coding-guidelines',
9310 'Google Java':'https://google.github.io/styleguide/javaguide.html',
9311 'Oracle/Sun':'https://www.oracle.com/java/technologies/javase/codeconventions-contents.html',
9312 'Spring':'https://github.com/spring-projects/spring-framework/wiki/Code-Style',
9313 'JetBrains':'https://www.jetbrains.com/help/idea/code-style.html',
9314 'Android':'https://source.android.com/docs/setup/contribute/code-style',
9315 'Google Kotlin':'https://developer.android.com/kotlin/style-guide',
9316 'Apache Groovy':'https://groovy-lang.org/style-guide.html',
9317 'Gradle DSL':'https://docs.gradle.org/current/userguide/groovy_build_script_primer.html',
9318 'Scala Style Guide':'https://docs.scala-lang.org/style/',
9319 'Lightbend':'https://docs.scala-lang.org/style/',
9320 'Spark':'https://spark.apache.org/contributing.html',
9321 'RuboCop':'https://docs.rubocop.org/rubocop/',
9322 'Airbnb Ruby':'https://github.com/airbnb/ruby',
9323 'Standard Ruby':'https://github.com/standardrb/standard',
9324 'Microsoft .NET':'https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions',
9325 'Google C#':'https://google.github.io/styleguide/csharp-style.html',
9326 'StyleCop':'https://github.com/DotNetAnalyzers/StyleCopAnalyzers',
9327 'Microsoft F#':'https://learn.microsoft.com/en-us/dotnet/fsharp/style-guide/formatting',
9328 'FSharp.Formatting':'https://fsprojects.github.io/FSharp.Formatting/'
9329 };
9330 // Human-readable descriptions for each guide shown in bar tooltips
9331 var GUIDE_DESC = {
9332 'PEP 8':'4-space | 79-col | Python style standard',
9333 'PEP 8 (99-col)':'4-space | 99-col | relaxed line limit',
9334 'Black':'4-space | 88-col | double quotes enforced',
9335 'Google Python':'4-space | 80-col | double quotes preferred',
9336 'Effective Go':'tabs | ~80-col | gofmt standard',
9337 'Uber Go':'tabs | 120-col max',
9338 'Google Go':'tabs | 80-col',
9339 'LLVM':'2-space | 80-col | C/C++ LLVM project style',
9340 'Google':'2-space | 80-col | Google C++ style',
9341 'Mozilla':'4-space | 80-col | Firefox codebase style',
9342 'Microsoft':'4-space | Allman braces | C++ Win32 style',
9343 'WebKit':'4-space | 80-col | WebKit engine style',
9344 'rustfmt defaults':'4-space | 100-col | official Rust formatter',
9345 'Mozilla Rust':'4-space | 100-col | Firefox Rust style',
9346 'Rust API Guidelines':'4-space | naming + docs conventions',
9347 'Relaxed (120-col)':'4-space | 120-col | relaxed line limit',
9348 'Airbnb':'2-space | single quotes | no semicolons opt',
9349 'Google JS':'2-space | 80-col | single quotes',
9350 'Standard.js':'2-space | no semicolons | single quotes',
9351 'Prettier':'2-space | 80-col | double quotes | semicolons',
9352 'Airbnb TS':'2-space | single quotes | TypeScript variant',
9353 'Google TS':'2-space | 80-col | single quotes | TypeScript',
9354 'Angular':'2-space | Angular team TypeScript conventions',
9355 'Microsoft TS':'4-space | TypeScript compiler team style',
9356 'Google Java':'2-space | 100-col | Google Java guide',
9357 'Oracle/Sun':'4-space | 80-col | original Java conventions',
9358 'Spring':'4-space | Spring Framework code style',
9359 'JetBrains':'4-space | IntelliJ default Java style',
9360 'Android':'4-space | 100-col | AOSP Java style',
9361 'Google Kotlin':'4-space | 100-col | Android Kotlin style',
9362 'Apache Groovy':'4-space | Apache Groovy style',
9363 'Gradle DSL':'4-space | Gradle build script conventions',
9364 'Scala Style Guide':'2-space | 100-col | official Scala style',
9365 'Lightbend':'2-space | Lightbend/Akka Scala style',
9366 'Spark':'2-space | Apache Spark Scala style',
9367 'RuboCop':'2-space | 120-col | community Ruby style',
9368 'Airbnb Ruby':'2-space | 80-col | Airbnb Ruby guide',
9369 'Standard Ruby':'2-space | 80-col | StandardRB formatter',
9370 'Microsoft .NET':'4-space | Allman braces | .NET C# style',
9371 'Google C#':'2-space | Google C# style guide',
9372 'StyleCop':'4-space | StyleCop analyzer rules',
9373 'Microsoft F#':'4-space | official F# formatting guide',
9374 'FSharp.Formatting':'4-space | FSharp.Formatting conventions'
9375 };
9376 function renderBars(family){
9377 var wrap=document.getElementById('style-guide-bars');
9378 if(!wrap)return;
9379 wrap.innerHTML='';
9380 var grp=null;
9381 for(var i=0;i<CHART_DATA.length;i++){if(CHART_DATA[i].family===family){grp=CHART_DATA[i];break;}}
9382 if(!grp||!grp.guides.length)return;
9383 grp.guides.forEach(function(d){
9384 var isTop=(d.guide===grp.dominant);
9385 var row=document.createElement('div');row.className='style-guide-row';
9386 // Hover tooltip showing guide name + score + description
9387 var tip=document.createElement('div');tip.className='style-bar-tip';
9388 var desc=GUIDE_DESC[d.guide]||'';
9389 tip.textContent=d.guide+': '+d.score+'%'+(desc?' \u00b7 '+desc:'');
9390 var lbl=document.createElement('div');lbl.className='style-guide-label';
9391 lbl.textContent=d.guide;
9392 if(isTop)lbl.style.color='var(--oxide)';
9393 var track=document.createElement('div');track.className='style-guide-track';
9394 var fill=document.createElement('div');fill.className='style-guide-fill';
9395 fill.style.width='0%';
9396 var pct=document.createElement('div');pct.className='style-guide-score';
9397 pct.textContent=d.score+'%';
9398 if(isTop)pct.style.color='var(--oxide)';
9399 track.appendChild(fill);
9400 row.appendChild(tip);
9401 row.appendChild(lbl);row.appendChild(track);row.appendChild(pct);
9402 wrap.appendChild(row);
9403 setTimeout(function(f,s){return function(){f.style.width=s+'%';};}(fill,d.score),60);
9404 });
9405 }
9406 function initTabs(){
9407 var tabsWrap=document.getElementById('style-lang-tabs');
9408 if(!tabsWrap||!CHART_DATA.length)return;
9409 CHART_DATA.forEach(function(grp){
9410 var btn=document.createElement('button');
9411 btn.className='style-lang-tab'+(grp.family===activeLang?' active':'');
9412 btn.textContent=grp.family+' ('+grp.files+')';
9413 btn.onclick=function(){
9414 activeLang=grp.family;
9415 var tabs=tabsWrap.querySelectorAll('.style-lang-tab');
9416 for(var i=0;i<tabs.length;i++)tabs[i].className='style-lang-tab';
9417 btn.className='style-lang-tab active';
9418 renderBars(activeLang);
9419 };
9420 tabsWrap.appendChild(btn);
9421 });
9422 renderBars(activeLang);
9423 }
9424 function buildGuideHtml(guide){
9425 if(!guide||guide==='\u2014'||guide==='Unknown')return'<span style="color:var(--muted);">\u2014</span>';
9426 var url=GUIDE_URLS[guide];
9427 var desc=GUIDE_DESC[guide]||'';
9428 var tipText='Open official '+guide+' documentation'+(desc?' \u00b7 '+desc:'');
9429 if(url){return'<a href="'+escH(url)+'" target="_blank" rel="noopener" class="style-badge">'+escH(guide)+'</a>';}
9430 return'<span class="style-badge">'+escH(guide)+'</span>';
9431 }
9432 function buildSigsHtml(sigs){
9433 if(!sigs||!sigs.length)return'<span style="color:var(--muted);">\u2014</span>';
9434 var html='';
9435 var visible=sigs.slice(0,2);
9436 var rest=sigs.slice(2);
9437 visible.forEach(function(s){html+='<span class="style-sig-chip">'+escH(s.v)+'</span>';});
9438 if(rest.length){html+='<span style="color:var(--muted);font-size:11px;margin-left:2px;">\u22EF</span>';}
9439 return html;
9440 }
9441 var _sigPop=null;
9442 window.showSigPop=function(btn,ev){
9443 ev.stopPropagation();
9444 if(_sigPop){var prev=_sigPop;_sigPop=null;prev.remove();if(btn._ownPop===prev)return;}
9445 var sigs;try{sigs=JSON.parse(btn.getAttribute('data-sigs'));}catch(e){return;}
9446 var pop=document.createElement('div');
9447 pop.className='style-sig-pop';
9448 pop.setAttribute('role','tooltip');
9449 var inner='<div class="style-sig-pop-title">All Signals</div>';
9450 sigs.forEach(function(s){
9451 inner+='<div class="style-sig-pop-row"><span class="style-sig-pop-key">'+escH(s.k)+':</span><span class="style-sig-pop-val">'+escH(s.v)+'</span></div>';
9452 });
9453 pop.innerHTML=inner;
9454 document.body.appendChild(pop);
9455 _sigPop=pop;
9456 btn._ownPop=pop;
9457 var r=btn.getBoundingClientRect();
9458 var pw=pop.offsetWidth||220;
9459 var left=r.left;
9460 if(left+pw>window.innerWidth-8)left=window.innerWidth-pw-8;
9461 if(left<8)left=8;
9462 var top=r.bottom+6;
9463 if(top+(pop.offsetHeight||120)>window.innerHeight-8)top=r.top-(pop.offsetHeight||120)-6;
9464 pop.style.left=left+'px';
9465 pop.style.top=top+'px';
9466 function dismiss(e){if(!pop.contains(e.target)){pop.remove();if(_sigPop===pop)_sigPop=null;document.removeEventListener('click',dismiss);document.removeEventListener('keydown',dismissKey);}}
9467 function dismissKey(e){if(e.key==='Escape'){pop.remove();if(_sigPop===pop)_sigPop=null;document.removeEventListener('click',dismiss);document.removeEventListener('keydown',dismissKey);}}
9468 setTimeout(function(){document.addEventListener('click',dismiss);document.addEventListener('keydown',dismissKey);},0);
9469 }
9470 var sftRows=[];
9471 var sftFilteredRows=[];
9472 var sftCurrentPage=1;
9473 function sftGetPageSize(){
9474 var sel=document.getElementById('sft-page-size');
9475 var v=sel?sel.value:'20';
9476 return v==='all'?Infinity:parseInt(v,10);
9477 }
9478 function sftApplyFilter(){
9479 var inp=document.getElementById('sft-search');
9480 var q=inp?inp.value.toLowerCase():'';
9481 var sorted=sftRows.slice();
9482 if(sftSortKey){
9483 sorted.sort(function(a,b){
9484 if(sftSortKey==='score'){var av=a.score||0,bv=b.score||0;return sftSortDir*(av-bv);}
9485 var av=String(a[sftSortKey]||'').toLowerCase(),bv=String(b[sftSortKey]||'').toLowerCase();
9486 return av<bv?-1*sftSortDir:av>bv?1*sftSortDir:0;
9487 });
9488 }
9489 sftFilteredRows=q===''?sorted:sorted.filter(function(f){
9490 return (f.path||'').toLowerCase().indexOf(q)>=0
9491 ||(f.lang||'').toLowerCase().indexOf(q)>=0
9492 ||(f.guide||'').toLowerCase().indexOf(q)>=0
9493 ||(f.indent||'').toLowerCase().indexOf(q)>=0;
9494 });
9495 sftCurrentPage=1;
9496 renderSftTable();
9497 }
9498 function renderSftTable(){
9499 var tbody=document.getElementById('style-file-tbody');
9500 if(!tbody)return;
9501 var ps=sftGetPageSize();
9502 var total=sftFilteredRows.length;
9503 var totalAll=sftRows.length;
9504 var totalPages=ps===Infinity?1:Math.max(1,Math.ceil(total/ps));
9505 if(sftCurrentPage>totalPages)sftCurrentPage=totalPages;
9506 if(sftCurrentPage<1)sftCurrentPage=1;
9507 var start=ps===Infinity?0:(sftCurrentPage-1)*ps;
9508 var end=ps===Infinity?total:Math.min(start+ps,total);
9509 var page=sftFilteredRows.slice(start,end);
9510 var html='';
9511 page.forEach(function(f){
9512 var barW=Math.round(f.score);
9513 var guide=f.guide&&f.guide!=='Unknown'?f.guide:'';
9514 var badge=guide?buildGuideHtml(guide):'<span style="color:var(--muted);">\u2014</span>';
9515 var sigHtml=buildSigsHtml(f.signals);
9516 var rowClass=SCORE_THRESHOLD>0&&f.score<SCORE_THRESHOLD?' class="style-row-warn"':'';
9517 html+='<tr'+rowClass+'>'
9518 +'<td title="'+escH(f.path)+'">'+escH(f.path.replace(/^.*[\/\\]/,''))+'</td>'
9519 +'<td>'+escH(f.lang)+'</td>'
9520 +'<td>'+escH(f.indent)+'</td>'
9521 +'<td class="guide-cell" data-gtip="'+(guide?(escH(guide)+(GUIDE_DESC[guide]?' \u00b7 '+escH(GUIDE_DESC[guide]):'')):'')+'">' +badge+'</td>'
9522 +'<td><span class="style-score-bar"><span class="style-score-fill" style="width:'+barW+'%"></span></span>'+f.score+'%</td>'
9523 +'<td class="sig-cell" data-sigs="'+escH(JSON.stringify(f.signals||[]))+'">'+sigHtml+'</td>'
9524 +'</tr>';
9525 });
9526 tbody.innerHTML=html||'<tr><td colspan="6" style="text-align:center;color:var(--muted);padding:18px;">No style-analysed files</td></tr>';
9527 var pageInfo=document.getElementById('sft-page-info');
9528 var firstBtn=document.getElementById('sft-first');
9529 var prevBtn=document.getElementById('sft-prev');
9530 var nextBtn=document.getElementById('sft-next');
9531 var lastBtn=document.getElementById('sft-last');
9532 var jumpInput=document.getElementById('sft-page-jump');
9533 var pageTotal=document.getElementById('sft-page-total');
9534 var countLabel=document.getElementById('sft-count-label');
9535 if(pageInfo){
9536 if(total===0){pageInfo.textContent='No results';}
9537 else if(ps===Infinity){pageInfo.textContent='All '+total.toLocaleString()+' files';}
9538 else{pageInfo.textContent=(start+1)+'\u2013'+end+' of '+total.toLocaleString()+' files';}
9539 }
9540 if(countLabel){countLabel.textContent=(total<totalAll&&total>0)?'('+total.toLocaleString()+' matching)':'';}
9541 var edgeOff=ps===Infinity;
9542 if(firstBtn)firstBtn.disabled=sftCurrentPage<=1||edgeOff;
9543 if(prevBtn)prevBtn.disabled=sftCurrentPage<=1||edgeOff;
9544 if(nextBtn)nextBtn.disabled=sftCurrentPage>=totalPages||edgeOff;
9545 if(lastBtn)lastBtn.disabled=sftCurrentPage>=totalPages||edgeOff;
9546 if(jumpInput){jumpInput.value=sftCurrentPage;jumpInput.max=totalPages;jumpInput.disabled=edgeOff;}
9547 if(pageTotal)pageTotal.textContent=totalPages.toLocaleString();
9548 }
9549 function initStyleTable(){
9550 if(!FILE_DATA.length){
9551 var tb=document.getElementById('style-file-tbody');
9552 if(tb)tb.innerHTML='<tr><td colspan="6" style="text-align:center;color:var(--muted);padding:18px;">No style-analysed files</td></tr>';
9553 return;
9554 }
9555 sftRows=FILE_DATA.slice();
9556 sftFilteredRows=sftRows.slice();
9557 sftApplyFilter();
9558 // Signal & guide cell tooltip (appears above hovered cell, arrow points down)
9559 var chipTipEl=document.createElement('div');
9560 chipTipEl.className='sig-tip';
9561 document.body.appendChild(chipTipEl);
9562 function _showSigTip(html,cell){
9563 chipTipEl.innerHTML=html;
9564 chipTipEl.style.display='block';
9565 var r=cell.getBoundingClientRect();
9566 var tw=chipTipEl.offsetWidth||220;
9567 var th=chipTipEl.offsetHeight||80;
9568 var cx=r.left+r.width/2;
9569 var left=cx-tw/2;
9570 if(left<8)left=8;
9571 if(left+tw>window.innerWidth-8)left=window.innerWidth-tw-8;
9572 var arrowPct=Math.round((cx-left)/tw*100);
9573 if(arrowPct<10)arrowPct=10;
9574 if(arrowPct>90)arrowPct=90;
9575 chipTipEl.style.setProperty('--sig-tip-ax',arrowPct+'%');
9576 var top=r.top-th-12;
9577 if(top<8)top=r.bottom+8;
9578 chipTipEl.style.left=left+'px';
9579 chipTipEl.style.top=top+'px';
9580 chipTipEl.classList.add('visible');
9581 }
9582 function _hideSigTip(){
9583 chipTipEl.classList.remove('visible');
9584 chipTipEl.style.display='none';
9585 }
9586 function _buildSigHtml(cell){
9587 var sigs;try{sigs=JSON.parse(cell.getAttribute('data-sigs'));}catch(ex){return null;}
9588 if(!sigs||!sigs.length)return null;
9589 var html='<div class="sig-tip-hd">Signals</div>';
9590 sigs.forEach(function(s){
9591 html+='<div class="sig-tip-row"><span class="sig-tip-k">'+escH(s.k)+':</span><span class="sig-tip-v">'+escH(s.v)+'</span></div>';
9592 });
9593 return html;
9594 }
9595 function _buildGuideHtml(cell){
9596 var tip=cell.getAttribute('data-gtip')||'';
9597 if(!tip)return null;
9598 var parts=tip.split(' \u00b7 ',2);
9599 var html='<div class="sig-tip-hd">'+escH(parts[0])+'</div>';
9600 if(parts[1])html+='<div class="sig-tip-v" style="font-size:11px;">'+escH(parts[1])+'</div>';
9601 return html;
9602 }
9603 var sigTbl=document.getElementById('style-file-table');
9604 if(sigTbl){
9605 sigTbl.addEventListener('mouseover',function(e){
9606 var sc=e.target.closest?e.target.closest('.sig-cell'):null;
9607 var gc=e.target.closest?e.target.closest('.guide-cell'):null;
9608 if(sc){var h=_buildSigHtml(sc);if(h)_showSigTip(h,sc);return;}
9609 if(gc){var h=_buildGuideHtml(gc);if(h){var badge=gc.querySelector('.style-badge')||gc;_showSigTip(h,badge);}return;}
9610 _hideSigTip();
9611 });
9612 sigTbl.addEventListener('mouseleave',function(){
9613 _hideSigTip();
9614 });
9615 sigTbl.addEventListener('mouseout',function(e){
9616 if(!e.relatedTarget||!sigTbl.contains(e.relatedTarget))_hideSigTip();
9617 });
9618 }
9619 // Wire up sortable column headers
9620 var ths=document.querySelectorAll('#style-file-table thead th[data-sort-key]');
9621 for(var i=0;i<ths.length;i++){(function(th){
9622 th.style.cursor='pointer';
9623 th.addEventListener('click',function(){
9624 var key=th.getAttribute('data-sort-key');
9625 if(sftSortKey===key){sftSortDir*=-1;}else{sftSortKey=key;sftSortDir=1;}
9626 for(var j=0;j<ths.length;j++){
9627 ths[j].classList.remove('sft-sort-asc','sft-sort-desc');
9628 var ind=ths[j].querySelector('.style-sort-ind');
9629 if(ind)ind.textContent='\u25BE';
9630 }
9631 th.classList.add(sftSortDir===1?'sft-sort-asc':'sft-sort-desc');
9632 var tind=th.querySelector('.style-sort-ind');
9633 if(tind)tind.textContent=sftSortDir===1?'\u25B2':'\u25BC';
9634 sftApplyFilter();
9635 });
9636 })(ths[i]);}
9637 var searchInput=document.getElementById('sft-search');
9638 if(searchInput){
9639 var sftTimer=null;
9640 searchInput.addEventListener('input',function(){clearTimeout(sftTimer);sftTimer=setTimeout(sftApplyFilter,200);});
9641 }
9642 var pageSel=document.getElementById('sft-page-size');
9643 if(pageSel){pageSel.addEventListener('change',function(){sftCurrentPage=1;renderSftTable();});}
9644 var sftFirstBtn=document.getElementById('sft-first');
9645 var sftPrevBtn=document.getElementById('sft-prev');
9646 var sftNextBtn=document.getElementById('sft-next');
9647 var sftLastBtn=document.getElementById('sft-last');
9648 var sftJumpInput=document.getElementById('sft-page-jump');
9649 if(sftFirstBtn){sftFirstBtn.addEventListener('click',function(){sftCurrentPage=1;renderSftTable();});}
9650 if(sftPrevBtn){sftPrevBtn.addEventListener('click',function(){if(sftCurrentPage>1){sftCurrentPage--;renderSftTable();}});}
9651 if(sftNextBtn){sftNextBtn.addEventListener('click',function(){
9652 var ps=sftGetPageSize();
9653 var totalPages=ps===Infinity?1:Math.ceil(sftFilteredRows.length/ps);
9654 if(sftCurrentPage<totalPages){sftCurrentPage++;renderSftTable();}
9655 });}
9656 if(sftLastBtn){sftLastBtn.addEventListener('click',function(){
9657 var ps=sftGetPageSize();
9658 sftCurrentPage=ps===Infinity?1:Math.max(1,Math.ceil(sftFilteredRows.length/ps));
9659 renderSftTable();
9660 });}
9661 if(sftJumpInput){
9662 function sftJump(){
9663 var ps=sftGetPageSize();
9664 var totalPages=ps===Infinity?1:Math.max(1,Math.ceil(sftFilteredRows.length/ps));
9665 var v=parseInt(sftJumpInput.value,10);
9666 if(!isNaN(v)){sftCurrentPage=Math.max(1,Math.min(v,totalPages));renderSftTable();}
9667 }
9668 sftJumpInput.addEventListener('change',sftJump);
9669 sftJumpInput.addEventListener('keydown',function(e){if(e.key==='Enter')sftJump();});
9670 }
9671 }
9672 function initSigInfoBtn(){
9673 var btn=document.getElementById('sig-info-btn');
9674 if(!btn)return;
9675 btn.addEventListener('click',function(){
9676 var overlay=document.createElement('div');
9677 overlay.className='style-sig-info-overlay';
9678 var GLOSSARY=[
9679 ['Quote Style','Dominant string quote character used in the file (single quotes, double quotes, or mixed)'],
9680 ['Indentation','Leading-whitespace style detected: Tabs, 2-Space, 4-Space, 8-Space, or Mixed'],
9681 ['Brace Style','Opening brace placement: K\u0026R / Attach (same line as statement) or Allman (own line)'],
9682 ['Semicolons','Whether statement-ending semicolons are present (JS/TS). \u201cNone detected\u201d means ASI-style.'],
9683 ['Variable Declarations','Preferred declaration keyword: const/let vs var (JS), short := vs var (Go)'],
9684 ['Function Naming','Dominant function naming convention: snake_case or CamelCase'],
9685 ['Type Hints','Whether Python PEP 484 type annotations (:Type, ->Type) are used in the file'],
9686 ['Wildcard Imports','Presence of import * wildcard import statements (Java/Kotlin)'],
9687 ['Pointer Style','Pointer/reference alignment in C/C++: *var (name-attached) or Type* (type-attached)'],
9688 ['Arrow Functions','Count of arrow function => expressions detected in JS/TS files'],
9689 ['Max Line Length','Character length of the longest line found in the file'],
9690 ['Error Handling','Presence of Go-style if err != nil error-checking patterns'],
9691 ['Type Inference','Whether the C# var keyword is used for implicit type inference'],
9692 ['Frozen String Literal','Whether the # frozen_string_literal: true pragma is present (Ruby)'],
9693 ['Space Before Paren','Spacing convention before opening parentheses in control structures (C/C++)'],
9694 ['Include Guard','Whether #pragma once is used as a header include guard (C/C++)']
9695 ];
9696 var rows='';
9697 GLOSSARY.forEach(function(g){rows+='<span class="style-sig-info-name">'+escH(g[0])+'</span><span class="style-sig-info-desc">'+escH(g[1])+'</span>';});
9698 overlay.innerHTML='<div class="style-sig-info-modal" role="dialog" aria-modal="true" aria-label="Signal glossary"><button type="button" class="style-sig-info-close" aria-label="Close">\u00D7</button><h3 style="margin:0 0 6px;font-size:17px;">Signal Glossary</h3><p style="color:var(--muted);font-size:13px;margin:0 0 2px;">Lexical signals detected per file. Values reflect dominant patterns in the source text.</p><div class="style-sig-info-grid">'+rows+'</div></div>';
9699 document.body.appendChild(overlay);
9700 overlay.addEventListener('click',function(e){if(e.target===overlay||e.target.classList.contains('style-sig-info-close')){overlay.remove();}});
9701 });
9702 }
9703 function init(){initTabs();initStyleTable();initSigInfoBtn();}
9704 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
9705 }());
9706 </script>
9707 {% endif %}
9708 <script nonce="{{ nonce }}">
9709 (function(){
9710 var params=new URLSearchParams(location.search);
9711 if(params.get('autoprint')!=='1')return;
9712 var overlay=document.createElement('div');
9713 overlay.id='autoprint-overlay';
9714 overlay.style.cssText='position:fixed;inset:0;z-index:99999;background:var(--bg,#fff);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;';
9715 overlay.innerHTML='<div style="font-size:20px;font-weight:800;color:var(--text,#1a1a1a);">Preparing PDF\u2026</div>'
9716 +'<div style="font-size:13px;color:var(--muted,#666);">Use your browser\u2019s print dialog \u2192 <strong>Save as PDF</strong>.</div>'
9717 +'<div style="width:200px;height:4px;border-radius:2px;background:rgba(0,0,0,0.1);overflow:hidden;">'
9718 +'<div id="autoprint-bar" style="height:100%;width:0%;background:#e07b3a;transition:width 1.5s ease;border-radius:2px;"></div></div>';
9719 document.body.appendChild(overlay);
9720 setTimeout(function(){var b=document.getElementById('autoprint-bar');if(b)b.style.width='80%';},50);
9721 var deadline=Date.now()+12000;
9722 function tryPrint(){
9723 if(window.oxSlocChartsReady||Date.now()>deadline){
9724 var b=document.getElementById('autoprint-bar');
9725 if(b)b.style.width='100%';
9726 setTimeout(function(){
9727 overlay.style.display='none';
9728 window.print();
9729 },350);
9730 } else {
9731 setTimeout(tryPrint,150);
9732 }
9733 }
9734 if(document.readyState==='loading'){
9735 document.addEventListener('DOMContentLoaded',function(){setTimeout(tryPrint,250);});
9736 } else {
9737 setTimeout(tryPrint,250);
9738 }
9739 window.addEventListener('afterprint',function(){overlay.remove();});
9740 }());
9741 </script>
9742 <footer class="report-footer">local code analysis — metrics, history and reports · oxide-sloc v{{ tool_version }} · AGPL-3.0-or-later · offline / air-gapped build</footer>
9743 {% if let Some(banner) = report_header_footer %}
9744 <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
9745 {% endif %}
9746</body>
9747</html>"##,
9748 ext = "html"
9749)]
9750#[allow(clippy::struct_excessive_bools, dead_code)]
9753struct ReportTemplate<'a> {
9754 nonce: String,
9755 title: String,
9756 browser_title: String,
9757 scan_performed_by: String,
9758 scan_time_pst: String,
9759 tool_version: String,
9760 is_sub_report: bool,
9761 run: &'a AnalysisRun,
9762 language_rows: Vec<LanguageRow>,
9763 file_rows: Vec<FileRow>,
9764 skipped_rows: Vec<FileRow>,
9765 config_json: String,
9766 lang_chart_json: String,
9767 submodule_chart_json: String,
9768 scatter_chart_json: String,
9769 semantic_chart_json: String,
9770 file_size_histogram_json: String,
9771 has_submodule_data: bool,
9772 has_semantic_data: bool,
9773 has_coverage_data: bool,
9774 has_fn_coverage: bool,
9775 has_branch_coverage: bool,
9776 test_files_count: u64,
9777 test_assertion_count: u64,
9778 test_suite_count: u64,
9779 test_density: String,
9780 most_tested_lang: String,
9781 langs_with_tests: usize,
9782 cov_line_pct: String,
9783 cov_fn_pct: String,
9784 cov_branch_pct: String,
9785 cov_line_class: String,
9786 cov_fn_class: String,
9787 cov_branch_class: String,
9788 has_run_warnings: bool,
9789 warning_count: usize,
9790 warning_summary_rows: Vec<WarningSummaryRow>,
9791 warning_opportunity_rows: Vec<WarningOpportunityRow>,
9792 warning_console_full: String,
9793 logo_text_uri: String,
9794 small_logo_uri: String,
9795 custom_logo_uri: Option<String>,
9797 company_name: Option<String>,
9799 accent_hex: Option<String>,
9801 report_header_footer: Option<String>,
9803 chart_js: &'static str,
9804 run_id_short: String,
9805 standalone_pdf_url: Option<String>,
9809 git_commit_url: Option<String>,
9812 git_branch_url: Option<String>,
9815 has_style_data: bool,
9817 style_lang_count: usize,
9819 style_score_threshold: u8,
9821 style_chart_json: String,
9823 style_file_json: String,
9825 style_summary: Option<StyleSummary>,
9827 has_delta: bool,
9829 delta_code_added: i64,
9830 delta_code_removed: i64,
9831 delta_unmodified_lines: i64,
9832 delta_files_added: usize,
9833 delta_files_removed: usize,
9834 delta_files_modified: usize,
9835 delta_files_unchanged: usize,
9836 delta_files_total: usize,
9837 prev_code_lines: u64,
9838 prev_scan_count: usize,
9839 prev_scan_label: String,
9840 prev_run_id: String,
9841 has_cocomo: bool,
9843 cocomo_effort_str: String,
9845 cocomo_duration_str: String,
9847 cocomo_staff_str: String,
9849 cocomo_ksloc_str: String,
9851 cocomo_mode_label: String,
9853 cocomo_mode_tooltip: String,
9855 uloc: u64,
9857 dryness_pct_str: String,
9859 duplicate_group_count: usize,
9861 has_hotspots: bool,
9863 hotspot_rows: Vec<HotspotRow>,
9865}
9866
9867fn csv_escape(s: &str) -> String {
9872 if s.contains(',') || s.contains('"') || s.contains('\n') {
9873 format!("\"{}\"", s.replace('"', "\"\""))
9874 } else {
9875 s.to_string()
9876 }
9877}
9878
9879pub fn write_csv(run: &AnalysisRun, path: &Path) -> Result<()> {
9885 let mut out = String::new();
9886
9887 out.push_str("# Summary\r\n");
9889 out.push_str("Metric,Value\r\n");
9890 let _ = write!(out, "Run ID,{}\r\n", csv_escape(&run.tool.run_id));
9891 let _ = write!(
9892 out,
9893 "Timestamp,{}\r\n",
9894 csv_escape(
9895 &run.tool
9896 .timestamp_utc
9897 .format("%Y-%m-%d %H:%M:%S UTC")
9898 .to_string()
9899 )
9900 );
9901 let _ = write!(
9902 out,
9903 "Report Title,{}\r\n",
9904 csv_escape(&run.effective_configuration.reporting.report_title)
9905 );
9906 let _ = write!(
9907 out,
9908 "Files Analyzed,{}\r\n",
9909 run.summary_totals.files_analyzed
9910 );
9911 let _ = write!(
9912 out,
9913 "Files Skipped,{}\r\n",
9914 run.summary_totals.files_skipped
9915 );
9916 let _ = write!(
9917 out,
9918 "Physical Lines,{}\r\n",
9919 run.summary_totals.total_physical_lines
9920 );
9921 let _ = write!(out, "Code Lines,{}\r\n", run.summary_totals.code_lines);
9922 let _ = write!(
9923 out,
9924 "Comment Lines,{}\r\n",
9925 run.summary_totals.comment_lines
9926 );
9927 let _ = write!(out, "Blank Lines,{}\r\n", run.summary_totals.blank_lines);
9928 let _ = write!(
9929 out,
9930 "Mixed Lines (separate),{}\r\n",
9931 run.summary_totals.mixed_lines_separate
9932 );
9933
9934 out.push_str("\r\n# By Language\r\n");
9936 out.push_str(
9937 "Language,Files,Physical Lines,Code Lines,Comment Lines,Blank Lines,Mixed Lines\r\n",
9938 );
9939 for lang in &run.totals_by_language {
9940 let _ = write!(
9941 out,
9942 "{},{},{},{},{},{},{}\r\n",
9943 csv_escape(lang.language.display_name()),
9944 lang.files,
9945 lang.total_physical_lines,
9946 lang.code_lines,
9947 lang.comment_lines,
9948 lang.blank_lines,
9949 lang.mixed_lines_separate,
9950 );
9951 }
9952
9953 write_csv_per_file_section(&mut out, run);
9955
9956 fs::write(path, out).with_context(|| format!("failed to write CSV to {}", path.display()))
9957}
9958
9959fn write_csv_per_file_section(out: &mut String, run: &AnalysisRun) {
9961 if run.per_file_records.is_empty() {
9962 return;
9963 }
9964 let has_activity = run
9966 .per_file_records
9967 .iter()
9968 .any(|r| r.commit_count.is_some());
9969 out.push_str("\r\n# Per File\r\n");
9970 out.push_str(
9971 "Path,Language,Size (bytes),Code Lines,Comment Lines,Blank Lines,Physical Lines,Generated,Minified,Vendor",
9972 );
9973 if has_activity {
9974 out.push_str(",Commits,Last Changed");
9975 }
9976 out.push_str("\r\n");
9977 for rec in &run.per_file_records {
9978 let _ = write!(
9979 out,
9980 "{},{},{},{},{},{},{},{},{},{}",
9981 csv_escape(&rec.relative_path),
9982 csv_escape(
9983 &rec.language
9984 .map(|l| l.display_name().to_string())
9985 .unwrap_or_default()
9986 ),
9987 rec.size_bytes,
9988 rec.effective_counts.code_lines,
9989 rec.effective_counts.comment_lines,
9990 rec.effective_counts.blank_lines,
9991 rec.raw_line_categories.total_physical_lines,
9992 rec.generated,
9993 rec.minified,
9994 rec.vendor,
9995 );
9996 if has_activity {
9997 let _ = write!(
9998 out,
9999 ",{},{}",
10000 rec.commit_count.map(|c| c.to_string()).unwrap_or_default(),
10001 csv_escape(rec.last_commit_date.as_deref().unwrap_or("")),
10002 );
10003 }
10004 out.push_str("\r\n");
10005 }
10006}
10007
10008pub fn write_diff_csv(cmp: &sloc_core::ScanComparison, path: &Path) -> Result<()> {
10014 let s = &cmp.summary;
10015 let mut out = String::new();
10016
10017 out.push_str("# Diff Summary\r\n");
10018 out.push_str("Metric,Value\r\n");
10019 let _ = write!(out, "Baseline Run,{}\r\n", csv_escape(&s.baseline_run_id));
10020 let _ = write!(out, "Current Run,{}\r\n", csv_escape(&s.current_run_id));
10021 let _ = write!(out, "Files Added,{}\r\n", cmp.files_added);
10022 let _ = write!(out, "Files Removed,{}\r\n", cmp.files_removed);
10023 let _ = write!(out, "Files Modified,{}\r\n", cmp.files_modified);
10024 let _ = write!(out, "Files Unchanged,{}\r\n", cmp.files_unchanged);
10025 let _ = write!(out, "Files Total,{}\r\n", cmp.files_total);
10026 let _ = write!(out, "Code Δ,{}\r\n", s.code_lines_delta);
10027 let _ = write!(out, "Comment Δ,{}\r\n", s.comment_lines_delta);
10028 let _ = write!(out, "Blank Δ,{}\r\n", s.blank_lines_delta);
10029 let _ = write!(out, "Total Δ,{}\r\n", s.total_lines_delta);
10030
10031 out.push_str("\r\n# File Deltas\r\n");
10032 out.push_str("Status,Path,Language,Baseline Code,Current Code,Code Δ,Baseline Comment,Current Comment,Comment Δ,Baseline Blank,Current Blank,Blank Δ,Total Δ\r\n");
10033 for f in &cmp.file_deltas {
10034 let status = match f.status {
10035 sloc_core::FileChangeStatus::Added => "Added",
10036 sloc_core::FileChangeStatus::Removed => "Removed",
10037 sloc_core::FileChangeStatus::Modified => "Modified",
10038 sloc_core::FileChangeStatus::Unchanged => "Unchanged",
10039 };
10040 let _ = write!(
10041 out,
10042 "{},{},{},{},{},{},{},{},{},{},{},{},{}\r\n",
10043 status,
10044 csv_escape(&f.relative_path),
10045 csv_escape(f.language.as_deref().unwrap_or("")),
10046 f.baseline_code,
10047 f.current_code,
10048 f.code_delta,
10049 f.baseline_comment,
10050 f.current_comment,
10051 f.comment_delta,
10052 f.baseline_blank,
10053 f.current_blank,
10054 f.blank_delta,
10055 f.total_delta,
10056 );
10057 }
10058
10059 fs::write(path, out).with_context(|| format!("failed to write diff CSV to {}", path.display()))
10060}
10061
10062fn crc32(data: &[u8]) -> u32 {
10071 let mut crc: u32 = 0xffff_ffff;
10072 for &b in data {
10073 crc ^= u32::from(b);
10074 for _ in 0..8 {
10075 crc = if crc & 1 == 0 {
10076 crc >> 1
10077 } else {
10078 (crc >> 1) ^ 0xedb8_8320
10079 };
10080 }
10081 }
10082 !crc
10083}
10084
10085struct ZipEntry {
10086 name: Vec<u8>,
10087 data: Vec<u8>,
10088 crc: u32,
10089 offset: u32,
10090}
10091
10092#[allow(clippy::cast_possible_truncation)] fn zip_add(entries: &mut Vec<ZipEntry>, buf: &mut Vec<u8>, name: &str, data: Vec<u8>) {
10094 let crc = crc32(&data);
10095 let offset = buf.len() as u32;
10096 let name_bytes = name.as_bytes().to_vec();
10097 let size = data.len() as u32;
10098
10099 buf.extend_from_slice(&0x0403_4b50_u32.to_le_bytes());
10101 buf.extend_from_slice(&20u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&crc.to_le_bytes());
10107 buf.extend_from_slice(&size.to_le_bytes()); buf.extend_from_slice(&size.to_le_bytes()); buf.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
10110 buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&name_bytes);
10112 buf.extend_from_slice(&data);
10113
10114 entries.push(ZipEntry {
10115 name: name_bytes,
10116 data,
10117 crc,
10118 offset,
10119 });
10120}
10121
10122#[allow(clippy::cast_possible_truncation)] fn zip_finish(mut buf: Vec<u8>, entries: &[ZipEntry]) -> Vec<u8> {
10124 let central_start = buf.len() as u32;
10125
10126 for e in entries {
10127 let size = e.data.len() as u32;
10128 buf.extend_from_slice(&0x0201_4b50_u32.to_le_bytes()); buf.extend_from_slice(&20u16.to_le_bytes()); buf.extend_from_slice(&20u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&e.crc.to_le_bytes());
10136 buf.extend_from_slice(&size.to_le_bytes());
10137 buf.extend_from_slice(&size.to_le_bytes());
10138 buf.extend_from_slice(&(e.name.len() as u16).to_le_bytes());
10139 buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u32.to_le_bytes()); buf.extend_from_slice(&e.offset.to_le_bytes());
10145 buf.extend_from_slice(&e.name);
10146 }
10147
10148 let central_size = buf.len() as u32 - central_start;
10149 let n = entries.len() as u16;
10150
10151 buf.extend_from_slice(&0x0605_4b50_u32.to_le_bytes());
10153 buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&n.to_le_bytes()); buf.extend_from_slice(&n.to_le_bytes()); buf.extend_from_slice(¢ral_size.to_le_bytes());
10158 buf.extend_from_slice(¢ral_start.to_le_bytes());
10159 buf.extend_from_slice(&0u16.to_le_bytes()); buf
10162}
10163
10164fn xml_escape(s: &str) -> String {
10165 s.replace('&', "&")
10166 .replace('<', "<")
10167 .replace('>', ">")
10168 .replace('"', """)
10169 .replace('\'', "'")
10170}
10171
10172const XLS_HEADER: u32 = 1;
10184const XLS_BODY: u32 = 2;
10185const XLS_BODY_ALT: u32 = 3;
10186const XLS_NUM: u32 = 4;
10187const XLS_NUM_ALT: u32 = 5;
10188const XLS_KV_KEY: u32 = 6;
10189const XLS_KV_VAL: u32 = 7;
10190
10191struct XlSheet<'a> {
10192 name: &'a str,
10193 tab_color: &'a str, headers: &'a [&'a str],
10195 rows: Vec<Vec<String>>,
10196 col_widths: Vec<f64>, is_kv: bool, }
10199
10200#[allow(clippy::cast_possible_truncation)] fn xl_col_name(idx: usize) -> String {
10202 let mut n = idx + 1;
10203 let mut s = String::new();
10204 while n > 0 {
10205 n -= 1;
10206 s.insert(0, char::from(b'A' + (n % 26) as u8));
10207 n /= 26;
10208 }
10209 s
10210}
10211
10212const fn xl_styles() -> &'static str {
10213 "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\
10214<styleSheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">\
10215<numFmts count=\"1\">\
10216<numFmt numFmtId=\"164\" formatCode=\"#,##0\"/>\
10217</numFmts>\
10218<fonts count=\"3\">\
10219<font><sz val=\"11\"/><name val=\"Calibri\"/></font>\
10220<font><b/><sz val=\"11\"/><color rgb=\"FFFFFFFF\"/><name val=\"Calibri\"/></font>\
10221<font><b/><sz val=\"11\"/><color rgb=\"FF283790\"/><name val=\"Calibri\"/></font>\
10222</fonts>\
10223<fills count=\"5\">\
10224<fill><patternFill patternType=\"none\"/></fill>\
10225<fill><patternFill patternType=\"gray125\"/></fill>\
10226<fill><patternFill patternType=\"solid\"><fgColor rgb=\"FF283790\"/><bgColor indexed=\"64\"/></patternFill></fill>\
10227<fill><patternFill patternType=\"solid\"><fgColor rgb=\"FFF5EFE8\"/><bgColor indexed=\"64\"/></patternFill></fill>\
10228<fill><patternFill patternType=\"solid\"><fgColor rgb=\"FFFBF7F2\"/><bgColor indexed=\"64\"/></patternFill></fill>\
10229</fills>\
10230<borders count=\"2\">\
10231<border><left/><right/><top/><bottom/><diagonal/></border>\
10232<border>\
10233<left style=\"thin\"><color rgb=\"FFD0B8A0\"/></left>\
10234<right style=\"thin\"><color rgb=\"FFD0B8A0\"/></right>\
10235<top style=\"thin\"><color rgb=\"FFD0B8A0\"/></top>\
10236<bottom style=\"thin\"><color rgb=\"FFD0B8A0\"/></bottom>\
10237<diagonal/>\
10238</border>\
10239</borders>\
10240<cellStyleXfs count=\"1\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/></cellStyleXfs>\
10241<cellXfs count=\"8\">\
10242<xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\"/>\
10243<xf numFmtId=\"0\" fontId=\"1\" fillId=\"2\" borderId=\"1\" xfId=\"0\" \
10244applyFont=\"1\" applyFill=\"1\" applyBorder=\"1\" applyAlignment=\"1\">\
10245<alignment horizontal=\"center\" vertical=\"center\"/></xf>\
10246<xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"1\" xfId=\"0\" applyBorder=\"1\"/>\
10247<xf numFmtId=\"0\" fontId=\"0\" fillId=\"3\" borderId=\"1\" xfId=\"0\" applyFill=\"1\" applyBorder=\"1\"/>\
10248<xf numFmtId=\"164\" fontId=\"0\" fillId=\"0\" borderId=\"1\" xfId=\"0\" \
10249applyNumberFormat=\"1\" applyBorder=\"1\" applyAlignment=\"1\">\
10250<alignment horizontal=\"right\"/></xf>\
10251<xf numFmtId=\"164\" fontId=\"0\" fillId=\"3\" borderId=\"1\" xfId=\"0\" \
10252applyNumberFormat=\"1\" applyFill=\"1\" applyBorder=\"1\" applyAlignment=\"1\">\
10253<alignment horizontal=\"right\"/></xf>\
10254<xf numFmtId=\"0\" fontId=\"2\" fillId=\"4\" borderId=\"1\" xfId=\"0\" \
10255applyFont=\"1\" applyFill=\"1\" applyBorder=\"1\"/>\
10256<xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"1\" xfId=\"0\" applyBorder=\"1\"/>\
10257</cellXfs>\
10258</styleSheet>"
10259}
10260
10261fn xl_sheet_xml(sheet: &XlSheet<'_>) -> Vec<u8> {
10262 let ncols = sheet.headers.len();
10263 let ndata = sheet.rows.len();
10264 let last_col = xl_col_name(ncols.saturating_sub(1));
10265 let last_row = ndata + 1;
10266 let range = format!("A1:{last_col}{last_row}");
10267
10268 let mut xml = String::with_capacity(4096 + ndata * 256);
10269 let _ = write!(
10270 xml,
10271 "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
10272 <worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">\n\
10273 <sheetPr><tabColor rgb=\"{tc}\"/></sheetPr>\n\
10274 <dimension ref=\"{rng}\"/>\n\
10275 <sheetViews><sheetView workbookViewId=\"0\">\
10276 <pane ySplit=\"1\" topLeftCell=\"A2\" activePane=\"bottomLeft\" state=\"frozen\"/>\
10277 <selection pane=\"bottomLeft\" activeCell=\"A2\" sqref=\"A2\"/>\
10278 </sheetView></sheetViews>\n\
10279 <sheetFormatPr defaultRowHeight=\"15\"/>\n",
10280 tc = sheet.tab_color,
10281 rng = range,
10282 );
10283
10284 xl_write_col_widths(&mut xml, &sheet.col_widths, ncols);
10285 xml.push_str("<sheetData>\n");
10286 xl_write_header_row(&mut xml, sheet.headers);
10287 xl_write_data_rows(&mut xml, &sheet.rows, sheet.is_kv);
10288 xml.push_str("</sheetData>\n");
10289 if !sheet.is_kv && ncols > 0 {
10290 let _ = writeln!(xml, "<autoFilter ref=\"{range}\"/>");
10291 }
10292 xml.push_str("</worksheet>");
10293 xml.into_bytes()
10294}
10295
10296fn xl_write_col_widths(xml: &mut String, col_widths: &[f64], ncols: usize) {
10297 if col_widths.is_empty() {
10298 return;
10299 }
10300 let default_w = *col_widths.last().unwrap_or(&10.0);
10301 xml.push_str("<cols>\n");
10302 for ci in 0..ncols {
10303 let w = col_widths.get(ci).copied().unwrap_or(default_w);
10304 let _ = writeln!(
10305 xml,
10306 " <col min=\"{n}\" max=\"{n}\" width=\"{w:.1}\" customWidth=\"1\"/>",
10307 n = ci + 1
10308 );
10309 }
10310 xml.push_str("</cols>\n");
10311}
10312
10313fn xl_write_header_row(xml: &mut String, headers: &[&str]) {
10314 let _ = write!(xml, "<row r=\"1\" ht=\"18\" customHeight=\"1\">");
10315 for (ci, &h) in headers.iter().enumerate() {
10316 let _ = write!(
10317 xml,
10318 "<c r=\"{}1\" t=\"inlineStr\" s=\"{}\"><is><t>{}</t></is></c>",
10319 xl_col_name(ci),
10320 XLS_HEADER,
10321 xml_escape(h),
10322 );
10323 }
10324 xml.push_str("</row>\n");
10325}
10326
10327const fn xl_cell_style(is_kv: bool, ci: usize, is_num: bool, is_alt: bool) -> u32 {
10328 if is_kv {
10329 if ci == 0 {
10330 XLS_KV_KEY
10331 } else if is_num {
10332 XLS_NUM
10333 } else {
10334 XLS_KV_VAL
10335 }
10336 } else if is_num {
10337 if is_alt { XLS_NUM_ALT } else { XLS_NUM }
10338 } else if is_alt {
10339 XLS_BODY_ALT
10340 } else {
10341 XLS_BODY
10342 }
10343}
10344
10345fn xl_write_data_rows(xml: &mut String, rows: &[Vec<String>], is_kv: bool) {
10346 for (ri, row) in rows.iter().enumerate() {
10347 let row_num = ri + 2;
10348 let is_alt = ri % 2 == 1;
10349 let _ = write!(xml, "<row r=\"{row_num}\">");
10350 for (ci, cell) in row.iter().enumerate() {
10351 let cell_ref = format!("{}{}", xl_col_name(ci), row_num);
10352 let is_num = !cell.is_empty() && cell.parse::<f64>().is_ok();
10353 let s = xl_cell_style(is_kv, ci, is_num, is_alt);
10354 if is_num {
10355 let _ = write!(
10356 xml,
10357 "<c r=\"{cell_ref}\" s=\"{s}\"><v>{}</v></c>",
10358 xml_escape(cell)
10359 );
10360 } else {
10361 let _ = write!(
10362 xml,
10363 "<c r=\"{cell_ref}\" t=\"inlineStr\" s=\"{s}\"><is><t>{}</t></is></c>",
10364 xml_escape(cell),
10365 );
10366 }
10367 }
10368 xml.push_str("</row>\n");
10369 }
10370}
10371
10372fn build_xlsx(sheets: &[XlSheet<'_>]) -> Vec<u8> {
10373 let mut buf: Vec<u8> = Vec::new();
10374 let mut entries: Vec<ZipEntry> = Vec::new();
10375
10376 let mut ct = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
10378 ct.push_str("<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">\n");
10379 ct.push_str(" <Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>\n");
10380 ct.push_str(" <Default Extension=\"xml\" ContentType=\"application/xml\"/>\n");
10381 ct.push_str(" <Override PartName=\"/xl/workbook.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml\"/>\n");
10382 ct.push_str(" <Override PartName=\"/xl/styles.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml\"/>\n");
10383 for (i, _) in sheets.iter().enumerate() {
10384 let _ = writeln!(
10385 ct,
10386 " <Override PartName=\"/xl/worksheets/sheet{}.xml\" \
10387 ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml\"/>",
10388 i + 1
10389 );
10390 }
10391 ct.push_str("</Types>");
10392 zip_add(
10393 &mut entries,
10394 &mut buf,
10395 "[Content_Types].xml",
10396 ct.into_bytes(),
10397 );
10398
10399 let rels = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
10401<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\n\
10402 <Relationship Id=\"rId1\" \
10403 Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" \
10404 Target=\"xl/workbook.xml\"/>\n\
10405</Relationships>";
10406 zip_add(
10407 &mut entries,
10408 &mut buf,
10409 "_rels/.rels",
10410 rels.as_bytes().to_vec(),
10411 );
10412
10413 let mut wb = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
10415 wb.push_str(
10416 "<workbook xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" \
10417 xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\">\n",
10418 );
10419 wb.push_str(" <sheets>\n");
10420 for (i, sheet) in sheets.iter().enumerate() {
10421 let _ = writeln!(
10422 wb,
10423 " <sheet name=\"{}\" sheetId=\"{}\" r:id=\"rId{}\"/>",
10424 xml_escape(sheet.name),
10425 i + 1,
10426 i + 1
10427 );
10428 }
10429 wb.push_str(" </sheets>\n</workbook>");
10430 zip_add(&mut entries, &mut buf, "xl/workbook.xml", wb.into_bytes());
10431
10432 let mut wbr = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
10434 wbr.push_str(
10435 "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\n",
10436 );
10437 for (i, _) in sheets.iter().enumerate() {
10438 let _ = writeln!(
10439 wbr,
10440 " <Relationship Id=\"rId{}\" \
10441 Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet\" \
10442 Target=\"worksheets/sheet{}.xml\"/>",
10443 i + 1,
10444 i + 1
10445 );
10446 }
10447 let _ = writeln!(
10448 wbr,
10449 " <Relationship Id=\"rId{}\" \
10450 Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles\" \
10451 Target=\"styles.xml\"/>",
10452 sheets.len() + 1
10453 );
10454 wbr.push_str("</Relationships>");
10455 zip_add(
10456 &mut entries,
10457 &mut buf,
10458 "xl/_rels/workbook.xml.rels",
10459 wbr.into_bytes(),
10460 );
10461
10462 zip_add(
10464 &mut entries,
10465 &mut buf,
10466 "xl/styles.xml",
10467 xl_styles().as_bytes().to_vec(),
10468 );
10469
10470 for (i, sheet) in sheets.iter().enumerate() {
10472 let sheet_xml = xl_sheet_xml(sheet);
10473 let name = format!("xl/worksheets/sheet{}.xml", i + 1);
10474 zip_add(&mut entries, &mut buf, &name, sheet_xml);
10475 }
10476
10477 zip_finish(buf, &entries)
10478}
10479
10480#[allow(clippy::too_many_lines)]
10486pub fn write_xlsx(run: &AnalysisRun, path: &Path) -> Result<()> {
10487 let summary_rows: Vec<Vec<String>> = vec![
10489 vec!["Run ID".into(), run.tool.run_id.clone()],
10490 vec![
10491 "Timestamp".into(),
10492 run.tool
10493 .timestamp_utc
10494 .format("%Y-%m-%d %H:%M:%S UTC")
10495 .to_string(),
10496 ],
10497 vec![
10498 "Report Title".into(),
10499 run.effective_configuration.reporting.report_title.clone(),
10500 ],
10501 vec![
10502 "Files Analyzed".into(),
10503 run.summary_totals.files_analyzed.to_string(),
10504 ],
10505 vec![
10506 "Files Skipped".into(),
10507 run.summary_totals.files_skipped.to_string(),
10508 ],
10509 vec![
10510 "Physical Lines".into(),
10511 run.summary_totals.total_physical_lines.to_string(),
10512 ],
10513 vec![
10514 "Code Lines".into(),
10515 run.summary_totals.code_lines.to_string(),
10516 ],
10517 vec![
10518 "Comment Lines".into(),
10519 run.summary_totals.comment_lines.to_string(),
10520 ],
10521 vec![
10522 "Blank Lines".into(),
10523 run.summary_totals.blank_lines.to_string(),
10524 ],
10525 vec![
10526 "Mixed Lines (separate)".into(),
10527 run.summary_totals.mixed_lines_separate.to_string(),
10528 ],
10529 ];
10530
10531 let lang_rows: Vec<Vec<String>> = run
10533 .totals_by_language
10534 .iter()
10535 .map(|l| {
10536 vec![
10537 l.language.display_name().to_string(),
10538 l.files.to_string(),
10539 l.total_physical_lines.to_string(),
10540 l.code_lines.to_string(),
10541 l.comment_lines.to_string(),
10542 l.blank_lines.to_string(),
10543 l.mixed_lines_separate.to_string(),
10544 ]
10545 })
10546 .collect();
10547
10548 let file_rows: Vec<Vec<String>> = run
10550 .per_file_records
10551 .iter()
10552 .map(|r| {
10553 vec![
10554 r.relative_path.clone(),
10555 r.language
10556 .map(|l| l.display_name().to_string())
10557 .unwrap_or_default(),
10558 r.size_bytes.to_string(),
10559 r.effective_counts.code_lines.to_string(),
10560 r.effective_counts.comment_lines.to_string(),
10561 r.effective_counts.blank_lines.to_string(),
10562 r.raw_line_categories.total_physical_lines.to_string(),
10563 r.generated.to_string(),
10564 r.minified.to_string(),
10565 r.vendor.to_string(),
10566 ]
10567 })
10568 .collect();
10569
10570 let skipped_rows: Vec<Vec<String>> = run
10572 .skipped_file_records
10573 .iter()
10574 .map(|r| {
10575 vec![
10576 r.relative_path.clone(),
10577 format!("{:?}", r.status),
10578 r.size_bytes.to_string(),
10579 ]
10580 })
10581 .collect();
10582
10583 let summary_hdrs: &[&str] = &["Metric", "Value"];
10584 let lang_hdrs: &[&str] = &[
10585 "Language",
10586 "Files",
10587 "Physical Lines",
10588 "Code Lines",
10589 "Comments",
10590 "Blank",
10591 "Mixed",
10592 ];
10593 let file_hdrs: &[&str] = &[
10594 "Path",
10595 "Language",
10596 "Size (bytes)",
10597 "Code Lines",
10598 "Comments",
10599 "Blank Lines",
10600 "Physical Lines",
10601 "Generated",
10602 "Minified",
10603 "Vendor",
10604 ];
10605 let skipped_hdrs: &[&str] = &["Path", "Status", "Size (bytes)"];
10606
10607 let sheets = vec![
10608 XlSheet {
10609 name: "Summary",
10610 tab_color: "FF283790",
10611 headers: summary_hdrs,
10612 rows: summary_rows,
10613 col_widths: vec![26.0, 44.0],
10614 is_kv: true,
10615 },
10616 XlSheet {
10617 name: "By Language",
10618 tab_color: "FFB85D33",
10619 headers: lang_hdrs,
10620 rows: lang_rows,
10621 col_widths: vec![20.0, 9.0, 15.0, 13.0, 13.0, 11.0, 11.0],
10622 is_kv: false,
10623 },
10624 XlSheet {
10625 name: "Per File",
10626 tab_color: "FF2A6846",
10627 headers: file_hdrs,
10628 rows: file_rows,
10629 col_widths: vec![48.0, 14.0, 13.0, 13.0, 11.0, 11.0, 15.0, 11.0, 11.0, 9.0],
10630 is_kv: false,
10631 },
10632 XlSheet {
10633 name: "Skipped",
10634 tab_color: "FF7B675B",
10635 headers: skipped_hdrs,
10636 rows: skipped_rows,
10637 col_widths: vec![52.0, 24.0, 13.0],
10638 is_kv: false,
10639 },
10640 ];
10641
10642 let bytes = build_xlsx(&sheets);
10643 fs::write(path, bytes).with_context(|| format!("failed to write XLSX to {}", path.display()))
10644}
10645
10646pub fn write_diff_xlsx(cmp: &sloc_core::ScanComparison, path: &Path) -> Result<()> {
10652 let s = &cmp.summary;
10653
10654 let summary_rows: Vec<Vec<String>> = vec![
10655 vec!["Baseline Run".into(), s.baseline_run_id.clone()],
10656 vec!["Current Run".into(), s.current_run_id.clone()],
10657 vec!["Files Added".into(), cmp.files_added.to_string()],
10658 vec!["Files Removed".into(), cmp.files_removed.to_string()],
10659 vec!["Files Modified".into(), cmp.files_modified.to_string()],
10660 vec!["Files Unchanged".into(), cmp.files_unchanged.to_string()],
10661 vec!["Files Total".into(), cmp.files_total.to_string()],
10662 vec!["Code Δ".into(), s.code_lines_delta.to_string()],
10663 vec!["Comment Δ".into(), s.comment_lines_delta.to_string()],
10664 vec!["Blank Δ".into(), s.blank_lines_delta.to_string()],
10665 vec!["Total Δ".into(), s.total_lines_delta.to_string()],
10666 ];
10667
10668 let delta_rows: Vec<Vec<String>> = cmp
10669 .file_deltas
10670 .iter()
10671 .map(|f| {
10672 let status = match f.status {
10673 sloc_core::FileChangeStatus::Added => "Added",
10674 sloc_core::FileChangeStatus::Removed => "Removed",
10675 sloc_core::FileChangeStatus::Modified => "Modified",
10676 sloc_core::FileChangeStatus::Unchanged => "Unchanged",
10677 };
10678 vec![
10679 status.to_string(),
10680 f.relative_path.clone(),
10681 f.language.clone().unwrap_or_default(),
10682 f.baseline_code.to_string(),
10683 f.current_code.to_string(),
10684 f.code_delta.to_string(),
10685 f.baseline_comment.to_string(),
10686 f.current_comment.to_string(),
10687 f.comment_delta.to_string(),
10688 f.total_delta.to_string(),
10689 ]
10690 })
10691 .collect();
10692
10693 let summary_hdrs: &[&str] = &["Metric", "Value"];
10694 let delta_hdrs: &[&str] = &[
10695 "Status",
10696 "Path",
10697 "Language",
10698 "Baseline Code",
10699 "Current Code",
10700 "Code Δ",
10701 "Baseline Comment",
10702 "Current Comment",
10703 "Comment Δ",
10704 "Total Δ",
10705 ];
10706
10707 let sheets = vec![
10708 XlSheet {
10709 name: "Diff Summary",
10710 tab_color: "FF283790",
10711 headers: summary_hdrs,
10712 rows: summary_rows,
10713 col_widths: vec![26.0, 44.0],
10714 is_kv: true,
10715 },
10716 XlSheet {
10717 name: "File Deltas",
10718 tab_color: "FFB85D33",
10719 headers: delta_hdrs,
10720 rows: delta_rows,
10721 col_widths: vec![12.0, 48.0, 16.0, 14.0, 14.0, 11.0, 14.0, 14.0, 11.0, 11.0],
10722 is_kv: false,
10723 },
10724 ];
10725
10726 let bytes = build_xlsx(&sheets);
10727 fs::write(path, bytes)
10728 .with_context(|| format!("failed to write diff XLSX to {}", path.display()))
10729}
10730
10731fn html_esc(s: &str) -> String {
10734 s.replace('&', "&")
10735 .replace('<', "<")
10736 .replace('>', ">")
10737 .replace('"', """)
10738}
10739
10740#[must_use]
10744pub fn render_confluence_storage(run: &AnalysisRun, report_url: Option<&str>) -> String {
10745 let mut out = String::with_capacity(8192);
10746
10747 let project = run.effective_configuration.reporting.report_title.as_str();
10748 let branch = run.git_branch.as_deref().unwrap_or("—");
10749 let commit = run.git_commit_short.as_deref().unwrap_or("—");
10750 let scanned = run
10751 .tool
10752 .timestamp_utc
10753 .format("%Y-%m-%d %H:%M UTC")
10754 .to_string();
10755
10756 out.push_str(
10758 "<ac:structured-macro ac:name=\"info\" ac:schema-version=\"1\">\
10759 <ac:rich-text-body><p>",
10760 );
10761 let _ = write!(
10762 out,
10763 "<strong>Project:</strong> {proj} · \
10764 <strong>Branch:</strong> {branch} · \
10765 <strong>Commit:</strong> {commit} · \
10766 <strong>Scanned:</strong> {scanned}",
10767 proj = html_esc(project),
10768 branch = html_esc(branch),
10769 commit = html_esc(commit),
10770 scanned = html_esc(&scanned),
10771 );
10772 out.push_str("</p></ac:rich-text-body></ac:structured-macro>");
10773
10774 out.push_str("<h2>Summary</h2>");
10776 out.push_str(
10777 "<table><thead><tr>\
10778 <th>Files Analyzed</th><th>Code Lines</th><th>Comment Lines</th>\
10779 <th>Blank Lines</th><th>Languages</th>\
10780 </tr></thead><tbody><tr>",
10781 );
10782 let t = &run.summary_totals;
10783 let _ = write!(
10784 out,
10785 "<td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td>",
10786 t.files_analyzed,
10787 t.code_lines,
10788 t.comment_lines,
10789 t.blank_lines,
10790 run.totals_by_language.len(),
10791 );
10792 out.push_str("</tr></tbody></table>");
10793
10794 if !run.totals_by_language.is_empty() {
10796 out.push_str("<h2>Language Breakdown</h2>");
10797 out.push_str(
10798 "<table><thead><tr>\
10799 <th>Language</th><th>Files</th><th>Code</th><th>Comments</th><th>Blank</th>\
10800 </tr></thead><tbody>",
10801 );
10802 for lang in &run.totals_by_language {
10803 let _ = write!(
10804 out,
10805 "<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
10806 html_esc(lang.language.display_name()),
10807 lang.files,
10808 lang.code_lines,
10809 lang.comment_lines,
10810 lang.blank_lines,
10811 );
10812 }
10813 out.push_str("</tbody></table>");
10814 }
10815
10816 if let Some(url) = report_url {
10818 let _ = write!(
10819 out,
10820 "<p><strong>Full interactive report:</strong> \
10821 <a href=\"{url}\">{url_disp}</a></p>",
10822 url = html_esc(url),
10823 url_disp = html_esc(url),
10824 );
10825 }
10826
10827 out
10828}
10829
10830#[must_use]
10833pub fn render_confluence_wiki_markup(run: &AnalysisRun) -> String {
10834 let mut out = String::with_capacity(4096);
10835
10836 let project = run.effective_configuration.reporting.report_title.as_str();
10837 let branch = run.git_branch.as_deref().unwrap_or("—");
10838 let commit = run.git_commit_short.as_deref().unwrap_or("—");
10839 let scanned = run
10840 .tool
10841 .timestamp_utc
10842 .format("%Y-%m-%d %H:%M UTC")
10843 .to_string();
10844
10845 let _ = writeln!(out, "{{info}}");
10846 let _ = writeln!(
10847 out,
10848 "Project: {project} · Branch: {branch} · Commit: {commit} · Scanned: {scanned}"
10849 );
10850 let _ = writeln!(out, "{{info}}");
10851 out.push('\n');
10852
10853 let t = &run.summary_totals;
10854 let _ = writeln!(out, "h2. Summary");
10855 let _ = writeln!(
10856 out,
10857 "||Files Analyzed||Code Lines||Comment Lines||Blank Lines||Languages||"
10858 );
10859 let _ = writeln!(
10860 out,
10861 "|{}|{}|{}|{}|{}|",
10862 t.files_analyzed,
10863 t.code_lines,
10864 t.comment_lines,
10865 t.blank_lines,
10866 run.totals_by_language.len(),
10867 );
10868 out.push('\n');
10869
10870 if !run.totals_by_language.is_empty() {
10871 let _ = writeln!(out, "h2. Language Breakdown");
10872 let _ = writeln!(out, "||Language||Files||Code||Comments||Blank||");
10873 for lang in &run.totals_by_language {
10874 let _ = writeln!(
10875 out,
10876 "|{}|{}|{}|{}|{}|",
10877 lang.language.display_name(),
10878 lang.files,
10879 lang.code_lines,
10880 lang.comment_lines,
10881 lang.blank_lines,
10882 );
10883 }
10884 out.push('\n');
10885 }
10886
10887 let _ = writeln!(
10888 out,
10889 "*Total:* {} code lines · {} files · {} languages",
10890 t.code_lines,
10891 t.files_analyzed,
10892 run.totals_by_language.len(),
10893 );
10894
10895 out
10896}
10897
10898#[cfg(test)]
10899mod tests {
10900 use super::*;
10901 use tempfile::tempdir;
10902
10903 #[test]
10906 fn base64_encode_empty() {
10907 assert_eq!(base64_encode(b""), "");
10908 }
10909
10910 #[test]
10911 fn base64_encode_one_byte() {
10912 assert_eq!(base64_encode(b"M"), "TQ==");
10913 }
10914
10915 #[test]
10916 fn base64_encode_two_bytes() {
10917 assert_eq!(base64_encode(b"Ma"), "TWE=");
10918 }
10919
10920 #[test]
10921 fn base64_encode_three_bytes_no_padding() {
10922 assert_eq!(base64_encode(b"Man"), "TWFu");
10923 }
10924
10925 #[test]
10926 fn base64_encode_hello() {
10927 assert_eq!(base64_encode(b"Hello"), "SGVsbG8=");
10928 }
10929
10930 #[test]
10931 fn base64_encode_roundtrip_length_multiple_of_3() {
10932 let data = b"abcdef";
10933 let encoded = base64_encode(data);
10934 assert_eq!(encoded.len(), 8);
10935 assert!(!encoded.contains('='));
10936 }
10937
10938 #[test]
10939 fn base64_encode_all_zeros() {
10940 assert_eq!(base64_encode(&[0u8, 0, 0]), "AAAA");
10941 }
10942
10943 #[test]
10944 fn base64_encode_binary_data() {
10945 let data: Vec<u8> = (0u8..=255).collect();
10946 let encoded = base64_encode(&data);
10947 assert!(!encoded.is_empty());
10948 assert!(
10949 encoded
10950 .chars()
10951 .all(|c| c.is_alphanumeric() || c == '+' || c == '/' || c == '=')
10952 );
10953 }
10954
10955 #[test]
10958 fn json_escape_no_special_chars() {
10959 assert_eq!(json_escape("hello world"), "hello world");
10960 }
10961
10962 #[test]
10963 fn json_escape_backslash() {
10964 assert_eq!(json_escape(r"path\to\file"), r"path\\to\\file");
10965 }
10966
10967 #[test]
10968 fn json_escape_double_quote() {
10969 assert_eq!(json_escape(r#"say "hi""#), r#"say \"hi\""#);
10970 }
10971
10972 #[test]
10973 fn json_escape_both_special_chars() {
10974 assert_eq!(json_escape(r#"a\"b"#), r#"a\\\"b"#);
10975 }
10976
10977 #[test]
10978 fn json_escape_empty_string() {
10979 assert_eq!(json_escape(""), "");
10980 }
10981
10982 #[test]
10983 fn json_escape_only_backslashes() {
10984 assert_eq!(json_escape(r"\\"), r"\\\\");
10985 }
10986
10987 #[test]
10990 fn coverage_pct_str_zero_found_returns_empty() {
10991 assert_eq!(coverage_pct_str(0, 0), "");
10992 }
10993
10994 #[test]
10995 fn coverage_pct_str_full_coverage() {
10996 assert_eq!(coverage_pct_str(100, 100), "100.0");
10997 }
10998
10999 #[test]
11000 fn coverage_pct_str_half_coverage() {
11001 assert_eq!(coverage_pct_str(50, 100), "50.0");
11002 }
11003
11004 #[test]
11005 fn coverage_pct_str_one_decimal_precision() {
11006 let s = coverage_pct_str(7, 10);
11007 assert_eq!(s, "70.0");
11008 }
11009
11010 #[test]
11011 fn coverage_pct_str_zero_hit_but_found() {
11012 assert_eq!(coverage_pct_str(0, 10), "0.0");
11013 }
11014
11015 #[test]
11016 fn coverage_pct_str_non_round_percentage() {
11017 let s = coverage_pct_str(1, 3);
11018 assert!(!s.is_empty());
11019 assert!(s.contains('.'), "result must have decimal point");
11020 }
11021
11022 #[test]
11025 fn coverage_class_zero_found_is_muted() {
11026 assert_eq!(coverage_class(0, 0), "muted");
11027 }
11028
11029 #[test]
11030 fn coverage_class_100_pct_is_good() {
11031 assert_eq!(coverage_class(100, 100), "good");
11032 }
11033
11034 #[test]
11035 fn coverage_class_80_pct_is_good() {
11036 assert_eq!(coverage_class(80, 100), "good");
11037 }
11038
11039 #[test]
11040 fn coverage_class_79_pct_is_warn() {
11041 assert_eq!(coverage_class(79, 100), "warn");
11042 }
11043
11044 #[test]
11045 fn coverage_class_60_pct_is_warn() {
11046 assert_eq!(coverage_class(60, 100), "warn");
11047 }
11048
11049 #[test]
11050 fn coverage_class_59_pct_is_danger() {
11051 assert_eq!(coverage_class(59, 100), "danger");
11052 }
11053
11054 #[test]
11055 fn coverage_class_zero_hit_is_danger() {
11056 assert_eq!(coverage_class(0, 100), "danger");
11057 }
11058
11059 #[test]
11062 fn format_test_density_zero_code_returns_zero() {
11063 assert_eq!(format_test_density(0, 5), "0.0");
11064 }
11065
11066 #[test]
11067 fn format_test_density_zero_tests_returns_zero() {
11068 assert_eq!(format_test_density(100, 0), "0.0");
11069 }
11070
11071 #[test]
11072 fn format_test_density_both_zero() {
11073 assert_eq!(format_test_density(0, 0), "0.0");
11074 }
11075
11076 #[test]
11077 fn format_test_density_1_test_per_1000_lines() {
11078 assert_eq!(format_test_density(1000, 1), "1.0");
11079 }
11080
11081 #[test]
11082 fn format_test_density_10_tests_per_100_lines() {
11083 assert_eq!(format_test_density(100, 10), "100.0");
11084 }
11085
11086 #[test]
11087 fn format_test_density_fractional() {
11088 let s = format_test_density(1000, 3);
11089 assert!(!s.is_empty());
11090 assert!(s.contains('.'));
11091 }
11092
11093 #[test]
11096 fn html_esc_no_special_chars() {
11097 assert_eq!(html_esc("hello"), "hello");
11098 }
11099
11100 #[test]
11101 fn html_esc_ampersand() {
11102 assert_eq!(html_esc("a&b"), "a&b");
11103 }
11104
11105 #[test]
11106 fn html_esc_less_than() {
11107 assert_eq!(html_esc("a<b"), "a<b");
11108 }
11109
11110 #[test]
11111 fn html_esc_greater_than() {
11112 assert_eq!(html_esc("a>b"), "a>b");
11113 }
11114
11115 #[test]
11116 fn html_esc_double_quote() {
11117 assert_eq!(html_esc(r#"a"b"#), "a"b");
11118 }
11119
11120 #[test]
11121 fn html_esc_all_special_chars() {
11122 assert_eq!(
11123 html_esc(r#"<a href="x&y">z</a>"#),
11124 "<a href="x&y">z</a>"
11125 );
11126 }
11127
11128 #[test]
11129 fn html_esc_empty_string() {
11130 assert_eq!(html_esc(""), "");
11131 }
11132
11133 #[test]
11136 fn png_data_uri_has_correct_prefix() {
11137 let uri = png_data_uri(b"\x89PNG\r\n\x1a\n");
11138 assert!(uri.starts_with("data:image/png;base64,"));
11139 }
11140
11141 #[test]
11142 fn png_data_uri_non_empty_for_non_empty_input() {
11143 let uri = png_data_uri(b"fake-png-bytes");
11144 assert!(uri.len() > "data:image/png;base64,".len());
11145 }
11146
11147 #[test]
11150 fn load_custom_logo_nonexistent_file_returns_none() {
11151 let result = load_custom_logo(std::path::Path::new("/nonexistent/__sloc_logo__.png"));
11152 assert!(result.is_none());
11153 }
11154
11155 #[test]
11156 fn load_custom_logo_png_file_returns_data_uri() {
11157 let dir = tempdir().unwrap();
11158 let path = dir.path().join("logo.png");
11159 std::fs::write(&path, b"\x89PNG\r\n\x1a\nfake-png-data").unwrap();
11160 let result = load_custom_logo(&path);
11161 assert!(result.is_some());
11162 let uri = result.unwrap();
11163 assert!(uri.starts_with("data:image/png;base64,"));
11164 }
11165
11166 #[test]
11167 fn load_custom_logo_svg_file_uses_svg_mime() {
11168 let dir = tempdir().unwrap();
11169 let path = dir.path().join("logo.svg");
11170 std::fs::write(&path, b"<svg></svg>").unwrap();
11171 let result = load_custom_logo(&path);
11172 assert!(result.is_some());
11173 let uri = result.unwrap();
11174 assert!(uri.starts_with("data:image/svg+xml;base64,"));
11175 }
11176
11177 #[test]
11178 fn load_custom_logo_unknown_extension_treated_as_png() {
11179 let dir = tempdir().unwrap();
11180 let path = dir.path().join("logo.bin");
11181 std::fs::write(&path, b"some-bytes").unwrap();
11182 let result = load_custom_logo(&path);
11183 assert!(result.is_some());
11184 let uri = result.unwrap();
11185 assert!(uri.starts_with("data:image/png;base64,"));
11186 }
11187}
11188
11189#[cfg(test)]
11190mod coverage_boost_report_tests {
11191 use super::*;
11192 use std::path::Path;
11193
11194 #[test]
11197 fn derive_commit_url_github_uses_commit_segment() {
11198 let url = derive_commit_url(
11199 "https://github.com/org/repo.git",
11200 "abc1234abc1234abc1234abc1234abc1234abc1234",
11201 );
11202 assert_eq!(
11203 url.as_deref(),
11204 Some("https://github.com/org/repo/commit/abc1234abc1234abc1234abc1234abc1234abc1234")
11205 );
11206 }
11207
11208 #[test]
11209 fn derive_commit_url_bitbucket_uses_commits_plural() {
11210 let url = derive_commit_url("https://bitbucket.org/org/repo.git", "deadbeef");
11211 assert_eq!(
11212 url.as_deref(),
11213 Some("https://bitbucket.org/org/repo/commits/deadbeef")
11214 );
11215 }
11216
11217 #[test]
11218 fn derive_commit_url_gitlab_uses_dash_commit() {
11219 let url = derive_commit_url("https://gitlab.example.com/org/repo.git", "cafe0000");
11220 assert_eq!(
11221 url.as_deref(),
11222 Some("https://gitlab.example.com/org/repo/-/commit/cafe0000")
11223 );
11224 }
11225
11226 #[test]
11227 fn derive_branch_url_github_uses_tree() {
11228 let url = derive_branch_url("https://github.com/org/repo.git", "main");
11229 assert_eq!(
11230 url.as_deref(),
11231 Some("https://github.com/org/repo/tree/main")
11232 );
11233 }
11234
11235 #[test]
11236 fn derive_branch_url_bitbucket_uses_branch_segment() {
11237 let url = derive_branch_url("https://bitbucket.org/org/repo.git", "develop");
11238 assert_eq!(
11239 url.as_deref(),
11240 Some("https://bitbucket.org/org/repo/branch/develop")
11241 );
11242 }
11243
11244 #[test]
11245 fn derive_branch_url_gitlab_uses_dash_tree() {
11246 let url = derive_branch_url("https://gitlab.mycompany.com/org/repo.git", "feature");
11247 assert_eq!(
11248 url.as_deref(),
11249 Some("https://gitlab.mycompany.com/org/repo/-/tree/feature")
11250 );
11251 }
11252
11253 #[test]
11254 fn derive_commit_url_invalid_url_returns_none() {
11255 let url = derive_commit_url("not-a-url", "abc123");
11256 assert!(url.is_none());
11257 }
11258
11259 #[test]
11260 fn normalize_remote_url_variants() {
11261 assert_eq!(
11262 normalize_remote_url("git@github.com:org/repo.git").as_deref(),
11263 Some("https://github.com/org/repo")
11264 );
11265 assert_eq!(
11266 normalize_remote_url("https://gitlab.com/a/b.git").as_deref(),
11267 Some("https://gitlab.com/a/b")
11268 );
11269 assert_eq!(
11270 normalize_remote_url("http://host/x").as_deref(),
11271 Some("http://host/x")
11272 );
11273 assert_eq!(normalize_remote_url("not a url"), None);
11274 }
11275
11276 #[test]
11277 fn classify_and_bucket_helpers() {
11278 assert_eq!(
11279 classify_unsupported_path("README.md"),
11280 "Documentation / text"
11281 );
11282 assert_eq!(
11283 classify_unsupported_path("pkg.json"),
11284 "JSON manifests and config"
11285 );
11286 assert_eq!(
11287 classify_unsupported_path("Cargo.toml"),
11288 "Project metadata and packaging"
11289 );
11290 assert_eq!(classify_unsupported_path("page.html"), "HTML templates");
11291 assert_eq!(classify_unsupported_path("notes.txt"), "Plain text assets");
11292 assert_eq!(
11293 classify_unsupported_path("data.xyz"),
11294 "Other unsupported text formats"
11295 );
11296 assert_eq!(
11297 classify_unsupported_path("Makefile_noext"),
11298 "Extensionless or custom text files"
11299 );
11300 for label in [
11302 "Documentation / text",
11303 "JSON manifests and config",
11304 "Project metadata and packaging",
11305 "HTML templates",
11306 "Plain text assets",
11307 "Extensionless or custom text files",
11308 "Unknown bucket",
11309 ] {
11310 assert!(!bucket_description(label).is_empty());
11311 assert!(!bucket_recommendation(label).is_empty());
11312 }
11313 }
11314
11315 #[test]
11316 fn summarize_warnings_groups_categories() {
11317 let warnings = vec![
11318 "file 'a.md': unsupported or undetected language".to_string(),
11319 "file 'b.bin': binary file skipped by default".to_string(),
11320 "file 'c.min.js': minified file skipped by policy".to_string(),
11321 "file 'big.txt': file exceeded max_file_size_bytes".to_string(),
11322 ];
11323 let rows = summarize_warnings(&warnings);
11324 assert!(!rows.is_empty(), "warnings should summarize into buckets");
11325 }
11326
11327 #[test]
11328 fn pdf_number_and_string_formatters() {
11329 assert_eq!(pdf_fmt_full(0), "0");
11330 assert!(pdf_fmt_full(1_234_567).contains('1'));
11331 let s = pdf_safe_str("héllo\tworld\u{1F600}");
11333 assert!(!s.is_empty());
11334 }
11335
11336 #[test]
11337 fn file_url_produces_uri() {
11338 let url = file_url(Path::new("/tmp/report.html"));
11339 assert!(url.starts_with("file://") || url.contains("report.html"));
11340 }
11341
11342 #[test]
11343 fn browser_discovery_is_callable_without_panicking() {
11344 unsafe { std::env::remove_var("SLOC_BROWSER") };
11348 unsafe { std::env::remove_var("BROWSER") };
11350 let _ = discover_browser();
11351 let _ = discover_browser_from_env();
11352 #[cfg(windows)]
11353 let _ = windows_browser_candidates();
11354 #[cfg(not(windows))]
11355 let _ = linux_browser_candidates();
11356 unsafe { std::env::set_var("SLOC_BROWSER", "/no/such/browser/path") };
11359 let _ = discover_browser_from_env();
11360 let p = normalize_browser_env_path("\"/quoted/path/chrome\"");
11361 assert!(p.to_string_lossy().contains("chrome"));
11362 unsafe { std::env::remove_var("SLOC_BROWSER") };
11364 }
11365
11366 #[test]
11367 fn which_in_path_returns_none_for_missing() {
11368 assert!(which_in_path("definitely-not-a-real-exe-xyz123").is_none());
11369 }
11370
11371 #[test]
11372 fn write_pdf_from_html_without_browser_errors_gracefully() {
11373 unsafe { std::env::remove_var("SLOC_BROWSER") };
11375 unsafe { std::env::remove_var("BROWSER") };
11377 let dir = std::env::temp_dir().join("sloc_report_pdf_test");
11378 let _ = std::fs::create_dir_all(&dir);
11379 let html = dir.join("in.html");
11380 std::fs::write(&html, "<html><body>hi</body></html>").unwrap();
11381 let out = dir.join("out.pdf");
11382 let res = write_pdf_from_html(&html, &out);
11384 let _ = res;
11386 let _ = std::fs::remove_dir_all(&dir);
11387 }
11388
11389 #[test]
11392 fn helvetica_advance_uppercase_a_differs_by_weight() {
11393 assert_eq!(helvetica_advance('A', true), 722);
11394 assert_eq!(helvetica_advance('A', false), 667);
11395 }
11396
11397 #[test]
11398 fn helvetica_advance_uppercase_w_same_both_weights() {
11399 assert_eq!(helvetica_advance('W', true), 944);
11400 assert_eq!(helvetica_advance('W', false), 944);
11401 }
11402
11403 #[test]
11404 fn helvetica_advance_lowercase_i_differs_by_weight() {
11405 assert_eq!(helvetica_advance('i', true), 278);
11406 assert_eq!(helvetica_advance('i', false), 222);
11407 }
11408
11409 #[test]
11410 fn helvetica_advance_digits_are_556_both_weights() {
11411 for d in '0'..='9' {
11412 assert_eq!(helvetica_advance(d, true), 556, "bold digit {d}");
11413 assert_eq!(helvetica_advance(d, false), 556, "regular digit {d}");
11414 }
11415 }
11416
11417 #[test]
11418 fn helvetica_advance_middle_dot_is_278() {
11419 assert_eq!(helvetica_advance('\u{00B7}', true), 278);
11420 assert_eq!(helvetica_advance('\u{00B7}', false), 278);
11421 }
11422
11423 #[test]
11424 fn helvetica_advance_unknown_char_returns_nonzero_fallback() {
11425 let bold_fb = helvetica_advance('\u{1F600}', true);
11426 let reg_fb = helvetica_advance('\u{1F600}', false);
11427 assert_eq!(bold_fb, 556);
11428 assert_eq!(reg_fb, 500);
11429 }
11430
11431 #[test]
11434 fn helvetica_width_mm_empty_is_zero() {
11435 assert!(helvetica_width_mm("", 10.0, false).abs() < f32::EPSILON);
11436 assert!(helvetica_width_mm("", 10.0, true).abs() < f32::EPSILON);
11437 }
11438
11439 #[test]
11440 fn helvetica_width_mm_scales_linearly_with_pt_size() {
11441 let w6 = helvetica_width_mm("Hello", 6.0, false);
11442 let w12 = helvetica_width_mm("Hello", 12.0, false);
11443 assert!(
11444 2.0_f32.mul_add(-w6, w12).abs() < 1e-4,
11445 "width must be proportional to pt size"
11446 );
11447 }
11448
11449 #[test]
11450 fn helvetica_width_mm_bold_a_wider_than_regular_a() {
11451 let bold = helvetica_width_mm("A", 10.0, true);
11452 let reg = helvetica_width_mm("A", 10.0, false);
11453 assert!(
11454 bold > reg,
11455 "bold 'A' (722) must be wider than regular 'A' (667)"
11456 );
11457 }
11458
11459 #[test]
11460 fn helvetica_width_mm_single_char_matches_manual_calculation() {
11461 let expected = 667.0_f32 * 10.0 * (25.4 / 72.0) / 1000.0;
11463 let got = helvetica_width_mm("A", 10.0, false);
11464 assert!((got - expected).abs() < 1e-4);
11465 }
11466}