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, Author, 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 let author_rows = build_author_rows(run);
465 let own_summary = build_ownership_summary(run);
466
467 let template = ReportTemplate {
468 nonce: String::new(),
471 title: rep.report_title.clone(),
472 browser_title: format!("Oxide-SLOC | {}", rep.report_title),
473 scan_performed_by: run.environment.ci_name.clone().unwrap_or_else(|| {
474 format!(
475 "{} / {}",
476 run.environment.initiator_username, run.environment.initiator_hostname
477 )
478 }),
479 scan_time_pst: to_pst_display(run.tool.timestamp_utc),
480 tool_version: run.tool.version.clone(),
481 is_sub_report,
482 run,
483 language_rows: run
484 .totals_by_language
485 .iter()
486 .map(|row| LanguageRow {
487 language: row.language.display_name().to_string(),
488 files: row.files,
489 total_physical_lines: row.total_physical_lines,
490 code_lines: row.code_lines,
491 comment_lines: row.comment_lines,
492 blank_lines: row.blank_lines,
493 mixed_lines_separate: row.mixed_lines_separate,
494 functions: row.functions,
495 classes: row.classes,
496 variables: row.variables,
497 imports: row.imports,
498 test_count: row.test_count,
499 test_assertion_count: row.test_assertion_count,
500 test_suite_count: row.test_suite_count,
501 test_density_str: if row.code_lines > 0 {
502 #[allow(clippy::cast_precision_loss)]
504 let density = row.test_count as f64 / row.code_lines as f64 * 1000.0;
505 format!("{density:.1}")
506 } else {
507 "—".to_string()
508 },
509 })
510 .collect(),
511 file_rows: run.per_file_records.iter().map(file_row_view).collect(),
512 skipped_rows: run.skipped_file_records.iter().map(file_row_view).collect(),
513 config_json,
514 lang_chart_json: build_lang_chart_json(run),
515 submodule_chart_json: build_submodule_chart_json(run),
516 scatter_chart_json: build_scatter_chart_json(run),
517 semantic_chart_json: build_semantic_chart_json(run),
518 file_size_histogram_json: build_file_size_histogram_json(run),
519 has_submodule_data: !run.submodule_summaries.is_empty(),
520 has_semantic_data: run
521 .totals_by_language
522 .iter()
523 .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
524 has_coverage_data: run.per_file_records.iter().any(|f| f.coverage.is_some()),
525 has_fn_coverage: totals.coverage_functions_found > 0,
526 has_branch_coverage: totals.coverage_branches_found > 0,
527 test_files_count: run
528 .per_file_records
529 .iter()
530 .filter(|f| f.raw_line_categories.test_count > 0)
531 .count() as u64,
532 test_assertion_count: totals.test_assertion_count,
533 test_suite_count: totals.test_suite_count,
534 test_density: format_test_density(totals.code_lines, totals.test_count),
535 most_tested_lang: run
536 .totals_by_language
537 .iter()
538 .filter(|l| l.test_count > 0)
539 .max_by_key(|l| l.test_count)
540 .map_or_else(
541 || "\u{2014}".to_string(),
542 |l| l.language.display_name().to_string(),
543 ),
544 langs_with_tests: run
545 .totals_by_language
546 .iter()
547 .filter(|l| l.test_count > 0)
548 .count(),
549 cov_line_pct: coverage_pct_str(totals.coverage_lines_hit, totals.coverage_lines_found),
550 cov_fn_pct: coverage_pct_str(
551 totals.coverage_functions_hit,
552 totals.coverage_functions_found,
553 ),
554 cov_branch_pct: coverage_pct_str(
555 totals.coverage_branches_hit,
556 totals.coverage_branches_found,
557 ),
558 cov_line_class: coverage_class(totals.coverage_lines_hit, totals.coverage_lines_found),
559 cov_fn_class: coverage_class(
560 totals.coverage_functions_hit,
561 totals.coverage_functions_found,
562 ),
563 cov_branch_class: coverage_class(
564 totals.coverage_branches_hit,
565 totals.coverage_branches_found,
566 ),
567 has_run_warnings: !run.warnings.is_empty(),
568 warning_count: run.warnings.len(),
569 warning_summary_rows,
570 warning_opportunity_rows,
571 warning_console_full: build_warning_console(&run.warnings),
572 logo_text_uri,
573 small_logo_uri,
574 custom_logo_uri,
575 company_name,
576 accent_hex,
577 report_header_footer,
578 chart_js: CHART_JS,
579 run_id_short: run
580 .tool
581 .run_id
582 .split('-')
583 .next_back()
584 .unwrap_or(&run.tool.run_id)
585 .chars()
586 .take(7)
587 .collect(),
588 standalone_pdf_url: pdf_url.map(str::to_string),
589 has_style_data: run.style_summary.is_some(),
590 style_lang_count: run
591 .style_summary
592 .as_ref()
593 .map_or(0, |ss| ss.by_language.len()),
594 style_score_threshold: run.effective_configuration.analysis.style_score_threshold,
595 style_chart_json: run
596 .style_summary
597 .as_ref()
598 .map(build_style_chart_json)
599 .unwrap_or_default(),
600 style_file_json: if run.style_summary.is_some() {
601 build_style_file_json(run)
602 } else {
603 String::new()
604 },
605 style_summary: run.style_summary.clone(),
606 has_delta: delta_ctx.is_some(),
607 delta_code_added: delta_ctx.map_or(0, |d| d.delta_code_added),
608 delta_code_removed: delta_ctx.map_or(0, |d| d.delta_code_removed),
609 delta_unmodified_lines: delta_ctx.map_or(0, |d| d.delta_unmodified_lines),
610 delta_files_added: delta_ctx.map_or(0, |d| d.delta_files_added),
611 delta_files_removed: delta_ctx.map_or(0, |d| d.delta_files_removed),
612 delta_files_modified: delta_ctx.map_or(0, |d| d.delta_files_modified),
613 delta_files_unchanged: delta_ctx.map_or(0, |d| d.delta_files_unchanged),
614 delta_files_total: delta_ctx.map_or(0, |d| {
615 d.delta_files_added
616 + d.delta_files_removed
617 + d.delta_files_modified
618 + d.delta_files_unchanged
619 }),
620 prev_code_lines: delta_ctx.map_or(0, |d| d.prev_code_lines),
621 prev_scan_count: delta_ctx.map_or(0, |d| d.prev_scan_count),
622 prev_scan_label: delta_ctx
623 .map(|d| d.prev_scan_label.clone())
624 .unwrap_or_default(),
625 prev_run_id: delta_ctx
626 .and_then(|d| d.prev_run_id.clone())
627 .unwrap_or_default(),
628 git_commit_url: run
629 .git_remote_url
630 .as_deref()
631 .zip(run.git_commit_long.as_deref())
632 .and_then(|(remote, sha)| derive_commit_url(remote, sha)),
633 git_branch_url: run
634 .git_remote_url
635 .as_deref()
636 .zip(run.git_branch.as_deref())
637 .and_then(|(remote, branch)| derive_branch_url(remote, branch)),
638 has_cocomo: run.cocomo.is_some(),
639 cocomo_effort_str: run
640 .cocomo
641 .as_ref()
642 .map_or(String::new(), |c| format!("{:.2}", c.effort_person_months)),
643 cocomo_duration_str: run
644 .cocomo
645 .as_ref()
646 .map_or(String::new(), |c| format!("{:.2}", c.duration_months)),
647 cocomo_staff_str: run
648 .cocomo
649 .as_ref()
650 .map_or(String::new(), |c| format!("{:.2}", c.avg_staff)),
651 cocomo_ksloc_str: run
652 .cocomo
653 .as_ref()
654 .map_or(String::new(), |c| format!("{:.2}", c.ksloc)),
655 cocomo_mode_label: run
656 .cocomo
657 .as_ref()
658 .map_or_else(|| "Organic".to_string(), |c| {
659 match c.mode {
660 CocomoMode::Organic => "Organic",
661 CocomoMode::SemiDetached => "Semi-detached",
662 CocomoMode::Embedded => "Embedded",
663 }
664 .to_string()
665 }),
666 cocomo_mode_tooltip: run
667 .cocomo
668 .as_ref()
669 .map_or(String::new(), |c| match c.mode {
670 CocomoMode::Organic => "Organic: A small team working on a well-understood \
671 project in a familiar environment with minimal external constraints. \
672 Suited for internal tools, utilities, and projects with stable requirements. \
673 Effort = 2.4 \u{00D7} KSLOC^1.05.",
674 CocomoMode::SemiDetached => "Semi-detached: A mixed team with varying levels of \
675 experience tackling a project with moderate novelty and some rigid constraints. \
676 Typical for compilers, transaction systems, and batch processors. \
677 Effort = 3.0 \u{00D7} KSLOC^1.12.",
678 CocomoMode::Embedded => "Embedded: Tight hardware, software, or operational \
679 constraints requiring significant innovation and deep integration work. \
680 Typical for real-time control systems and safety-critical software. \
681 Effort = 3.6 \u{00D7} KSLOC^1.20.",
682 }.to_string()),
683 uloc: run.uloc,
684 dryness_pct_str: run
685 .dryness_pct
686 .map_or(String::new(), |d| format!("{d:.1}")),
687 duplicate_group_count: run.duplicate_groups.len(),
688 has_hotspots: !hotspot_rows.is_empty(),
689 hotspot_rows,
690 has_ownership: !author_rows.is_empty(),
691 ownership_rows: author_rows,
692 own_contributors: own_summary.contributors,
693 own_top_name: own_summary.top_name,
694 own_top_pct_str: own_summary.top_pct_str,
695 own_bus_factor: own_summary.bus_factor,
696 own_total_code: own_summary.total_code,
697 own_dev_code: own_summary.dev_code,
698 own_test_code: own_summary.test_code,
699 own_test_pct_str: own_summary.test_pct_str,
700 own_total_comment: own_summary.total_comment,
701 };
702
703 template.render().context("failed to render HTML report")
704}
705
706struct HotspotRow {
708 path: String,
709 code_lines: u64,
710 commit_count: u32,
711 last_commit_date: String,
712 score: u64,
713 owner: String,
715 owner_profile_url: Option<String>,
718}
719
720fn author_profile_url(email: &str) -> Option<String> {
727 let (local, domain) = email.trim().split_once('@')?;
728 let host = domain.strip_prefix("users.noreply.")?;
729 let handle = local.rsplit_once('+').map_or(local, |(_, h)| h);
730 if handle.is_empty() || host.is_empty() {
731 return None;
732 }
733 Some(format!("https://{host}/{handle}"))
734}
735
736fn author_profile_url_for(author: &Author) -> Option<String> {
740 author_profile_url(&author.canonical_email).or_else(|| {
741 author
742 .aliases
743 .iter()
744 .find_map(|al| author_profile_url(&al.email))
745 })
746}
747
748fn build_hotspot_rows(run: &AnalysisRun, limit: usize) -> Vec<HotspotRow> {
754 let author_names: std::collections::HashMap<u32, &str> = run
756 .authors
757 .iter()
758 .map(|a| (a.id, a.canonical_name.as_str()))
759 .collect();
760 let author_urls: std::collections::HashMap<u32, String> = run
762 .authors
763 .iter()
764 .filter_map(|a| author_profile_url_for(a).map(|url| (a.id, url)))
765 .collect();
766 let mut rows: Vec<HotspotRow> = run
767 .per_file_records
768 .iter()
769 .filter_map(|r| {
770 let commits = r.commit_count?;
771 let code = r.effective_counts.code_lines;
772 let top_owner_id = r
773 .ownership
774 .as_ref()
775 .and_then(|o| o.first())
776 .map(|top| top.author_id);
777 let owner = top_owner_id
778 .and_then(|id| author_names.get(&id))
779 .map_or_else(String::new, |name| (*name).to_string());
780 let owner_profile_url = top_owner_id.and_then(|id| author_urls.get(&id).cloned());
781 Some(HotspotRow {
782 path: r.relative_path.clone(),
783 code_lines: code,
784 commit_count: commits,
785 last_commit_date: r.last_commit_date.as_deref().map_or_else(String::new, |d| {
787 d.split('T').next().unwrap_or(d).to_string()
788 }),
789 score: code.saturating_mul(u64::from(commits)),
790 owner,
791 owner_profile_url,
792 })
793 })
794 .collect();
795 rows.sort_by(|a, b| {
796 b.score
797 .cmp(&a.score)
798 .then(b.commit_count.cmp(&a.commit_count))
799 });
800 rows.truncate(limit);
801 rows
802}
803
804struct AuthorReportRow {
806 name: String,
807 email: String,
808 code: u64,
809 comment: u64,
810 blank: u64,
811 total: u64,
812 code_pct_str: String,
814 files_owned: u64,
816 files: Vec<OwnedFileRow>,
819 rank: usize,
822 rank_class: &'static str,
824 initials: String,
826 color: &'static str,
828 profile_url: Option<String>,
830}
831
832struct OwnedFileRow {
835 path: String,
836 code: u64,
837 comment: u64,
838 blank: u64,
839 total: u64,
840 last_changed: String,
841}
842
843const REPORT_PALETTE: &[&str] = &[
845 "#C45C10", "#2A6846", "#4472C4", "#805099", "#D4A017", "#B23030", "#2E75B6", "#70AD47",
846 "#FF9900", "#9E480E", "#156082", "#5BA8A0",
847];
848
849fn author_initials(name: &str) -> String {
853 let parts: Vec<&str> = name.split_whitespace().collect();
854 match parts.as_slice() {
855 [] => "?".to_string(),
856 [one] => one.chars().take(2).collect::<String>().to_uppercase(),
857 [first, .., last] => {
858 let a = first.chars().next().unwrap_or('?');
859 let b = last.chars().next().unwrap_or('?');
860 format!("{a}{b}").to_uppercase()
861 }
862 }
863}
864
865fn build_author_rows(run: &AnalysisRun) -> Vec<AuthorReportRow> {
868 if run.authors.is_empty() {
869 return Vec::new();
870 }
871 let total_code: u64 = run.authors.iter().map(|a| a.counts.code_lines).sum();
872 let mut owned_files: std::collections::HashMap<u32, Vec<OwnedFileRow>> =
874 std::collections::HashMap::new();
875 for rec in &run.per_file_records {
876 if let Some(top) = rec.ownership.as_ref().and_then(|o| o.first()) {
877 owned_files
878 .entry(top.author_id)
879 .or_default()
880 .push(OwnedFileRow {
881 path: rec.relative_path.clone(),
882 code: top.counts.code_lines,
883 comment: top.counts.comment_lines,
884 blank: top.counts.blank_lines,
885 total: top.counts.total_lines,
886 last_changed: rec
887 .last_commit_date
888 .as_deref()
889 .map_or_else(String::new, |d| {
890 d.split('T').next().unwrap_or(d).to_string()
891 }),
892 });
893 }
894 }
895 let mut rows: Vec<AuthorReportRow> = run
896 .authors
897 .iter()
898 .map(|a| {
899 let pct = if total_code > 0 {
900 a.counts.code_lines as f64 / total_code as f64 * 100.0
901 } else {
902 0.0
903 };
904 let mut files = owned_files.remove(&a.id).unwrap_or_default();
905 files.sort_by_key(|f| std::cmp::Reverse(f.code));
906 AuthorReportRow {
907 name: a.canonical_name.clone(),
908 email: a.canonical_email.clone(),
909 code: a.counts.code_lines,
910 comment: a.counts.comment_lines,
911 blank: a.counts.blank_lines,
912 total: a.counts.total_lines,
913 code_pct_str: format!("{pct:.1}"),
914 files_owned: files.len() as u64,
915 files,
916 rank: 0,
917 rank_class: "",
918 initials: author_initials(&a.canonical_name),
919 color: REPORT_PALETTE[0],
920 profile_url: author_profile_url_for(a),
921 }
922 })
923 .collect();
924 let mut rank = 0usize;
927 for row in &mut rows {
928 if row.files.is_empty() {
929 continue;
930 }
931 rank += 1;
932 row.rank = rank;
933 row.rank_class = match rank {
934 1 => "lb-r1",
935 2 => "lb-r2",
936 3 => "lb-r3",
937 _ => "",
938 };
939 row.color = REPORT_PALETTE[(rank - 1) % REPORT_PALETTE.len()];
940 }
941 rows
942}
943
944#[derive(Default)]
947struct OwnershipSummary {
948 contributors: usize,
949 top_name: String,
950 top_pct_str: String,
951 bus_factor: usize,
952 total_code: u64,
953 dev_code: u64,
954 test_code: u64,
955 test_pct_str: String,
956 total_comment: u64,
957}
958
959fn build_ownership_summary(run: &AnalysisRun) -> OwnershipSummary {
962 if run.authors.is_empty() {
963 return OwnershipSummary::default();
964 }
965 let total_code: u64 = run.authors.iter().map(|a| a.counts.code_lines).sum();
966 let total_comment: u64 = run.authors.iter().map(|a| a.counts.comment_lines).sum();
967 let mut acc = 0u64;
969 let mut bus_factor = 0usize;
970 for a in &run.authors {
971 acc += a.counts.code_lines;
972 bus_factor += 1;
973 if total_code > 0 && acc * 2 >= total_code {
974 break;
975 }
976 }
977 let test_code: u64 = run
979 .per_file_records
980 .iter()
981 .filter(|rec| rec.is_test_file())
982 .filter_map(|rec| rec.ownership.as_ref())
983 .flat_map(|own| own.iter().map(|o| o.counts.code_lines))
984 .sum();
985 let top = &run.authors[0];
986 let top_pct = if total_code > 0 {
987 top.counts.code_lines as f64 / total_code as f64 * 100.0
988 } else {
989 0.0
990 };
991 let test_pct = if total_code > 0 {
992 test_code as f64 / total_code as f64 * 100.0
993 } else {
994 0.0
995 };
996 OwnershipSummary {
997 contributors: run.authors.len(),
998 top_name: top.canonical_name.clone(),
999 top_pct_str: format!("{top_pct:.0}"),
1000 bus_factor,
1001 total_code,
1002 dev_code: total_code.saturating_sub(test_code),
1003 test_code,
1004 test_pct_str: format!("{test_pct:.0}"),
1005 total_comment,
1006 }
1007}
1008
1009pub fn write_html(run: &AnalysisRun, output_path: &Path) -> Result<()> {
1015 let html = render_html_inner(run, false, None, None)?;
1016 fs::write(output_path, html)
1017 .with_context(|| format!("failed to write HTML report to {}", output_path.display()))
1018}
1019
1020pub fn write_html_with_pdf_link(
1030 run: &AnalysisRun,
1031 output_path: &Path,
1032 pdf_path: Option<&Path>,
1033) -> Result<()> {
1034 let pdf_relative = pdf_path.and_then(|pdf| {
1035 let html_dir = output_path.parent()?;
1036 let pdf_dir = pdf.parent()?;
1037 if html_dir == pdf_dir {
1038 pdf.file_name().map(|n| n.to_string_lossy().into_owned())
1039 } else {
1040 None
1041 }
1042 });
1043 let html = render_html_inner(run, false, pdf_relative.as_deref(), None)?;
1044 fs::write(output_path, html)
1045 .with_context(|| format!("failed to write HTML report to {}", output_path.display()))
1046}
1047
1048fn launch_cdp_browser(
1054 browser_path: std::path::PathBuf,
1055 no_sandbox: bool,
1056) -> Result<headless_chrome::Browser> {
1057 use headless_chrome::{Browser, LaunchOptions};
1058
1059 if no_sandbox {
1060 return Browser::new(LaunchOptions {
1061 headless: true,
1062 path: Some(browser_path),
1063 window_size: Some((1122, 794)),
1064 sandbox: false,
1065 ..Default::default()
1066 })
1067 .context("failed to launch browser via CDP (no-sandbox)");
1068 }
1069
1070 Browser::new(LaunchOptions {
1073 headless: true,
1074 path: Some(browser_path),
1075 window_size: Some((1122, 794)),
1076 sandbox: true,
1077 ..Default::default()
1078 })
1079 .map_err(|e| {
1080 anyhow::anyhow!(
1081 "Browser launch failed with sandbox enabled: {e:#}\n\
1082 If running in a container without user namespaces (e.g. Docker with cap_drop:ALL), \
1083 set SLOC_BROWSER_NOSANDBOX=1 to opt into --no-sandbox mode."
1084 )
1085 })
1086}
1087
1088fn report_chart_error_if_any(tab: &headless_chrome::Tab) {
1090 let Ok(e) = tab.evaluate("window.oxSlocChartError||''", false) else {
1091 return;
1092 };
1093 let Some(serde_json::Value::String(msg)) = e.value else {
1094 return;
1095 };
1096 if !msg.is_empty() {
1097 eprintln!("[oxide-sloc][pdf] chart JS error (charts may be missing): {msg}");
1098 }
1099}
1100
1101fn wait_for_charts_ready(tab: &headless_chrome::Tab) {
1103 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
1104 let mut last_cdp_err: Option<String> = None;
1105 loop {
1106 match tab.evaluate("!!window.oxSlocChartsReady", false) {
1107 Ok(r) => {
1108 last_cdp_err = None;
1109 if matches!(r.value, Some(serde_json::Value::Bool(true))) {
1110 report_chart_error_if_any(tab);
1111 return;
1112 }
1113 }
1114 Err(e) => {
1115 let msg = format!("{e:#}");
1116 if last_cdp_err.as_deref() != Some(&msg) {
1117 eprintln!("[oxide-sloc][pdf] CDP evaluate error (will retry): {msg}");
1118 last_cdp_err = Some(msg);
1119 }
1120 }
1121 }
1122 if std::time::Instant::now() >= deadline {
1123 report_chart_error_if_any(tab);
1124 break;
1125 }
1126 std::thread::sleep(std::time::Duration::from_millis(250));
1127 }
1128}
1129
1130fn extract_banner_text(tab: &headless_chrome::Tab) -> Option<String> {
1132 let result = tab
1133 .evaluate(
1134 "(function(){\
1135 var el=document.querySelector('.report-id-banner');\
1136 return el?el.textContent.trim():null;\
1137 })()",
1138 false,
1139 )
1140 .ok()?;
1141 match result.value? {
1142 serde_json::Value::String(s) if !s.is_empty() => Some(s),
1143 _ => None,
1144 }
1145}
1146
1147fn extract_pdf_template(tab: &headless_chrome::Tab, id: &str) -> Option<String> {
1152 let js = format!(
1154 "(function(){{var el=document.getElementById('{id}');return el?el.innerHTML:null;}})()"
1155 );
1156 let result = tab.evaluate(&js, false).ok()?;
1157 match result.value? {
1158 serde_json::Value::String(s) if !s.trim().is_empty() => Some(s),
1159 _ => None,
1160 }
1161}
1162
1163fn write_pdf_via_cdp(html_path: &Path, output_path: &Path) -> Result<()> {
1169 use headless_chrome::types::PrintToPdfOptions;
1170
1171 let browser_path = discover_browser().context(
1172 "no supported Chromium-based browser found; \
1173 set SLOC_BROWSER/BROWSER or install Chrome, Chromium, Edge, Brave, Vivaldi, or Opera",
1174 )?;
1175 eprintln!("[oxide-sloc][pdf] browser = {}", browser_path.display());
1176
1177 let no_sandbox = std::env::var("SLOC_BROWSER_NOSANDBOX").as_deref() == Ok("1");
1178 if no_sandbox {
1179 eprintln!("[oxide-sloc][pdf] --no-sandbox enabled via SLOC_BROWSER_NOSANDBOX=1");
1180 }
1181
1182 let browser = launch_cdp_browser(browser_path, no_sandbox)?;
1183 let tab = browser.new_tab().context("failed to open browser tab")?;
1184 tab.set_default_timeout(std::time::Duration::from_secs(90));
1189
1190 let html_for_url = PathBuf::from(
1191 html_path
1192 .to_string_lossy()
1193 .trim_start_matches(r"\\?\")
1194 .to_string(),
1195 );
1196 let url = file_url(&html_for_url);
1197 eprintln!("[oxide-sloc][pdf] url = {url}");
1198
1199 tab.navigate_to(&url)
1200 .context("failed to navigate browser to HTML file")?;
1201 tab.wait_until_navigated()
1202 .context("browser navigation did not complete")?;
1203
1204 wait_for_charts_ready(&tab);
1205
1206 let chrome = build_pdf_chrome(&tab);
1209
1210 let pdf_bytes = tab
1211 .print_to_pdf(Some(PrintToPdfOptions {
1212 landscape: Some(true),
1213 print_background: Some(true),
1214 scale: Some(0.97),
1215 paper_width: Some(11.69), paper_height: Some(8.27), margin_top: Some(chrome.margin_top),
1218 margin_bottom: Some(chrome.margin_bottom),
1219 margin_left: Some(0.0),
1220 margin_right: Some(0.0),
1221 prefer_css_page_size: Some(false),
1222 display_header_footer: if chrome.display_header_footer {
1223 Some(true)
1224 } else {
1225 None
1226 },
1227 header_template: chrome.header_template,
1228 footer_template: chrome.footer_template,
1229 ..Default::default()
1230 }))
1231 .context("browser failed to generate PDF")?;
1232
1233 fs::write(output_path, &pdf_bytes)
1234 .with_context(|| format!("failed to write PDF to {}", output_path.display()))?;
1235
1236 eprintln!("[oxide-sloc][pdf] wrote {} bytes", pdf_bytes.len());
1237 Ok(())
1238}
1239
1240struct PdfChrome {
1242 header_template: Option<String>,
1243 footer_template: Option<String>,
1244 display_header_footer: bool,
1245 margin_top: f64,
1246 margin_bottom: f64,
1247}
1248
1249fn pdf_banner_template(text: &str) -> String {
1252 let escaped = text
1253 .replace('&', "&")
1254 .replace('<', "<")
1255 .replace('>', ">")
1256 .replace('"', """);
1257 format!(
1258 r#"<div style="font-size:10px;width:100%;text-align:center;\
1259color:#fff;background:#b35428;padding:5px 0;\
1260font-family:sans-serif;font-weight:700;letter-spacing:0.05em;\
1261-webkit-print-color-adjust:exact;print-color-adjust:exact;">{escaped}</div>"#
1262 )
1263}
1264
1265const fn pdf_margins(
1268 has_banner: bool,
1269 has_native_header: bool,
1270 has_native_footer: bool,
1271) -> (f64, f64) {
1272 let top = if has_banner {
1273 0.35
1274 } else if has_native_header {
1275 0.55
1276 } else {
1277 0.0
1278 };
1279 let bottom = if has_banner {
1280 0.25
1281 } else if has_native_footer {
1282 0.42
1283 } else {
1284 0.0
1285 };
1286 (top, bottom)
1287}
1288
1289fn pdf_header_footer_templates(
1293 banner_text: Option<&str>,
1294 native_header: Option<String>,
1295 native_footer: Option<String>,
1296) -> (Option<String>, Option<String>) {
1297 if let Some(t) = banner_text {
1298 let tmpl = pdf_banner_template(t);
1299 return (Some(tmpl.clone()), Some(tmpl));
1300 }
1301 if native_header.is_some() || native_footer.is_some() {
1302 let empty = || "<span></span>".to_string();
1303 return (
1304 Some(native_header.unwrap_or_else(empty)),
1305 Some(native_footer.unwrap_or_else(empty)),
1306 );
1307 }
1308 (None, None)
1309}
1310
1311fn build_pdf_chrome(tab: &headless_chrome::Tab) -> PdfChrome {
1318 let banner_text = extract_banner_text(tab);
1319 if let Some(ref t) = banner_text {
1320 eprintln!("[oxide-sloc][pdf] report banner detected: {t}");
1321 }
1322 let has_banner = banner_text.is_some();
1323
1324 let native_header = if has_banner {
1325 None
1326 } else {
1327 extract_pdf_template(tab, "pdf-native-header")
1328 };
1329 let native_footer = if has_banner {
1330 None
1331 } else {
1332 extract_pdf_template(tab, "pdf-native-footer")
1333 };
1334 let has_native_header = native_header.is_some();
1335 let has_native_footer = native_footer.is_some();
1336
1337 let (header_template, footer_template) =
1338 pdf_header_footer_templates(banner_text.as_deref(), native_header, native_footer);
1339 let (margin_top, margin_bottom) = pdf_margins(has_banner, has_native_header, has_native_footer);
1340
1341 PdfChrome {
1342 header_template,
1343 footer_template,
1344 display_header_footer: has_banner || has_native_header || has_native_footer,
1345 margin_top,
1346 margin_bottom,
1347 }
1348}
1349
1350fn discover_wkhtmltopdf() -> Option<PathBuf> {
1360 if let Some(p) = which_in_path("wkhtmltopdf") {
1361 return Some(p);
1362 }
1363
1364 #[cfg(windows)]
1365 {
1366 for var in ["ProgramFiles", "ProgramFiles(x86)"] {
1367 if let Ok(base) = std::env::var(var) {
1368 let candidate = PathBuf::from(base)
1369 .join("wkhtmltopdf")
1370 .join("bin")
1371 .join("wkhtmltopdf.exe");
1372 if candidate.is_file() {
1373 return Some(candidate);
1374 }
1375 }
1376 }
1377 }
1378
1379 #[cfg(not(windows))]
1380 for p in [
1381 "/usr/bin/wkhtmltopdf",
1382 "/usr/local/bin/wkhtmltopdf",
1383 "/opt/wkhtmltopdf/bin/wkhtmltopdf",
1384 "/snap/bin/wkhtmltopdf",
1385 ] {
1386 let candidate = PathBuf::from(p);
1387 if candidate.is_file() {
1388 return Some(candidate);
1389 }
1390 }
1391
1392 None
1393}
1394
1395fn write_pdf_via_wkhtmltopdf(html_path: &Path, pdf_path: &Path) -> Result<()> {
1402 eprintln!("[oxide-sloc][pdf] trying wkhtmltopdf fallback");
1403
1404 let exe = discover_wkhtmltopdf().context(
1405 "wkhtmltopdf not found. \
1406 Linux: install via 'dnf install wkhtmltopdf' or 'apt install wkhtmltopdf'. \
1407 Windows: install the MSI from https://wkhtmltopdf.org/downloads.html. \
1408 Alternatively, set SLOC_BROWSER to a Chromium-based browser executable.",
1409 )?;
1410 eprintln!("[oxide-sloc][pdf] wkhtmltopdf = {}", exe.display());
1411
1412 let html_normalized = PathBuf::from(
1414 html_path
1415 .to_string_lossy()
1416 .trim_start_matches(r"\\?\")
1417 .to_string(),
1418 );
1419 let html_url = file_url(&html_normalized);
1421 eprintln!("[oxide-sloc][pdf] wkhtmltopdf url = {html_url}");
1422
1423 let pdf_str = pdf_path
1424 .to_str()
1425 .context("PDF output path contains non-UTF-8 characters")?;
1426
1427 let output = std::process::Command::new(&exe)
1428 .args([
1429 "--enable-javascript",
1430 "--javascript-delay",
1431 "2000",
1432 "--quiet",
1433 "--orientation",
1434 "Landscape",
1435 "--page-size",
1436 "A4",
1437 "--margin-top",
1438 "9",
1439 "--margin-bottom",
1440 "9",
1441 "--margin-left",
1442 "13",
1443 "--margin-right",
1444 "13",
1445 "--print-media-type",
1446 &html_url,
1447 pdf_str,
1448 ])
1449 .output()
1450 .with_context(|| format!("failed to launch wkhtmltopdf at {}", exe.display()))?;
1451
1452 if !output.status.success() {
1453 let stderr = String::from_utf8_lossy(&output.stderr);
1454 anyhow::bail!("wkhtmltopdf exited with {}: {stderr}", output.status);
1455 }
1456
1457 if !pdf_path.exists() {
1458 anyhow::bail!(
1459 "wkhtmltopdf exited successfully but {} was not created",
1460 pdf_path.display()
1461 );
1462 }
1463
1464 eprintln!("[oxide-sloc][pdf] wkhtmltopdf wrote {}", pdf_path.display());
1465 Ok(())
1466}
1467
1468struct PdfCtx<'a> {
1469 layer: &'a crate::pdf_compat::PdfLayerReference,
1470 font_reg: crate::pdf_compat::IndirectFontRef,
1471 font_bold: crate::pdf_compat::IndirectFontRef,
1472 w: f32,
1473 margin: f32,
1474 row_h: f32,
1475 tbl_hdr_h: f32,
1476}
1477
1478#[derive(Clone, Copy)]
1481struct PdfPageDims {
1482 w: f32,
1483 h: f32,
1484 margin: f32,
1485 footer_h: f32,
1486 row_h: f32,
1487 tbl_hdr_h: f32,
1488}
1489
1490#[allow(
1491 clippy::cast_precision_loss,
1492 clippy::suboptimal_flops,
1493 clippy::too_many_lines
1494)]
1495fn runtime_mode_display(mode: &str) -> &str {
1496 match mode {
1497 "serve" => "Web UI",
1498 "analyze" => "CLI",
1499 "git-scan" => "Git Scan",
1500 "git-compare" => "Git Compare",
1501 "watch" => "Watch",
1502 other => other,
1503 }
1504}
1505
1506fn pdf_render_page1_header(
1507 ctx: &PdfCtx<'_>,
1508 run: &AnalysisRun,
1509 ts: &str,
1510 title: &str,
1511 h: f32,
1512 hdr_h: f32,
1513 banner: Option<&str>,
1514) -> f32 {
1515 use crate::pdf_compat::{Color, Mm, Rgb};
1516 let hdr_y = h - hdr_h;
1517 pdf_fill_rect(
1518 ctx.layer,
1519 0.0,
1520 hdr_y,
1521 ctx.w,
1522 hdr_h,
1523 Rgb::new(0.098, 0.11, 0.15, None),
1524 );
1525 ctx.layer
1526 .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
1527 ctx.layer.use_text(
1528 "oxide-sloc",
1529 13.0,
1530 Mm(ctx.margin),
1531 Mm(hdr_y + 4.5),
1532 ctx.font_bold,
1533 );
1534 ctx.layer
1535 .set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
1536 ctx.layer.use_text(
1537 "Code Metrics Report",
1538 9.5,
1539 Mm(54.0),
1540 Mm(hdr_y + 5.0),
1541 ctx.font_reg,
1542 );
1543 ctx.layer.use_text(
1544 pdf_safe_str(ts),
1545 8.0,
1546 Mm(ctx.w - 70.0),
1547 Mm(hdr_y + 5.0),
1548 ctx.font_reg,
1549 );
1550 if let Some(text) = banner {
1552 let safe = pdf_trunc(&pdf_safe_str(text), 40);
1553 #[allow(
1557 clippy::cast_precision_loss,
1558 reason = "small bounded char count; sub-mm layout offset"
1559 )]
1560 let text_x = (safe.len() as f32).mul_add(-0.97, ctx.w / 2.0).max(95.0);
1561 ctx.layer
1562 .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
1563 ctx.layer
1564 .use_text(safe, 9.0, Mm(text_x), Mm(hdr_y + 4.5), ctx.font_bold);
1565 }
1566 let title_text_y = hdr_y - 5.5;
1567 ctx.layer
1568 .set_fill_color(Color::Rgb(Rgb::new(0.098, 0.11, 0.15, None)));
1569 ctx.layer.use_text(
1570 pdf_trunc(&pdf_safe_str(title), 55),
1571 9.5,
1572 Mm(ctx.margin),
1573 Mm(title_text_y),
1574 ctx.font_bold,
1575 );
1576 let roots_text_y = title_text_y - 5.0;
1577 let roots: String = run
1579 .input_roots
1580 .iter()
1581 .map(|r| pdf_safe_str(r))
1582 .collect::<Vec<_>>()
1583 .join(" ");
1584 ctx.layer
1585 .set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)));
1586 ctx.layer.use_text(
1587 pdf_trunc(&roots, 85),
1588 6.5,
1589 Mm(ctx.margin),
1590 Mm(roots_text_y),
1591 ctx.font_reg,
1592 );
1593 pdf_render_page1_gitbox(ctx, run, title_text_y, roots_text_y);
1595 roots_text_y
1596}
1597
1598fn pdf_render_page1_gitbox(
1600 ctx: &PdfCtx<'_>,
1601 run: &AnalysisRun,
1602 title_text_y: f32,
1603 roots_text_y: f32,
1604) {
1605 use crate::pdf_compat::{Color, Mm, Rgb};
1606 let mut git_parts: Vec<String> = vec![];
1607 if let Some(ref b) = run.git_branch {
1608 git_parts.push(format!("Branch: {}", pdf_safe_str(b)));
1609 }
1610 if let Some(ref c) = run.git_commit_short {
1611 git_parts.push(format!("Commit: {}", pdf_safe_str(c)));
1612 }
1613 if let Some(ref t) = run.git_nearest_tag {
1614 git_parts.push(format!("Tag: {}", pdf_safe_str(t)));
1615 }
1616 let git_str = pdf_trunc(&git_parts.join(" \u{00B7} "), 70);
1617
1618 let initiator = run
1619 .environment
1620 .ci_name
1621 .as_deref()
1622 .unwrap_or(run.environment.initiator_username.as_str());
1623 let mode_label = runtime_mode_display(&run.environment.runtime_mode);
1624 let env_str = format!(
1625 "OS: {} / {} \u{00B7} User: {} \u{00B7} Host: {} \u{00B7} Source: {}",
1626 pdf_safe_str(&run.environment.operating_system),
1627 pdf_safe_str(&run.environment.architecture),
1628 pdf_safe_str(initiator),
1629 pdf_safe_str(&run.environment.initiator_hostname),
1630 mode_label,
1631 );
1632 let env_trunc = pdf_trunc(&env_str, 100);
1633
1634 let right_anchor = ctx.w - ctx.margin - 6.0;
1636 let git_w = helvetica_width_mm(&git_str, 7.5, true);
1639 let env_w = helvetica_width_mm(&env_trunc, 6.5, false);
1640 let max_w = git_w.max(env_w);
1641
1642 let pad_h: f32 = 3.5;
1644 let pad_v: f32 = 1.8;
1645 let box_left = (right_anchor - max_w - pad_h).max(ctx.w / 2.0 - pad_h);
1646 let box_right = right_anchor + pad_h;
1647 let box_width = box_right - box_left;
1648 let box_bot = roots_text_y - pad_v;
1649 let box_top = title_text_y + pad_v + 1.5;
1650 let box_height = box_top - box_bot;
1651 pdf_fill_rect(
1652 ctx.layer,
1653 box_left - 0.6,
1654 box_bot - 0.6,
1655 box_width + 1.2,
1656 box_height + 1.2,
1657 Rgb::new(0.80, 0.75, 0.68, None),
1658 );
1659 pdf_fill_rect(
1660 ctx.layer,
1661 box_left,
1662 box_bot,
1663 box_width,
1664 box_height,
1665 Rgb::new(0.97, 0.95, 0.92, None),
1666 );
1667
1668 if !git_str.is_empty() {
1670 let git_x = (right_anchor - git_w).max(box_left + 2.0);
1671 ctx.layer
1672 .set_fill_color(Color::Rgb(Rgb::new(0.25, 0.42, 0.25, None)));
1673 ctx.layer.use_text(
1674 git_str.as_str(),
1675 7.5,
1676 Mm(git_x),
1677 Mm(title_text_y),
1678 ctx.font_bold,
1679 );
1680 }
1681 let env_x = (right_anchor - env_w).max(box_left + 2.0);
1683 ctx.layer
1684 .set_fill_color(Color::Rgb(Rgb::new(0.38, 0.38, 0.38, None)));
1685 ctx.layer.use_text(
1686 env_trunc.as_str(),
1687 6.5,
1688 Mm(env_x),
1689 Mm(roots_text_y),
1690 ctx.font_reg,
1691 );
1692}
1693
1694#[allow(clippy::cast_precision_loss)]
1695fn pdf_render_summary_chips(ctx: &PdfCtx<'_>, run: &AnalysisRun, roots_text_y: f32) -> f32 {
1696 use crate::pdf_compat::{Color, Mm, Rgb};
1697 let tot = &run.summary_totals;
1698 let chip_gap: f32 = 5.0;
1699 let chip_w = 3.0f32.mul_add(-chip_gap, 2.0f32.mul_add(-ctx.margin, ctx.w)) / 4.0;
1700 let chip_h: f32 = 17.0;
1701 let row1_bot = roots_text_y - 4.0 - chip_h;
1702 let row1: [(&str, u64); 4] = [
1703 ("Code Lines", tot.code_lines),
1704 ("Comment Lines", tot.comment_lines),
1705 ("Blank Lines", tot.blank_lines),
1706 ("Physical Lines", tot.total_physical_lines),
1707 ];
1708 for (i, (label, value)) in row1.iter().enumerate() {
1709 let cx = (i as f32).mul_add(chip_w + chip_gap, ctx.margin);
1710 pdf_fill_rect(
1711 ctx.layer,
1712 cx,
1713 row1_bot,
1714 chip_w,
1715 chip_h,
1716 Rgb::new(0.945, 0.925, 0.90, None),
1717 );
1718 ctx.layer
1719 .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
1720 ctx.layer.use_text(
1722 pdf_fmt_full(*value),
1723 13.0,
1724 Mm(cx + 4.0),
1725 Mm(row1_bot + 9.0),
1726 ctx.font_bold,
1727 );
1728 ctx.layer
1729 .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
1730 ctx.layer.use_text(
1731 pdf_safe_str(label),
1732 6.5,
1733 Mm(cx + 4.0),
1734 Mm(row1_bot + 3.0),
1735 ctx.font_reg,
1736 );
1737 }
1738 let row2_bot = row1_bot - 3.0 - chip_h;
1739 let row2_4th = if tot.test_count > 0 {
1740 ("Test Methods", tot.test_count)
1741 } else if tot.classes > 0 {
1742 ("Classes", tot.classes)
1743 } else {
1744 ("Mixed Lines", tot.mixed_lines_separate)
1745 };
1746 let row2: [(&str, u64); 4] = [
1747 ("Files Analyzed", tot.files_analyzed),
1748 ("Files Skipped", tot.files_skipped),
1749 ("Functions", tot.functions),
1750 row2_4th,
1751 ];
1752 for (i, (label, value)) in row2.iter().enumerate() {
1753 let cx = (i as f32).mul_add(chip_w + chip_gap, ctx.margin);
1754 pdf_fill_rect(
1755 ctx.layer,
1756 cx,
1757 row2_bot,
1758 chip_w,
1759 chip_h,
1760 Rgb::new(0.91, 0.92, 0.96, None),
1761 );
1762 ctx.layer
1763 .set_fill_color(Color::Rgb(Rgb::new(0.15, 0.25, 0.55, None)));
1764 ctx.layer.use_text(
1765 pdf_fmt_full(*value),
1766 13.0,
1767 Mm(cx + 4.0),
1768 Mm(row2_bot + 9.0),
1769 ctx.font_bold,
1770 );
1771 ctx.layer
1772 .set_fill_color(Color::Rgb(Rgb::new(0.35, 0.35, 0.45, None)));
1773 ctx.layer.use_text(
1774 pdf_safe_str(label),
1775 6.5,
1776 Mm(cx + 4.0),
1777 Mm(row2_bot + 3.0),
1778 ctx.font_reg,
1779 );
1780 }
1781 row2_bot
1782}
1783
1784#[allow(clippy::cast_precision_loss)]
1785fn pdf_info_parts_stats(tot: &SummaryTotals) -> Vec<String> {
1786 let total = tot.total_physical_lines.max(1) as f64;
1787 let code_pct = tot.code_lines as f64 / total * 100.0;
1788 let cmt_pct = tot.comment_lines as f64 / total * 100.0;
1789 let blank_pct = tot.blank_lines as f64 / total * 100.0;
1790 let mixed_pct = tot.mixed_lines_separate as f64 / total * 100.0;
1791 let mut parts = vec![
1792 format!(
1793 "Code: {code_pct:.1}% ({} lines)",
1794 pdf_fmt_full(tot.code_lines)
1795 ),
1796 format!(
1797 "Comments: {cmt_pct:.1}% ({} lines)",
1798 pdf_fmt_full(tot.comment_lines)
1799 ),
1800 format!(
1801 "Blank: {blank_pct:.1}% ({} lines)",
1802 pdf_fmt_full(tot.blank_lines)
1803 ),
1804 ];
1805 if tot.functions > 0 {
1806 parts.push(format!("Functions: {}", pdf_fmt_full(tot.functions)));
1807 }
1808 if tot.mixed_lines_separate > 0 {
1809 parts.push(format!(
1810 "Mixed: {mixed_pct:.1}% ({} lines)",
1811 pdf_fmt_full(tot.mixed_lines_separate)
1812 ));
1813 }
1814 if tot.imports > 0 {
1815 parts.push(format!("Imports: {}", pdf_fmt_full(tot.imports)));
1816 }
1817 if tot.variables > 0 {
1818 parts.push(format!("Variables: {}", pdf_fmt_full(tot.variables)));
1819 }
1820 if tot.classes > 0 {
1821 parts.push(format!("Classes: {}", pdf_fmt_full(tot.classes)));
1822 }
1823 parts
1824}
1825
1826fn pdf_info_parts_git(run: &AnalysisRun) -> Vec<String> {
1827 let mut parts: Vec<String> = Vec::new();
1828 if let Some(ref b) = run.git_branch {
1829 parts.push(format!("Branch: {}", pdf_safe_str(b)));
1830 }
1831 if let Some(ref c) = run.git_commit_short {
1832 parts.push(format!("Commit: {}", pdf_safe_str(c)));
1833 }
1834 if let Some(ref t) = run.git_nearest_tag {
1835 parts.push(format!("Tag: {}", pdf_safe_str(t)));
1836 }
1837 if let Some(ref a) = run.git_commit_author {
1838 parts.push(format!("Author: {}", pdf_safe_str(a)));
1839 }
1840 if let Some(ref d) = run.git_commit_date {
1841 parts.push(format!("Commit Date: {}", fmt_commit_date_pt(d)));
1842 }
1843 parts
1844}
1845
1846#[allow(clippy::cast_precision_loss)]
1847fn pdf_info_parts_tests(tot: &SummaryTotals) -> Vec<String> {
1848 let mut tc: Vec<String> = Vec::new();
1849 if tot.test_count > 0 {
1850 tc.push(format!("Tests: {}", pdf_fmt_full(tot.test_count)));
1851 }
1852 if tot.test_assertion_count > 0 {
1853 tc.push(format!(
1854 "Assertions: {}",
1855 pdf_fmt_full(tot.test_assertion_count)
1856 ));
1857 }
1858 if tot.test_suite_count > 0 {
1859 tc.push(format!("Suites: {}", pdf_fmt_full(tot.test_suite_count)));
1860 }
1861 if tot.coverage_lines_found > 0 {
1862 tc.push(format!(
1863 "Line Cov: {:.1}% ({}/{})",
1864 tot.coverage_lines_hit as f64 / tot.coverage_lines_found as f64 * 100.0,
1865 pdf_fmt_full(tot.coverage_lines_hit),
1866 pdf_fmt_full(tot.coverage_lines_found)
1867 ));
1868 }
1869 if tot.coverage_functions_found > 0 {
1870 tc.push(format!(
1871 "Func Cov: {:.1}%",
1872 tot.coverage_functions_hit as f64 / tot.coverage_functions_found as f64 * 100.0
1873 ));
1874 }
1875 if tot.coverage_branches_found > 0 {
1876 tc.push(format!(
1877 "Branch Cov: {:.1}%",
1878 tot.coverage_branches_hit as f64 / tot.coverage_branches_found as f64 * 100.0
1879 ));
1880 }
1881 tc
1882}
1883
1884#[allow(clippy::many_single_char_names)]
1892fn pdf_info_emit_line(
1893 ctx: &PdfCtx<'_>,
1894 mut y: f32,
1895 r: f32,
1896 g: f32,
1897 b: f32,
1898 parts: &[String],
1899) -> f32 {
1900 use crate::pdf_compat::{Color, Mm, Rgb};
1901 const SIZE: f32 = 7.0;
1902 const LINE_GAP: f32 = 6.2;
1903 const SEP: &str = " | ";
1904 if parts.is_empty() {
1905 return y;
1906 }
1907 let usable = ctx.margin.mul_add(-2.0, ctx.w);
1908 let sep_w = helvetica_width_mm(SEP, SIZE, false);
1909 let group = Color::Rgb(Rgb::new(r, g, b, None));
1910 let sep_color = Color::Rgb(Rgb::new(0.66, 0.63, 0.60, None));
1911 let mut x = ctx.margin;
1912 let mut first_on_line = true;
1913 for part in parts {
1914 let (key, val) = match part.split_once(": ") {
1916 Some((k, v)) => (format!("{k}: "), v.to_string()),
1917 None => (part.clone(), String::new()),
1918 };
1919 let key_w = helvetica_width_mm(&key, SIZE, true);
1920 let val_w = helvetica_width_mm(&val, SIZE, false);
1921 let advance = if first_on_line {
1922 key_w + val_w
1923 } else {
1924 sep_w + key_w + val_w
1925 };
1926 if !first_on_line && x + advance > ctx.margin + usable {
1927 y -= LINE_GAP;
1928 x = ctx.margin;
1929 first_on_line = true;
1930 }
1931 if !first_on_line {
1932 ctx.layer.set_fill_color(sep_color.clone());
1933 ctx.layer.use_text(SEP, SIZE, Mm(x), Mm(y), ctx.font_reg);
1934 x += sep_w;
1935 }
1936 ctx.layer.set_fill_color(group.clone());
1937 ctx.layer
1938 .use_text(key.as_str(), SIZE, Mm(x), Mm(y), ctx.font_bold);
1939 x += key_w;
1940 if !val.is_empty() {
1941 ctx.layer
1942 .use_text(val.as_str(), SIZE, Mm(x), Mm(y), ctx.font_reg);
1943 x += val_w;
1944 }
1945 first_on_line = false;
1946 }
1947 y - LINE_GAP
1948}
1949
1950fn pdf_render_info_lines(ctx: &PdfCtx<'_>, run: &AnalysisRun, row2_bot: f32) -> f32 {
1951 let tot = &run.summary_totals;
1952 let mut y = row2_bot - 6.5;
1953 let stats = pdf_info_parts_stats(tot);
1954 y = pdf_info_emit_line(ctx, y, 0.15, 0.15, 0.15, &stats);
1955 let git = pdf_info_parts_git(run);
1957 if !git.is_empty() {
1958 y -= 1.6;
1959 y = pdf_info_emit_line(ctx, y, 0.10, 0.35, 0.15, &git);
1960 }
1961 let tests = pdf_info_parts_tests(tot);
1962 if !tests.is_empty() {
1963 y -= 1.6;
1964 y = pdf_info_emit_line(ctx, y, 0.15, 0.15, 0.50, &tests);
1965 }
1966 y
1967}
1968
1969#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
1970fn pdf_table_render_section(
1971 ctx: &PdfCtx<'_>,
1972 x: f32,
1973 top: f32,
1974 w: f32,
1975 lbl_frac: f32,
1976 title: &str,
1977 rows: &[(&str, String)],
1978) {
1979 use crate::pdf_compat::{Color, Mm, Rgb};
1980 pdf_fill_rect(
1981 ctx.layer,
1982 x,
1983 top - ctx.tbl_hdr_h,
1984 w,
1985 ctx.tbl_hdr_h,
1986 Rgb::new(0.098, 0.11, 0.15, None),
1987 );
1988 ctx.layer
1989 .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
1990 ctx.layer.use_text(
1991 title,
1992 7.0,
1993 Mm(x + 2.0),
1994 Mm(top - ctx.tbl_hdr_h + 1.5),
1995 ctx.font_bold,
1996 );
1997 let y = top - ctx.tbl_hdr_h;
1998 for (ri, (lbl, val)) in rows.iter().enumerate() {
1999 let ry = ((ri + 1) as f32).mul_add(-ctx.row_h, y);
2000 let bg = if ri % 2 == 0 {
2001 Rgb::new(0.975, 0.965, 0.95, None)
2002 } else {
2003 Rgb::new(1.0, 1.0, 1.0, None)
2004 };
2005 pdf_fill_rect(ctx.layer, x, ry, w, ctx.row_h, bg);
2006 ctx.layer
2007 .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
2008 ctx.layer
2009 .use_text(*lbl, 6.5, Mm(x + 2.0), Mm(ry + 1.5), ctx.font_reg);
2010 let is_dash = val == "--";
2011 let val_rgb = if is_dash {
2012 Rgb::new(0.55, 0.55, 0.55, None)
2013 } else {
2014 Rgb::new(0.12, 0.12, 0.12, None)
2015 };
2016 let val_font = if is_dash { ctx.font_reg } else { ctx.font_bold };
2017 ctx.layer.set_fill_color(Color::Rgb(val_rgb));
2018 ctx.layer.use_text(
2019 val.as_str(),
2020 6.5,
2021 Mm(x + w * lbl_frac + 2.0),
2022 Mm(ry + 1.5),
2023 val_font,
2024 );
2025 }
2026}
2027
2028#[allow(
2029 clippy::cast_precision_loss,
2030 clippy::suboptimal_flops,
2031 clippy::similar_names
2032)]
2033fn pdf_render_metric_tables(ctx: &PdfCtx<'_>, run: &AnalysisRun, tbl_top: f32) {
2034 let tot = &run.summary_totals;
2035 let half_w = (2.0f32.mul_add(-ctx.margin, ctx.w) - 4.0) / 2.0;
2036 let left_x = ctx.margin;
2037 let right_x = ctx.margin + half_w + 4.0;
2038 let lbl_frac: f32 = 0.68;
2039
2040 let files_rows: [(&str, String); 4] = [
2041 ("Files analyzed", pdf_fmt_full(tot.files_analyzed)),
2042 ("Files skipped", pdf_fmt_full(tot.files_skipped)),
2043 ("Files modified", "--".to_string()),
2044 ("Files unchanged", "--".to_string()),
2045 ];
2046 pdf_table_render_section(ctx, left_x, tbl_top, half_w, lbl_frac, "FILES", &files_rows);
2047
2048 let lc_rows: [(&str, String); 5] = [
2049 ("Physical lines", pdf_fmt_full(tot.total_physical_lines)),
2050 ("Code lines", pdf_fmt_full(tot.code_lines)),
2051 ("Comment lines", pdf_fmt_full(tot.comment_lines)),
2052 ("Blank lines", pdf_fmt_full(tot.blank_lines)),
2053 ("Mixed (separate)", pdf_fmt_full(tot.mixed_lines_separate)),
2054 ];
2055 let lc_top = tbl_top - ctx.tbl_hdr_h - (files_rows.len() as f32).mul_add(ctx.row_h, 3.0);
2056 pdf_table_render_section(
2057 ctx,
2058 left_x,
2059 lc_top,
2060 half_w,
2061 lbl_frac,
2062 "LINE COUNTS",
2063 &lc_rows,
2064 );
2065
2066 let cs_rows: [(&str, String); 4] = [
2067 ("Functions", pdf_fmt_full(tot.functions)),
2068 ("Classes / Types", pdf_fmt_full(tot.classes)),
2069 ("Variables", pdf_fmt_full(tot.variables)),
2070 ("Imports", pdf_fmt_full(tot.imports)),
2071 ];
2072 pdf_table_render_section(
2073 ctx,
2074 right_x,
2075 tbl_top,
2076 half_w,
2077 lbl_frac,
2078 "CODE STRUCTURE",
2079 &cs_rows,
2080 );
2081
2082 let lcs_rows: [(&str, String); 4] = [
2083 ("Lines added", "--".to_string()),
2084 ("Lines removed", "--".to_string()),
2085 ("Lines modified (net)", "--".to_string()),
2086 ("Lines unmodified", "--".to_string()),
2087 ];
2088 let lcs_top = tbl_top - ctx.tbl_hdr_h - (cs_rows.len() as f32).mul_add(ctx.row_h, 3.0);
2089 pdf_table_render_section(
2090 ctx,
2091 right_x,
2092 lcs_top,
2093 half_w,
2094 lbl_frac,
2095 "LINE CHANGE SUMMARY",
2096 &lcs_rows,
2097 );
2098}
2099
2100fn pdf_tc_title_bar(ctx: &PdfCtx<'_>, label: &str, y: f32) -> f32 {
2103 use crate::pdf_compat::{Color, Mm, Rgb};
2104 let tbl_w = 2.0_f32.mul_add(-ctx.margin, ctx.w);
2105 pdf_fill_rect(
2106 ctx.layer,
2107 ctx.margin,
2108 y - ctx.tbl_hdr_h,
2109 tbl_w,
2110 ctx.tbl_hdr_h,
2111 Rgb::new(0.098, 0.11, 0.15, None),
2112 );
2113 ctx.layer
2114 .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
2115 ctx.layer.use_text(
2116 label,
2117 7.0,
2118 Mm(ctx.margin + 2.0),
2119 Mm(y - ctx.tbl_hdr_h + 1.5),
2120 ctx.font_bold,
2121 );
2122 y - ctx.tbl_hdr_h
2123}
2124
2125fn pdf_row_bg(ri: usize) -> crate::pdf_compat::Rgb {
2127 use crate::pdf_compat::Rgb;
2128 if ri.is_multiple_of(2) {
2129 Rgb::new(0.975, 0.965, 0.95, None)
2130 } else {
2131 Rgb::new(1.0, 1.0, 1.0, None)
2132 }
2133}
2134
2135fn pdf_sub_sum(
2137 sub: &sloc_core::SubmoduleSummary,
2138 f: impl Fn(&sloc_core::LanguageSummary) -> u64,
2139) -> u64 {
2140 sub.language_summaries.iter().map(f).sum()
2141}
2142
2143#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
2145fn pdf_tc_stat_boxes(ctx: &PdfCtx<'_>, run: &AnalysisRun, has_cov: bool, mut y: f32) -> f32 {
2146 use crate::pdf_compat::{Color, Mm, Rgb};
2147 let gap: f32 = 4.0;
2148 let box_h: f32 = 15.0;
2149 let box_w = (ctx.w - 2.0 * ctx.margin - 3.0 * gap) / 4.0;
2150 let line_cov_str = if has_cov {
2151 let pct = run.summary_totals.coverage_lines_hit as f64
2152 / run.summary_totals.coverage_lines_found as f64
2153 * 100.0;
2154 format!("{pct:.1}%")
2155 } else {
2156 "\u{2014}".to_string()
2157 };
2158 let box_vals: [String; 4] = [
2159 pdf_fmt_full(run.summary_totals.test_count),
2160 pdf_fmt_full(run.summary_totals.test_assertion_count),
2161 pdf_fmt_full(run.summary_totals.test_suite_count),
2162 line_cov_str,
2163 ];
2164 let box_labels: [&str; 4] = [
2165 "Test Functions",
2166 "Test Assertions",
2167 "Test Suites",
2168 "Line Coverage",
2169 ];
2170 for (i, (label, val)) in box_labels.iter().zip(box_vals.iter()).enumerate() {
2171 let bx = ctx.margin + i as f32 * (box_w + gap);
2172 let by = y - box_h;
2173 pdf_fill_rect(
2174 ctx.layer,
2175 bx,
2176 by,
2177 box_w,
2178 box_h,
2179 Rgb::new(0.97, 0.96, 0.94, None),
2180 );
2181 ctx.layer
2182 .set_fill_color(Color::Rgb(Rgb::new(0.60, 0.40, 0.22, None)));
2183 ctx.layer
2184 .use_text(val.as_str(), 9.5, Mm(bx + 3.0), Mm(by + 7.5), ctx.font_bold);
2185 ctx.layer
2186 .set_fill_color(Color::Rgb(Rgb::new(0.50, 0.44, 0.40, None)));
2187 ctx.layer
2188 .use_text(*label, 5.5, Mm(bx + 3.0), Mm(by + 2.0), ctx.font_reg);
2189 }
2190 y -= box_h + 4.0;
2191 y
2192}
2193
2194#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
2196fn pdf_tc_submodules(ctx: &PdfCtx<'_>, run: &AnalysisRun, footer_h: f32, mut y: f32) -> f32 {
2197 use crate::pdf_compat::{Color, Mm, Rgb};
2198 let subs = &run.submodule_summaries;
2199 if subs.is_empty() {
2200 return y;
2201 }
2202 let margin = ctx.margin;
2203 let row_h = ctx.row_h;
2204 let tbl_w = ctx.w - 2.0 * margin;
2205
2206 let col_name = tbl_w * 0.40;
2207 let rem = tbl_w - col_name;
2208 let col_files = rem * 0.15;
2209 let col_code = rem * 0.20;
2210 let col_tests = rem * 0.20;
2211 let col_assert = rem * 0.20;
2212
2213 let cx_files = margin + col_name;
2214 let cx_code = cx_files + col_files;
2215 let cx_tests = cx_code + col_code;
2216 let cx_assert = cx_tests + col_tests;
2217 let cx_cov = cx_assert + col_assert;
2218
2219 y = pdf_tc_title_bar(ctx, "SUBMODULES", y);
2220
2221 pdf_fill_rect(
2222 ctx.layer,
2223 margin,
2224 y - row_h,
2225 tbl_w,
2226 row_h,
2227 Rgb::new(0.25, 0.27, 0.32, None),
2228 );
2229 ctx.layer
2230 .set_fill_color(Color::Rgb(Rgb::new(0.88, 0.88, 0.88, None)));
2231 for (lbl, x) in &[
2232 ("Submodule", margin + 2.0),
2233 ("Files", cx_files + 2.0),
2234 ("Code Lines", cx_code + 2.0),
2235 ("Test Functions", cx_tests + 2.0),
2236 ("Assertions", cx_assert + 2.0),
2237 ("Line Coverage %", cx_cov + 2.0),
2238 ] {
2239 ctx.layer
2240 .use_text(*lbl, 5.5, Mm(*x), Mm(y - row_h + 1.5), ctx.font_bold);
2241 }
2242 y -= row_h;
2243
2244 for (ri, sub) in subs.iter().enumerate() {
2245 if y < footer_h + row_h {
2246 break;
2247 }
2248 let sub_tests = pdf_sub_sum(sub, |l| l.test_count);
2249 let sub_assert = pdf_sub_sum(sub, |l| l.test_assertion_count);
2250 let sub_cov_hit = pdf_sub_sum(sub, |l| l.coverage_lines_hit);
2251 let sub_cov_found = pdf_sub_sum(sub, |l| l.coverage_lines_found);
2252 let sub_cov_str = if sub_cov_found > 0 {
2253 format!("{:.1}%", sub_cov_hit as f64 / sub_cov_found as f64 * 100.0)
2254 } else {
2255 "\u{2014}".to_string()
2256 };
2257
2258 let ry = y - row_h;
2259 pdf_fill_rect(ctx.layer, margin, ry, tbl_w, row_h, pdf_row_bg(ri));
2260 ctx.layer
2261 .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
2262 ctx.layer.use_text(
2263 pdf_trunc(&pdf_safe_str(&sub.name), 40),
2264 5.5,
2265 Mm(margin + 2.0),
2266 Mm(ry + 1.5),
2267 ctx.font_bold,
2268 );
2269 for (val, x) in &[
2270 (pdf_fmt_full(sub.files_analyzed), cx_files + 2.0),
2271 (pdf_fmt_full(sub.code_lines), cx_code + 2.0),
2272 (pdf_fmt_full(sub_tests), cx_tests + 2.0),
2273 (pdf_fmt_full(sub_assert), cx_assert + 2.0),
2274 (sub_cov_str, cx_cov + 2.0),
2275 ] {
2276 ctx.layer
2277 .use_text(val.as_str(), 5.5, Mm(*x), Mm(ry + 1.5), ctx.font_reg);
2278 }
2279 y -= row_h;
2280 }
2281 y - 3.0
2282}
2283
2284#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
2286fn pdf_tc_gauges(ctx: &PdfCtx<'_>, run: &AnalysisRun, mut y: f32) -> f32 {
2287 use crate::pdf_compat::{Color, Mm, Rgb};
2288 let margin = ctx.margin;
2289 let gap: f32 = 4.0;
2290 let gauges: &[(&str, u64, u64)] = &[
2291 (
2292 "Line Coverage",
2293 run.summary_totals.coverage_lines_hit,
2294 run.summary_totals.coverage_lines_found,
2295 ),
2296 (
2297 "Function Coverage",
2298 run.summary_totals.coverage_functions_hit,
2299 run.summary_totals.coverage_functions_found,
2300 ),
2301 (
2302 "Branch Coverage",
2303 run.summary_totals.coverage_branches_hit,
2304 run.summary_totals.coverage_branches_found,
2305 ),
2306 ];
2307 let visible: Vec<_> = gauges.iter().filter(|(_, _, found)| *found > 0).collect();
2308 if visible.is_empty() {
2309 return y;
2310 }
2311 let count = visible.len() as f32;
2312 let gauge_h: f32 = 16.0;
2313 let pad: f32 = 4.0;
2314 let bar_h: f32 = 3.0;
2315 let gauge_w = (ctx.w - 2.0 * margin - (count - 1.0) * gap) / count;
2316 let bar_w = gauge_w - 2.0 * pad;
2317 for (gi, (label, hit, found)) in visible.iter().enumerate() {
2318 let gx = margin + gi as f32 * (gauge_w + gap);
2319 let pct = *hit as f64 / *found as f64 * 100.0;
2320 let pct_str = format!("{pct:.1}%");
2321 #[allow(
2324 clippy::cast_possible_truncation,
2325 reason = "0..=100 percentage to f32 bar width"
2326 )]
2327 let bar_fill = bar_w * (pct as f32 / 100.0);
2328 let gy = y - gauge_h;
2329 pdf_fill_rect(
2331 ctx.layer,
2332 gx - 0.5,
2333 gy - 0.5,
2334 gauge_w + 1.0,
2335 gauge_h + 1.0,
2336 Rgb::new(0.80, 0.75, 0.68, None),
2337 );
2338 pdf_fill_rect(
2339 ctx.layer,
2340 gx,
2341 gy,
2342 gauge_w,
2343 gauge_h,
2344 Rgb::new(0.98, 0.97, 0.95, None),
2345 );
2346 ctx.layer
2348 .set_fill_color(Color::Rgb(Rgb::new(0.15, 0.15, 0.15, None)));
2349 ctx.layer.use_text(
2350 *label,
2351 6.0,
2352 Mm(gx + pad),
2353 Mm(gy + gauge_h - 4.5),
2354 ctx.font_bold,
2355 );
2356 ctx.layer
2357 .set_fill_color(Color::Rgb(Rgb::new(0.20, 0.55, 0.35, None)));
2358 ctx.layer.use_text(
2359 &pct_str,
2360 8.5,
2361 Mm(gx + pad),
2362 Mm(gy + bar_h + 3.0),
2363 ctx.font_bold,
2364 );
2365 pdf_fill_rect(
2366 ctx.layer,
2367 gx + pad,
2368 gy + pad * 0.5,
2369 bar_w,
2370 bar_h,
2371 Rgb::new(0.86, 0.84, 0.80, None),
2372 );
2373 if bar_fill > 0.0 {
2374 pdf_fill_rect(
2375 ctx.layer,
2376 gx + pad,
2377 gy + pad * 0.5,
2378 bar_fill,
2379 bar_h,
2380 Rgb::new(0.20, 0.55, 0.35, None),
2381 );
2382 }
2383 }
2384 y -= gauge_h + 5.0;
2385 y
2386}
2387
2388struct CovCols {
2390 has_fn_cov: bool,
2391 has_br_cov: bool,
2392 col_fn_w: f32,
2393 hdr_x2: f32,
2394}
2395
2396fn pdf_tc_per_file_header(
2398 ctx: &PdfCtx<'_>,
2399 has_fn_cov: bool,
2400 has_br_cov: bool,
2401 col_fn_w: f32,
2402 y: f32,
2403) -> (f32, CovCols) {
2404 use crate::pdf_compat::{Color, Mm, Rgb};
2405 let margin = ctx.margin;
2406 let col_br_w: f32 = if has_br_cov { 22.0 } else { 0.0 };
2407 let col_file_w = 2.0_f32.mul_add(-margin, ctx.w) - 22.0 - col_fn_w - col_br_w;
2408 let hdr_x2 = margin + col_file_w;
2409
2410 let y = pdf_tc_title_bar(ctx, "PER-FILE COVERAGE", y - 3.0);
2411 ctx.layer
2412 .set_fill_color(Color::Rgb(Rgb::new(0.55, 0.55, 0.55, None)));
2413 ctx.layer
2414 .use_text("Line%", 5.5, Mm(hdr_x2 + 2.0), Mm(y - 3.5), ctx.font_bold);
2415 if has_fn_cov {
2416 ctx.layer
2417 .use_text("Fn%", 5.5, Mm(hdr_x2 + 24.0), Mm(y - 3.5), ctx.font_bold);
2418 }
2419 if has_br_cov {
2420 ctx.layer.use_text(
2421 "Br%",
2422 5.5,
2423 Mm(hdr_x2 + 22.0 + col_fn_w + 2.0),
2424 Mm(y - 3.5),
2425 ctx.font_bold,
2426 );
2427 }
2428 (
2429 y - ctx.row_h,
2430 CovCols {
2431 has_fn_cov,
2432 has_br_cov,
2433 col_fn_w,
2434 hdr_x2,
2435 },
2436 )
2437}
2438
2439fn pdf_tc_per_file_row(ctx: &PdfCtx<'_>, file: &FileRecord, ri: usize, cols: &CovCols, ry: f32) {
2441 use crate::pdf_compat::{Color, Mm, Rgb};
2442 let (has_fn_cov, has_br_cov, col_fn_w, hdr_x2) =
2443 (cols.has_fn_cov, cols.has_br_cov, cols.col_fn_w, cols.hdr_x2);
2444 let Some(cov) = file.coverage.as_ref() else {
2445 return;
2446 };
2447 pdf_fill_rect(
2448 ctx.layer,
2449 ctx.margin,
2450 ry,
2451 2.0_f32.mul_add(-ctx.margin, ctx.w),
2452 ctx.row_h,
2453 pdf_row_bg(ri),
2454 );
2455 ctx.layer
2456 .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
2457 let fname = pdf_trunc(
2458 &pdf_safe_str(
2459 std::path::Path::new(&file.relative_path)
2460 .file_name()
2461 .and_then(|n| n.to_str())
2462 .unwrap_or(&file.relative_path),
2463 ),
2464 52,
2465 );
2466 ctx.layer.use_text(
2467 &fname,
2468 5.5,
2469 Mm(ctx.margin + 2.0),
2470 Mm(ry + 1.5),
2471 ctx.font_reg,
2472 );
2473 ctx.layer
2474 .set_fill_color(Color::Rgb(Rgb::new(0.10, 0.42, 0.25, None)));
2475 ctx.layer.use_text(
2476 format!("{:.1}%", cov.line_pct()),
2477 5.5,
2478 Mm(hdr_x2 + 2.0),
2479 Mm(ry + 1.5),
2480 ctx.font_bold,
2481 );
2482 if has_fn_cov && cov.functions_found > 0 {
2483 ctx.layer.use_text(
2484 format!("{:.1}%", cov.function_pct()),
2485 5.5,
2486 Mm(hdr_x2 + 24.0),
2487 Mm(ry + 1.5),
2488 ctx.font_bold,
2489 );
2490 }
2491 if has_br_cov && cov.branches_found > 0 {
2492 ctx.layer.use_text(
2493 format!("{:.1}%", cov.branch_pct()),
2494 5.5,
2495 Mm(hdr_x2 + 22.0 + col_fn_w + 2.0),
2496 Mm(ry + 1.5),
2497 ctx.font_bold,
2498 );
2499 }
2500}
2501
2502fn pdf_tc_per_file(
2504 ctx: &PdfCtx<'_>,
2505 run: &AnalysisRun,
2506 footer_h: f32,
2507 has_fn_cov: bool,
2508 has_br_cov: bool,
2509 mut y: f32,
2510) -> f32 {
2511 let cov_files: Vec<_> = run
2512 .per_file_records
2513 .iter()
2514 .filter(|r| r.coverage.is_some())
2515 .collect();
2516 if cov_files.is_empty() {
2517 return y;
2518 }
2519 let col_fn_w: f32 = if has_fn_cov { 22.0 } else { 0.0 };
2520 let (rows_start, cols) = pdf_tc_per_file_header(ctx, has_fn_cov, has_br_cov, col_fn_w, y);
2521 y = rows_start;
2522 for (ri, file) in cov_files.iter().enumerate() {
2523 if y < footer_h + ctx.row_h {
2524 break;
2525 }
2526 let ry = y - ctx.row_h;
2527 pdf_tc_per_file_row(ctx, file, ri, &cols, ry);
2528 y -= ctx.row_h;
2529 }
2530 y
2531}
2532
2533fn pdf_tc_no_coverage_note(ctx: &PdfCtx<'_>, mut y: f32) -> f32 {
2535 use crate::pdf_compat::{Color, Mm, Rgb};
2536 let margin = ctx.margin;
2537 let note_h: f32 = 12.0;
2538 pdf_fill_rect(
2539 ctx.layer,
2540 margin,
2541 y - note_h,
2542 2.0_f32.mul_add(-margin, ctx.w),
2543 note_h,
2544 Rgb::new(0.96, 0.95, 0.93, None),
2545 );
2546 ctx.layer
2547 .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.40, 0.37, None)));
2548 ctx.layer.use_text(
2549 "No code coverage data detected.",
2550 7.0,
2551 Mm(margin + 4.0),
2552 Mm(y - note_h + 7.0),
2553 ctx.font_bold,
2554 );
2555 ctx.layer.use_text(
2556 "Re-run with --lcov-path <file.info> to see per-file line, function, and branch coverage.",
2557 6.0,
2558 Mm(margin + 4.0),
2559 Mm(y - note_h + 2.5),
2560 ctx.font_reg,
2561 );
2562 y -= note_h;
2563 y
2564}
2565
2566fn pdf_render_tc_inline(ctx: &PdfCtx<'_>, run: &AnalysisRun, y_start: f32, footer_h: f32) -> f32 {
2569 let has_cov = run.summary_totals.coverage_lines_found > 0;
2570 let has_fn_cov = run.summary_totals.coverage_functions_found > 0;
2571 let has_br_cov = run.summary_totals.coverage_branches_found > 0;
2572
2573 let mut y = pdf_tc_title_bar(ctx, "TESTS & COVERAGE", y_start) - 4.0;
2574 y = pdf_tc_stat_boxes(ctx, run, has_cov, y);
2575 y = pdf_tc_submodules(ctx, run, footer_h, y);
2576
2577 if has_cov {
2578 y = pdf_tc_gauges(ctx, run, y);
2579 y = pdf_tc_per_file(ctx, run, footer_h, has_fn_cov, has_br_cov, y);
2580 } else {
2581 y = pdf_tc_no_coverage_note(ctx, y);
2582 }
2583 y
2584}
2585
2586fn pdf_page_header_meta(run: &AnalysisRun) -> String {
2589 let mut parts = vec![format!(
2590 "Run ID: {}",
2591 pdf_safe_str(&run.tool.run_id[..run.tool.run_id.len().min(20)])
2592 )];
2593 if let Some(ref c) = run.git_commit_short {
2594 parts.push(format!("Commit: {}", pdf_safe_str(c)));
2595 }
2596 parts.push(to_pt_hhmm(run.tool.timestamp_utc));
2597 parts.join(" \u{00B7} ")
2598}
2599
2600fn pdf_draw_header_meta(
2604 layer: &crate::pdf_compat::PdfLayerReference,
2605 font: crate::pdf_compat::IndirectFontRef,
2606 w: f32,
2607 margin: f32,
2608 baseline_y: f32,
2609 text: &str,
2610) {
2611 use crate::pdf_compat::{Color, Mm, Rgb};
2612 let tw = helvetica_width_mm(text, 6.5, false);
2613 let x = (w - margin - tw).max(margin + 60.0);
2614 layer.set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
2615 layer.use_text(text, 6.5, Mm(x), Mm(baseline_y), font);
2616}
2617
2618fn pdf_page_mini_header(ctx: &PdfCtx<'_>, h: f32, hdr_h: f32, title: &str, run: &AnalysisRun) {
2622 use crate::pdf_compat::{Color, Mm, Rgb};
2623 pdf_fill_rect(
2624 ctx.layer,
2625 0.0,
2626 h - hdr_h,
2627 ctx.w,
2628 hdr_h,
2629 Rgb::new(0.098, 0.11, 0.15, None),
2630 );
2631 ctx.layer
2632 .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
2633 ctx.layer.use_text(
2634 "oxide-sloc",
2635 9.0,
2636 Mm(ctx.margin),
2637 Mm(h - 5.5),
2638 ctx.font_bold,
2639 );
2640 ctx.layer
2641 .set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
2642 ctx.layer.use_text(
2643 pdf_trunc(&pdf_safe_str(title), 45),
2644 7.5,
2645 Mm(46.0),
2646 Mm(h - 5.5),
2647 ctx.font_reg,
2648 );
2649 pdf_draw_header_meta(
2650 ctx.layer,
2651 ctx.font_reg,
2652 ctx.w,
2653 ctx.margin,
2654 h - 5.5,
2655 &pdf_page_header_meta(run),
2656 );
2657}
2658
2659fn pdf_page_footer_band(ctx: &PdfCtx<'_>, footer_h: f32, version: &str) {
2662 use crate::pdf_compat::{Color, Mm, Rgb};
2663 pdf_fill_rect(
2664 ctx.layer,
2665 0.0,
2666 0.0,
2667 ctx.w,
2668 footer_h,
2669 Rgb::new(0.93, 0.91, 0.87, None),
2670 );
2671 ctx.layer
2672 .set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)));
2673 ctx.layer.use_text(
2674 format!("oxide-sloc v{version} \u{00b7} AGPL-3.0-or-later"),
2675 6.5,
2676 Mm(ctx.margin),
2677 Mm(3.0),
2678 ctx.font_reg,
2679 );
2680}
2681
2682#[allow(clippy::cast_precision_loss, clippy::too_many_arguments)]
2685fn pdf_render_tests_coverage_page(
2686 doc: &crate::pdf_compat::PdfDocumentReference,
2687 font_reg: crate::pdf_compat::IndirectFontRef,
2688 font_bold: crate::pdf_compat::IndirectFontRef,
2689 run: &AnalysisRun,
2690 w: f32,
2691 h: f32,
2692 margin: f32,
2693 footer_h: f32,
2694 title: &str,
2695 version: &str,
2696) -> (
2697 crate::pdf_compat::PdfPageIndex,
2698 crate::pdf_compat::PdfLayerIndex,
2699 f32,
2700) {
2701 use crate::pdf_compat::Mm;
2702 const HDR_H: f32 = 8.0;
2703
2704 let (tc_page, tc_layer_idx) = doc.add_page(Mm(w), Mm(h), "Tests & Coverage");
2705 let layer = doc.get_page(tc_page).get_layer(tc_layer_idx);
2706 let ctx = PdfCtx {
2707 layer: &layer,
2708 font_reg,
2709 font_bold,
2710 w,
2711 margin,
2712 row_h: 5.5,
2713 tbl_hdr_h: 6.0,
2714 };
2715
2716 pdf_page_mini_header(&ctx, h, HDR_H, title, run);
2717
2718 let tc_bottom = pdf_render_tc_inline(&ctx, run, h - HDR_H - 4.0, footer_h);
2720
2721 pdf_page_footer_band(&ctx, footer_h, version);
2722
2723 (tc_page, tc_layer_idx, tc_bottom - 3.0)
2724}
2725
2726#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
2727fn pdf_render_page1_footer(
2728 ctx: &PdfCtx<'_>,
2729 run: &AnalysisRun,
2730 footer_h: f32,
2731 version: &str,
2732 banner: Option<&str>,
2733) {
2734 use crate::pdf_compat::{Color, Mm, Rgb};
2735 pdf_fill_rect(
2736 ctx.layer,
2737 0.0,
2738 0.0,
2739 ctx.w,
2740 footer_h,
2741 Rgb::new(0.93, 0.91, 0.87, None),
2742 );
2743 ctx.layer
2744 .set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)));
2745 ctx.layer.use_text(
2747 format!("oxide-sloc v{version} | AGPL-3.0-or-later"),
2748 6.5,
2749 Mm(ctx.margin),
2750 Mm(3.0),
2751 ctx.font_reg,
2752 );
2753 let right_text = format!(
2755 "github.com/oxide-sloc/oxide-sloc | Run ID: {}",
2756 pdf_safe_str(&run.tool.run_id[..run.tool.run_id.len().min(20)])
2757 );
2758 let right_x = (ctx.w - ctx.margin - right_text.len() as f32 * 1.27).max(ctx.margin + 80.0);
2759 ctx.layer
2760 .use_text(right_text, 6.5, Mm(right_x), Mm(3.0), ctx.font_reg);
2761 if let Some(text) = banner {
2763 let safe = pdf_trunc(&pdf_safe_str(text), 40);
2764 let text_x = (ctx.w / 2.0 - safe.len() as f32 * 0.97).max(ctx.margin + 50.0);
2766 ctx.layer
2767 .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
2768 ctx.layer
2769 .use_text(safe, 9.0, Mm(text_x), Mm(2.6), ctx.font_bold);
2770 }
2771}
2772
2773fn per_file_row_bg(ri: usize) -> crate::pdf_compat::Rgb {
2774 if ri.is_multiple_of(2) {
2775 crate::pdf_compat::Rgb::new(0.975, 0.965, 0.95, None)
2776 } else {
2777 crate::pdf_compat::Rgb::new(1.0, 1.0, 1.0, None)
2778 }
2779}
2780
2781const PDF_PERFILE_HDR_H: f32 = 8.0;
2783const PDF_PERFILE_SUB_H: f32 = 5.5;
2784const PDF_PERFILE_TABLE_GAP: f32 = 3.0;
2787
2788struct PdfPerFileCtx<'a> {
2791 doc: &'a crate::pdf_compat::PdfDocumentReference,
2792 font_reg: crate::pdf_compat::IndirectFontRef,
2793 font_bold: crate::pdf_compat::IndirectFontRef,
2794 w: f32,
2795 h: f32,
2796 margin: f32,
2797}
2798
2799fn pdf_perfile_page_slice(
2801 page_idx: usize,
2802 use_continuation: bool,
2803 has_first_page: bool,
2804 fp_rows: usize,
2805 rows_per_page: usize,
2806 total_files: usize,
2807) -> (usize, usize) {
2808 if use_continuation {
2809 (0, fp_rows.min(total_files))
2810 } else if has_first_page {
2811 let s = fp_rows + (page_idx - 1) * rows_per_page;
2812 (s, (s + rows_per_page).min(total_files))
2813 } else {
2814 let s = page_idx * rows_per_page;
2815 (s, (s + rows_per_page).min(total_files))
2816 }
2817}
2818
2819#[allow(clippy::suboptimal_flops, clippy::cast_precision_loss)]
2825fn pdf_draw_perfile_header(
2826 ctx: &PdfPerFileCtx<'_>,
2827 use_continuation: bool,
2828 first_page: Option<(
2829 crate::pdf_compat::PdfPageIndex,
2830 crate::pdf_compat::PdfLayerIndex,
2831 f32,
2832 )>,
2833 page_idx: usize,
2834 page_count: usize,
2835 banner: Option<&str>,
2836 meta: &str,
2837) -> (crate::pdf_compat::PdfLayerReference, f32) {
2838 use crate::pdf_compat::{Color, Mm, Rgb};
2839 if use_continuation {
2840 let (fp_page, fp_layer_idx, fp_top) = first_page.unwrap();
2841 let layer = ctx.doc.get_page(fp_page).get_layer(fp_layer_idx);
2842 (layer, fp_top - PDF_PERFILE_SUB_H)
2843 } else {
2844 let (pf_page, pf_layer_idx) = ctx.doc.add_page(Mm(ctx.w), Mm(ctx.h), "Content");
2845 let layer = ctx.doc.get_page(pf_page).get_layer(pf_layer_idx);
2846 let hdr_top = ctx.h - PDF_PERFILE_HDR_H;
2847 pdf_fill_rect(
2848 &layer,
2849 0.0,
2850 hdr_top,
2851 ctx.w,
2852 PDF_PERFILE_HDR_H,
2853 Rgb::new(0.098, 0.11, 0.15, None),
2854 );
2855 layer.set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
2856 layer.use_text(
2857 "oxide-sloc",
2858 9.0,
2859 Mm(ctx.margin),
2860 Mm(hdr_top + 2.5),
2861 ctx.font_bold,
2862 );
2863 layer.set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
2864 layer.use_text(
2865 "Per-File Detail",
2866 8.0,
2867 Mm(46.0),
2868 Mm(hdr_top + 2.5),
2869 ctx.font_reg,
2870 );
2871 let right = format!(
2873 "{meta} \u{00B7} Page {} of {}",
2874 page_idx + 2,
2875 page_count + 1
2876 );
2877 let right_w = helvetica_width_mm(&right, 6.5, false);
2878 let right_x = (ctx.w - ctx.margin - right_w).max(ctx.margin + 60.0);
2879 layer.use_text(right, 6.5, Mm(right_x), Mm(hdr_top + 2.5), ctx.font_reg);
2880 if let Some(text) = banner {
2881 let safe = pdf_trunc(&pdf_safe_str(text), 40);
2882 let text_x = (ctx.w / 2.0 - safe.len() as f32 * 0.97).max(80.0);
2883 layer.set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
2884 layer.use_text(safe, 9.0, Mm(text_x), Mm(hdr_top + 2.5), ctx.font_bold);
2885 }
2886 (layer, hdr_top - PDF_PERFILE_TABLE_GAP - PDF_PERFILE_SUB_H)
2888 }
2889}
2890
2891#[allow(clippy::suboptimal_flops, clippy::cast_precision_loss)]
2893fn pdf_draw_perfile_rows(
2894 ctx: &PdfCtx<'_>,
2895 records: &[FileRecord],
2896 col_x: &[f32; 13],
2897 pf_tbl_top: f32,
2898) {
2899 use crate::pdf_compat::{Color, Mm, Rgb};
2900 for (ri, rec) in records.iter().enumerate() {
2901 let ry = ((ri + 1) as f32).mul_add(-ctx.row_h, pf_tbl_top - ctx.tbl_hdr_h);
2902 let bg = per_file_row_bg(ri);
2903 pdf_fill_rect(
2904 ctx.layer,
2905 ctx.margin,
2906 ry,
2907 2.0f32.mul_add(-ctx.margin, ctx.w),
2908 ctx.row_h,
2909 bg,
2910 );
2911 ctx.layer
2912 .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
2913 let file_str = pdf_safe_str(&rec.relative_path);
2914 let lang_str = rec
2915 .language
2916 .as_ref()
2917 .map_or_else(|| "--".to_string(), |l| l.display_name().to_string());
2918 let raw = &rec.raw_line_categories;
2919 let eff = &rec.effective_counts;
2920 let cells = [
2921 pdf_trunc_end(&file_str, 110),
2922 lang_str,
2923 pdf_fmt_full(raw.total_physical_lines),
2924 pdf_fmt_full(eff.code_lines),
2925 pdf_fmt_full(eff.comment_lines),
2926 pdf_fmt_full(eff.blank_lines),
2927 pdf_fmt_full(eff.mixed_lines_separate),
2928 pdf_fmt_full(raw.functions),
2929 pdf_fmt_full(raw.classes),
2930 pdf_fmt_full(raw.variables),
2931 pdf_fmt_full(raw.imports),
2932 pdf_fmt_full(raw.test_count),
2933 pdf_fmt_full(raw.test_assertion_count),
2934 ];
2935 for (ci, cell) in cells.iter().enumerate() {
2936 ctx.layer.use_text(
2937 cell.clone(),
2938 5.5,
2939 Mm(col_x[ci] + 0.5),
2940 Mm(ry + 1.0),
2941 ctx.font_reg,
2942 );
2943 }
2944 }
2945}
2946
2947#[allow(
2949 clippy::cast_precision_loss,
2950 clippy::cast_possible_truncation,
2951 clippy::cast_sign_loss,
2952 clippy::too_many_arguments,
2953 clippy::too_many_lines,
2954 clippy::suboptimal_flops
2955)]
2956fn pdf_render_per_file_pages(
2957 doc: &crate::pdf_compat::PdfDocumentReference,
2958 font_reg: crate::pdf_compat::IndirectFontRef,
2959 font_bold: crate::pdf_compat::IndirectFontRef,
2960 run: &AnalysisRun,
2961 w: f32,
2962 h: f32,
2963 margin: f32,
2964 footer_h: f32,
2965 row_h: f32,
2966 tbl_hdr_h: f32,
2967 title: &str,
2968 ts: &str,
2969 version: &str,
2970 banner: Option<&str>,
2971 first_page: Option<(
2974 crate::pdf_compat::PdfPageIndex,
2975 crate::pdf_compat::PdfLayerIndex,
2976 f32,
2977 )>,
2978) {
2979 use crate::pdf_compat::{Color, Mm, Rgb};
2980 let col_x: [f32; 13] = [
2984 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,
2985 ];
2986 let col_labels: [&str; 13] = [
2987 "File",
2988 "Language",
2989 "Physical",
2990 "Code",
2991 "Comments",
2992 "Blank",
2993 "Mixed",
2994 "Functions",
2995 "Classes",
2996 "Variables",
2997 "Imports",
2998 "Tests",
2999 "Assertions",
3000 ];
3001 let rows_per_page =
3002 ((h - PDF_PERFILE_HDR_H - PDF_PERFILE_SUB_H - PDF_PERFILE_TABLE_GAP - tbl_hdr_h - footer_h)
3003 / row_h)
3004 .floor() as usize;
3005 let total_files = run.per_file_records.len();
3006
3007 let fp_rows = match first_page {
3009 Some((_, _, fp_top)) => ((fp_top - PDF_PERFILE_SUB_H - tbl_hdr_h - footer_h) / row_h)
3010 .floor()
3011 .max(0.0) as usize,
3012 None => rows_per_page,
3013 };
3014 let page_count = if first_page.is_some() {
3015 1 + total_files.saturating_sub(fp_rows).div_ceil(rows_per_page)
3016 } else {
3017 total_files.div_ceil(rows_per_page)
3018 };
3019 let pf_ctx = PdfPerFileCtx {
3020 doc,
3021 font_reg,
3022 font_bold,
3023 w,
3024 h,
3025 margin,
3026 };
3027 let header_meta = pdf_page_header_meta(run);
3028
3029 for page_idx in 0..page_count {
3030 let use_continuation = page_idx == 0 && first_page.is_some();
3031 let (pf_layer, sub_top) = pdf_draw_perfile_header(
3032 &pf_ctx,
3033 use_continuation,
3034 first_page,
3035 page_idx,
3036 page_count,
3037 banner,
3038 &header_meta,
3039 );
3040
3041 pdf_fill_rect(
3043 &pf_layer,
3044 margin,
3045 sub_top,
3046 w - 2.0 * margin,
3047 PDF_PERFILE_SUB_H,
3048 Rgb::new(0.098, 0.11, 0.15, None),
3049 );
3050 pf_layer.set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
3051 pf_layer.use_text(
3052 "PER-FILE DETAIL",
3053 7.0,
3054 Mm(margin + 2.0),
3055 Mm(sub_top + 1.5),
3056 font_bold,
3057 );
3058 if use_continuation {
3059 let right = format!(
3061 "{} | {} files | {ts}",
3062 pdf_trunc(title, 30),
3063 total_files
3064 );
3065 pf_layer.set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
3066 let right_x = (w - margin - right.len() as f32 * 1.05).max(margin + 80.0);
3067 pf_layer.use_text(right, 5.5, Mm(right_x), Mm(sub_top + 1.5), font_reg);
3068 } else {
3069 pf_layer.set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
3070 pf_layer.use_text(
3071 pdf_trunc(title, 45),
3072 5.5,
3073 Mm(margin + 60.0),
3074 Mm(sub_top + 1.5),
3075 font_reg,
3076 );
3077 pf_layer.use_text(
3078 format!("{total_files} files | {ts}"),
3079 5.5,
3080 Mm(w - margin - 55.0),
3081 Mm(sub_top + 1.5),
3082 font_reg,
3083 );
3084 }
3085
3086 let pf_tbl_top = sub_top;
3087 pdf_fill_rect(
3088 &pf_layer,
3089 margin,
3090 pf_tbl_top - tbl_hdr_h,
3091 2.0f32.mul_add(-margin, w),
3092 tbl_hdr_h,
3093 Rgb::new(0.098, 0.11, 0.15, None),
3094 );
3095 pf_layer.set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
3096 for (i, lbl) in col_labels.iter().enumerate() {
3097 pf_layer.use_text(
3098 *lbl,
3099 5.0,
3100 Mm(col_x[i] + 0.5),
3101 Mm(pf_tbl_top - tbl_hdr_h + 1.5),
3102 font_bold,
3103 );
3104 }
3105
3106 let (start, end) = pdf_perfile_page_slice(
3107 page_idx,
3108 use_continuation,
3109 first_page.is_some(),
3110 fp_rows,
3111 rows_per_page,
3112 total_files,
3113 );
3114 let row_ctx = PdfCtx {
3115 layer: &pf_layer,
3116 font_reg,
3117 font_bold,
3118 w,
3119 margin,
3120 row_h,
3121 tbl_hdr_h,
3122 };
3123 pdf_draw_perfile_rows(
3124 &row_ctx,
3125 &run.per_file_records[start..end],
3126 &col_x,
3127 pf_tbl_top,
3128 );
3129
3130 pdf_fill_rect(
3132 &pf_layer,
3133 0.0,
3134 0.0,
3135 w,
3136 footer_h,
3137 Rgb::new(0.93, 0.91, 0.87, None),
3138 );
3139 pf_layer.set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)));
3140 pf_layer.use_text(
3141 format!("oxide-sloc v{version} | AGPL-3.0-or-later"),
3142 6.5,
3143 Mm(margin),
3144 Mm(3.0),
3145 font_reg,
3146 );
3147 let right_text = format!(
3148 "github.com/oxide-sloc/oxide-sloc | Run ID: {}",
3149 pdf_safe_str(&run.tool.run_id[..run.tool.run_id.len().min(20)])
3150 );
3151 let right_x = (w - margin - right_text.len() as f32 * 1.27).max(margin + 80.0);
3152 pf_layer.use_text(right_text, 6.5, Mm(right_x), Mm(3.0), font_reg);
3153 if let Some(text) = banner {
3155 let safe = pdf_trunc(&pdf_safe_str(text), 40);
3156 let text_x = (w / 2.0 - safe.len() as f32 * 0.97).max(margin + 50.0);
3157 pf_layer.set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
3158 pf_layer.use_text(safe, 9.0, Mm(text_x), Mm(2.6), font_bold);
3159 }
3160 }
3161}
3162
3163fn pdf_section_header_bar(
3167 ctx: &PdfCtx<'_>,
3168 usable_w: f32,
3169 section_top: f32,
3170 hdr_h: f32,
3171 title: &str,
3172) {
3173 use crate::pdf_compat::{Color, Mm, Rgb};
3174 pdf_fill_rect(
3175 ctx.layer,
3176 ctx.margin,
3177 section_top - hdr_h,
3178 usable_w,
3179 hdr_h,
3180 Rgb::new(0.098, 0.11, 0.15, None),
3181 );
3182 ctx.layer
3183 .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
3184 ctx.layer.use_text(
3185 title,
3186 7.0,
3187 Mm(ctx.margin + 2.0),
3188 Mm(section_top - hdr_h + 1.5),
3189 ctx.font_bold,
3190 );
3191}
3192
3193#[allow(
3199 clippy::cast_precision_loss,
3200 clippy::cast_possible_truncation,
3201 clippy::too_many_lines,
3202 clippy::suboptimal_flops
3203)]
3204fn pdf_render_style_section(ctx: &PdfCtx<'_>, ss: &StyleSummary, section_top: f32) -> f32 {
3205 use crate::pdf_compat::{Color, Mm, Rgb};
3206 const HDR_H: f32 = 5.5;
3207 const CHIP_H: f32 = 11.0;
3208 const CHIP_GAP: f32 = 4.0;
3209 const ROW_H: f32 = 5.0;
3210 const TBL_HDR_H: f32 = 5.0;
3211 const GAP: f32 = 2.5;
3212
3213 let usable_w = ctx.w - 2.0 * ctx.margin;
3214 let chip_w = (usable_w - 3.0 * CHIP_GAP) / 4.0;
3215
3216 pdf_section_header_bar(ctx, usable_w, section_top, HDR_H, "CODE STYLE ANALYSIS");
3218 let col_label = format!("{}-Col", ss.col_threshold);
3219 ctx.layer
3220 .set_fill_color(Color::Rgb(Rgb::new(0.85, 0.65, 0.35, None)));
3221 ctx.layer.use_text(
3222 "Lexical heuristics",
3223 5.5,
3224 Mm(ctx.w - ctx.margin - 26.0),
3225 Mm(section_top - HDR_H + 1.5),
3226 ctx.font_reg,
3227 );
3228
3229 let chips_bot = section_top - HDR_H - GAP - CHIP_H;
3231 let chip_data: [(&str, String); 4] = [
3232 ("Files Analyzed", ss.files_analyzed.to_string()),
3233 ("Language Groups", ss.by_language.len().to_string()),
3234 ("Common Indent", ss.common_indent_style.clone()),
3235 (&col_label, format!("{}%", ss.line_col_compliant_pct)),
3236 ];
3237 for (i, (label, value)) in chip_data.iter().enumerate() {
3238 let cx = (i as f32).mul_add(chip_w + CHIP_GAP, ctx.margin);
3239 pdf_fill_rect(
3240 ctx.layer,
3241 cx,
3242 chips_bot,
3243 chip_w,
3244 CHIP_H,
3245 Rgb::new(0.945, 0.925, 0.90, None),
3246 );
3247 ctx.layer
3248 .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
3249 ctx.layer.use_text(
3250 pdf_trunc(value, 16),
3251 10.0,
3252 Mm(cx + 3.0),
3253 Mm(chips_bot + 5.5),
3254 ctx.font_bold,
3255 );
3256 ctx.layer
3257 .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
3258 ctx.layer.use_text(
3259 pdf_safe_str(label),
3260 5.5,
3261 Mm(cx + 3.0),
3262 Mm(chips_bot + 1.5),
3263 ctx.font_reg,
3264 );
3265 }
3266
3267 if ss.by_language.is_empty() {
3269 return chips_bot;
3270 }
3271 let tbl_top = chips_bot - GAP;
3272
3273 let col_w = [0.28_f32, 0.08, 0.36, 0.14, 0.14];
3275 let col_x: Vec<f32> = col_w
3276 .iter()
3277 .scan(ctx.margin, |acc, &w| {
3278 let x = *acc;
3279 *acc += w * usable_w;
3280 Some(x)
3281 })
3282 .collect();
3283 let headers = ["Language Family", "Files", "Top Guide", "Score", &col_label];
3284
3285 pdf_fill_rect(
3286 ctx.layer,
3287 ctx.margin,
3288 tbl_top - TBL_HDR_H,
3289 usable_w,
3290 TBL_HDR_H,
3291 Rgb::new(0.098, 0.11, 0.15, None),
3292 );
3293 ctx.layer
3294 .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
3295 for (hi, hdr) in headers.iter().enumerate() {
3296 ctx.layer.use_text(
3297 pdf_safe_str(hdr),
3298 5.5,
3299 Mm(col_x[hi] + 2.0),
3300 Mm(tbl_top - TBL_HDR_H + 1.5),
3301 ctx.font_bold,
3302 );
3303 }
3304
3305 let mut row_y = tbl_top - TBL_HDR_H;
3306 for (ri, grp) in ss.by_language.iter().take(5).enumerate() {
3307 let ry = row_y - ROW_H;
3308 let bg = if ri % 2 == 0 {
3309 Rgb::new(0.975, 0.965, 0.95, None)
3310 } else {
3311 Rgb::new(1.0, 1.0, 1.0, None)
3312 };
3313 pdf_fill_rect(ctx.layer, ctx.margin, ry, usable_w, ROW_H, bg);
3314 ctx.layer
3315 .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
3316 let cells = [
3317 pdf_trunc(&grp.language_family, 26),
3318 grp.files_count.to_string(),
3319 pdf_trunc(&grp.dominant_guide, 28),
3320 format!("{}%", grp.dominant_score_pct),
3321 format!("{}%", grp.line_col_compliant_pct),
3322 ];
3323 for (ci, cell) in cells.iter().enumerate() {
3324 let is_score = ci == 3 || ci == 4;
3325 if is_score && cell != "--" {
3326 ctx.layer
3327 .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
3328 } else {
3329 ctx.layer
3330 .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
3331 }
3332 ctx.layer.use_text(
3333 pdf_safe_str(cell),
3334 6.0,
3335 Mm(col_x[ci] + 2.0),
3336 Mm(ry + 1.5),
3337 ctx.font_reg,
3338 );
3339 }
3340 row_y = ry;
3341 }
3342
3343 row_y
3344}
3345
3346#[allow(clippy::cast_precision_loss)]
3349fn pdf_render_cocomo_section(ctx: &PdfCtx<'_>, run: &AnalysisRun, section_top: f32) -> f32 {
3350 use crate::pdf_compat::{Color, Mm, Rgb};
3351 const HDR_H: f32 = 5.5;
3352 const ROW_H: f32 = 13.0; const NOTE_H: f32 = 2.0; const GAP: f32 = 5.0; let Some(ref c) = run.cocomo else {
3357 return section_top;
3358 };
3359
3360 let mode_label = match c.mode {
3361 CocomoMode::Organic => "Organic",
3362 CocomoMode::SemiDetached => "Semi-detached",
3363 CocomoMode::Embedded => "Embedded",
3364 };
3365 let usable_w = 2.0_f32.mul_add(-ctx.margin, ctx.w);
3366
3367 pdf_section_header_bar(
3369 ctx,
3370 usable_w,
3371 section_top,
3372 HDR_H,
3373 "CONSTRUCTIVE COST MODEL (COCOMO I) ESTIMATE",
3374 );
3375 ctx.layer
3376 .set_fill_color(Color::Rgb(Rgb::new(0.85, 0.65, 0.35, None)));
3377 let mode_display = format!("{mode_label} mode");
3378 ctx.layer.use_text(
3379 mode_display.as_str(),
3380 5.5,
3381 Mm(ctx.w - ctx.margin - 28.0),
3382 Mm(section_top - HDR_H + 1.5),
3383 ctx.font_reg,
3384 );
3385
3386 let col_w = usable_w / 4.0;
3388 let row_y = section_top - HDR_H - ROW_H;
3389 let data: [(&str, String); 4] = [
3390 ("Person-months", format!("{:.2}", c.effort_person_months)),
3391 ("Schedule (months)", format!("{:.2}", c.duration_months)),
3392 ("Avg. Team Size", format!("{:.2}", c.avg_staff)),
3393 ("Input KSLOC", format!("{:.2}K", c.ksloc)),
3394 ];
3395 for (i, (label, value)) in data.iter().enumerate() {
3396 let cx = (i as f32).mul_add(col_w, ctx.margin);
3397 let bg = if i % 2 == 0 {
3398 Rgb::new(0.975, 0.965, 0.95, None)
3399 } else {
3400 Rgb::new(1.0, 1.0, 1.0, None)
3401 };
3402 pdf_fill_rect(ctx.layer, cx, row_y, col_w, ROW_H, bg);
3403 ctx.layer
3404 .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
3405 ctx.layer
3406 .use_text(*label, 5.5, Mm(cx + 2.0), Mm(row_y + 9.0), ctx.font_reg);
3407 ctx.layer
3408 .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
3409 ctx.layer.use_text(
3410 value.as_str(),
3411 10.0,
3412 Mm(cx + 2.0),
3413 Mm(row_y + 2.5),
3414 ctx.font_bold,
3415 );
3416 }
3417
3418 let note_y = row_y - GAP;
3420 ctx.layer
3421 .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
3422 ctx.layer.use_text(
3423 "COCOMO I (Boehm, 1981): algorithmic model converting SLOC into effort, schedule, and team-size estimates. \
3424 Ballpark figures only - actual outcomes vary with team experience and domain complexity.",
3425 5.5,
3426 Mm(ctx.margin),
3427 Mm(note_y),
3428 ctx.font_reg,
3429 );
3430
3431 note_y - NOTE_H
3432}
3433
3434fn pdf_text_right(ctx: &PdfCtx<'_>, text: &str, pt: f32, x_right: f32, y: f32, bold: bool) {
3437 use crate::pdf_compat::Mm;
3438 let font = if bold { ctx.font_bold } else { ctx.font_reg };
3439 let w = helvetica_width_mm(text, pt, bold);
3440 ctx.layer.use_text(text, pt, Mm(x_right - w), Mm(y), font);
3441}
3442
3443fn pdf_fit_path(path: &str, budget_mm: f32, pt: f32) -> String {
3446 if helvetica_width_mm(path, pt, false) <= budget_mm {
3447 return path.to_string();
3448 }
3449 let mut chars: Vec<char> = path.chars().collect();
3450 while !chars.is_empty() {
3451 chars.remove(0);
3452 let candidate: String = format!("...{}", chars.iter().collect::<String>());
3453 if helvetica_width_mm(&candidate, pt, false) <= budget_mm {
3454 return candidate;
3455 }
3456 }
3457 "...".to_string()
3458}
3459
3460fn pdf_fit_text(text: &str, budget_mm: f32, pt: f32) -> String {
3463 if helvetica_width_mm(text, pt, false) <= budget_mm {
3464 return text.to_string();
3465 }
3466 let mut chars: Vec<char> = text.chars().collect();
3467 while !chars.is_empty() {
3468 chars.pop();
3469 let candidate: String = format!("{}...", chars.iter().collect::<String>());
3470 if helvetica_width_mm(&candidate, pt, false) <= budget_mm {
3471 return candidate;
3472 }
3473 }
3474 "...".to_string()
3475}
3476
3477fn pdf_render_hotspots_section(ctx: &PdfCtx<'_>, rows: &[HotspotRow], section_top: f32) -> f32 {
3481 use crate::pdf_compat::{Color, Mm, Rgb};
3482 const HDR_H: f32 = 5.5;
3483 const COLHDR_H: f32 = 5.0;
3484 const ROW_H: f32 = 5.2;
3485 const NOTE_GAP: f32 = 4.0;
3486
3487 let usable_w = 2.0_f32.mul_add(-ctx.margin, ctx.w);
3488
3489 pdf_section_header_bar(
3491 ctx,
3492 usable_w,
3493 section_top,
3494 HDR_H,
3495 "GIT HOTSPOTS (CODE LINES x RECENT COMMITS)",
3496 );
3497
3498 let col_last_r = ctx.w - ctx.margin;
3500 let col_score_r = col_last_r - 32.0;
3501 let col_commits_r = col_score_r - 33.0;
3502 let col_code_r = col_commits_r - 32.0;
3503 let file_x = ctx.margin + 2.0;
3504 let file_budget = (col_code_r - 26.0) - file_x;
3505
3506 let chdr_y = section_top - HDR_H - COLHDR_H;
3508 pdf_fill_rect(
3509 ctx.layer,
3510 ctx.margin,
3511 chdr_y,
3512 usable_w,
3513 COLHDR_H,
3514 Rgb::new(0.90, 0.88, 0.84, None),
3515 );
3516 ctx.layer
3517 .set_fill_color(Color::Rgb(Rgb::new(0.30, 0.30, 0.30, None)));
3518 ctx.layer
3519 .use_text("File", 6.0, Mm(file_x), Mm(chdr_y + 1.4), ctx.font_bold);
3520 pdf_text_right(ctx, "Code lines", 6.0, col_code_r, chdr_y + 1.4, true);
3521 pdf_text_right(ctx, "Commits", 6.0, col_commits_r, chdr_y + 1.4, true);
3522 pdf_text_right(ctx, "Hotspot score", 6.0, col_score_r, chdr_y + 1.4, true);
3523 pdf_text_right(ctx, "Last changed", 6.0, col_last_r, chdr_y + 1.4, true);
3524
3525 let mut y = chdr_y;
3527 for (ri, hrow) in rows.iter().enumerate() {
3528 y -= ROW_H;
3529 let bg = if ri.is_multiple_of(2) {
3530 Rgb::new(0.975, 0.965, 0.95, None)
3531 } else {
3532 Rgb::new(1.0, 1.0, 1.0, None)
3533 };
3534 pdf_fill_rect(ctx.layer, ctx.margin, y, usable_w, ROW_H, bg);
3535 ctx.layer
3537 .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
3538 let path = pdf_fit_path(&pdf_safe_str(&hrow.path), file_budget, 6.0);
3539 ctx.layer
3540 .use_text(path, 6.0, Mm(file_x), Mm(y + 1.4), ctx.font_reg);
3541 ctx.layer
3543 .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
3544 pdf_text_right(
3545 ctx,
3546 &group_thousands(&hrow.code_lines.to_string()),
3547 6.0,
3548 col_code_r,
3549 y + 1.4,
3550 false,
3551 );
3552 pdf_text_right(
3553 ctx,
3554 &hrow.commit_count.to_string(),
3555 6.0,
3556 col_commits_r,
3557 y + 1.4,
3558 false,
3559 );
3560 ctx.layer
3562 .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
3563 pdf_text_right(
3564 ctx,
3565 &group_thousands(&hrow.score.to_string()),
3566 6.0,
3567 col_score_r,
3568 y + 1.4,
3569 true,
3570 );
3571 ctx.layer
3572 .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
3573 pdf_text_right(ctx, &hrow.last_commit_date, 6.0, col_last_r, y + 1.4, false);
3574 }
3575
3576 let note_y = y - NOTE_GAP;
3578 ctx.layer
3579 .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
3580 ctx.layer.use_text(
3581 "Files ranked by code lines x commits over the configured git activity window. \
3582 Distinct from the Compare page's scan-to-scan churn rate.",
3583 5.5,
3584 Mm(ctx.margin),
3585 Mm(note_y + 1.0),
3586 ctx.font_reg,
3587 );
3588
3589 note_y
3590}
3591
3592#[allow(clippy::too_many_arguments)]
3595fn pdf_render_hotspots_page(
3596 doc: &crate::pdf_compat::PdfDocumentReference,
3597 font_reg: crate::pdf_compat::IndirectFontRef,
3598 font_bold: crate::pdf_compat::IndirectFontRef,
3599 run: &AnalysisRun,
3600 rows: &[HotspotRow],
3601 w: f32,
3602 h: f32,
3603 margin: f32,
3604 footer_h: f32,
3605 title: &str,
3606 version: &str,
3607) -> (
3608 crate::pdf_compat::PdfPageIndex,
3609 crate::pdf_compat::PdfLayerIndex,
3610 f32,
3611) {
3612 use crate::pdf_compat::Mm;
3613 const HDR_H: f32 = 8.0;
3614
3615 let (page, layer_idx) = doc.add_page(Mm(w), Mm(h), "Git Hotspots");
3616 let layer = doc.get_page(page).get_layer(layer_idx);
3617 let ctx = PdfCtx {
3618 layer: &layer,
3619 font_reg,
3620 font_bold,
3621 w,
3622 margin,
3623 row_h: 5.5,
3624 tbl_hdr_h: 6.0,
3625 };
3626
3627 pdf_page_mini_header(&ctx, h, HDR_H, title, run);
3628
3629 let bottom = pdf_render_hotspots_section(&ctx, rows, h - HDR_H - 4.0);
3630
3631 pdf_page_footer_band(&ctx, footer_h, version);
3632
3633 (page, layer_idx, bottom - 3.0)
3634}
3635
3636fn pdf_render_ownership_section(
3640 ctx: &PdfCtx<'_>,
3641 rows: &[AuthorReportRow],
3642 section_top: f32,
3643) -> f32 {
3644 use crate::pdf_compat::{Color, Mm, Rgb};
3645 const HDR_H: f32 = 5.5;
3646 const COLHDR_H: f32 = 5.0;
3647 const ROW_H: f32 = 5.2;
3648 const NOTE_GAP: f32 = 4.0;
3649
3650 let usable_w = 2.0_f32.mul_add(-ctx.margin, ctx.w);
3651
3652 pdf_section_header_bar(
3653 ctx,
3654 usable_w,
3655 section_top,
3656 HDR_H,
3657 "CODE OWNERSHIP (LINES PER CONTRIBUTOR, GIT BLAME)",
3658 );
3659
3660 let col_files_r = ctx.w - ctx.margin;
3662 let col_pct_r = col_files_r - 22.0;
3663 let col_total_r = col_pct_r - 28.0;
3664 let col_blank_r = col_total_r - 26.0;
3665 let col_comment_r = col_blank_r - 28.0;
3666 let col_code_r = col_comment_r - 26.0;
3667 let name_x = ctx.margin + 2.0;
3668 let name_budget = (col_code_r - 26.0) - name_x;
3669
3670 let chdr_y = section_top - HDR_H - COLHDR_H;
3671 pdf_fill_rect(
3672 ctx.layer,
3673 ctx.margin,
3674 chdr_y,
3675 usable_w,
3676 COLHDR_H,
3677 Rgb::new(0.90, 0.88, 0.84, None),
3678 );
3679 ctx.layer
3680 .set_fill_color(Color::Rgb(Rgb::new(0.30, 0.30, 0.30, None)));
3681 ctx.layer
3682 .use_text("Author", 6.0, Mm(name_x), Mm(chdr_y + 1.4), ctx.font_bold);
3683 pdf_text_right(ctx, "Code", 6.0, col_code_r, chdr_y + 1.4, true);
3684 pdf_text_right(ctx, "Comment", 6.0, col_comment_r, chdr_y + 1.4, true);
3685 pdf_text_right(ctx, "Blank", 6.0, col_blank_r, chdr_y + 1.4, true);
3686 pdf_text_right(ctx, "Total", 6.0, col_total_r, chdr_y + 1.4, true);
3687 pdf_text_right(ctx, "Code %", 6.0, col_pct_r, chdr_y + 1.4, true);
3688 pdf_text_right(ctx, "Files", 6.0, col_files_r, chdr_y + 1.4, true);
3689
3690 let mut y = chdr_y;
3691 for (ri, a) in rows.iter().enumerate() {
3692 y -= ROW_H;
3693 let bg = if ri.is_multiple_of(2) {
3694 Rgb::new(0.975, 0.965, 0.95, None)
3695 } else {
3696 Rgb::new(1.0, 1.0, 1.0, None)
3697 };
3698 pdf_fill_rect(ctx.layer, ctx.margin, y, usable_w, ROW_H, bg);
3699 ctx.layer
3700 .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
3701 let name = pdf_fit_text(&pdf_safe_str(&a.name), name_budget, 6.0);
3702 ctx.layer
3703 .use_text(name, 6.0, Mm(name_x), Mm(y + 1.4), ctx.font_reg);
3704 pdf_text_right(
3705 ctx,
3706 &group_thousands(&a.code.to_string()),
3707 6.0,
3708 col_code_r,
3709 y + 1.4,
3710 false,
3711 );
3712 pdf_text_right(
3713 ctx,
3714 &group_thousands(&a.comment.to_string()),
3715 6.0,
3716 col_comment_r,
3717 y + 1.4,
3718 false,
3719 );
3720 pdf_text_right(
3721 ctx,
3722 &group_thousands(&a.blank.to_string()),
3723 6.0,
3724 col_blank_r,
3725 y + 1.4,
3726 false,
3727 );
3728 pdf_text_right(
3729 ctx,
3730 &group_thousands(&a.total.to_string()),
3731 6.0,
3732 col_total_r,
3733 y + 1.4,
3734 false,
3735 );
3736 ctx.layer
3737 .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
3738 pdf_text_right(
3739 ctx,
3740 &format!("{}%", a.code_pct_str),
3741 6.0,
3742 col_pct_r,
3743 y + 1.4,
3744 true,
3745 );
3746 ctx.layer
3747 .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
3748 pdf_text_right(
3749 ctx,
3750 &a.files_owned.to_string(),
3751 6.0,
3752 col_files_r,
3753 y + 1.4,
3754 false,
3755 );
3756 }
3757
3758 let note_y = y - NOTE_GAP;
3759 ctx.layer
3760 .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
3761 ctx.layer.use_text(
3762 "Physical lines attributed by git blame (-w -M -C, .mailmap honoured). Same-email \
3763 identities are merged; cross-account merging is a later step.",
3764 5.5,
3765 Mm(ctx.margin),
3766 Mm(note_y + 1.0),
3767 ctx.font_reg,
3768 );
3769
3770 note_y
3771}
3772
3773#[allow(clippy::too_many_arguments)]
3776fn pdf_render_ownership_page(
3777 doc: &crate::pdf_compat::PdfDocumentReference,
3778 font_reg: crate::pdf_compat::IndirectFontRef,
3779 font_bold: crate::pdf_compat::IndirectFontRef,
3780 run: &AnalysisRun,
3781 rows: &[AuthorReportRow],
3782 w: f32,
3783 h: f32,
3784 margin: f32,
3785 footer_h: f32,
3786 title: &str,
3787 version: &str,
3788) {
3789 use crate::pdf_compat::Mm;
3790 const HDR_H: f32 = 8.0;
3791
3792 let (page, layer_idx) = doc.add_page(Mm(w), Mm(h), "Code Ownership");
3793 let layer = doc.get_page(page).get_layer(layer_idx);
3794 let ctx = PdfCtx {
3795 layer: &layer,
3796 font_reg,
3797 font_bold,
3798 w,
3799 margin,
3800 row_h: 5.2,
3801 tbl_hdr_h: 5.0,
3802 };
3803
3804 pdf_page_mini_header(&ctx, h, HDR_H, title, run);
3805 pdf_render_ownership_section(&ctx, rows, h - HDR_H - 4.0);
3806 pdf_page_footer_band(&ctx, footer_h, version);
3807}
3808
3809#[allow(clippy::too_many_arguments)]
3818fn measure_terminal_tc_page_height(
3819 run: &AnalysisRun,
3820 w: f32,
3821 h_full: f32,
3822 margin: f32,
3823 footer_h: f32,
3824 row_h: f32,
3825 tbl_hdr_h: f32,
3826 with_cocomo: bool,
3827) -> f32 {
3828 use crate::pdf_compat::{BuiltinFont, Mm, PdfDocument};
3829 let measure = || -> Option<f32> {
3830 let (doc, page, layer_idx) = PdfDocument::new("measure", Mm(w), Mm(h_full), "m");
3831 let font_reg = doc.add_builtin_font(BuiltinFont::Helvetica).ok()?;
3832 let font_bold = doc.add_builtin_font(BuiltinFont::HelveticaBold).ok()?;
3833 let layer = doc.get_page(page).get_layer(layer_idx);
3834 let ctx = PdfCtx {
3835 layer: &layer,
3836 font_reg,
3837 font_bold,
3838 w,
3839 margin,
3840 row_h,
3841 tbl_hdr_h,
3842 };
3843 let content_bottom = if with_cocomo {
3846 let cocomo_bottom = pdf_render_cocomo_section(&ctx, run, h_full - 8.0 - 6.0);
3847 pdf_render_tc_inline(&ctx, run, cocomo_bottom - 2.0, footer_h)
3848 } else {
3849 pdf_render_tc_inline(&ctx, run, h_full - 8.0 - 4.0, footer_h)
3850 };
3851 let pad = 4.0;
3853 Some((h_full - content_bottom + footer_h + pad).clamp(60.0, h_full))
3854 };
3855 measure().unwrap_or(h_full)
3856}
3857
3858#[allow(
3864 clippy::cast_precision_loss,
3865 clippy::cast_possible_truncation,
3866 clippy::cast_sign_loss,
3867 clippy::too_many_arguments
3868)]
3869fn pdf_render_cocomo_or_tc_page(
3870 doc: &crate::pdf_compat::PdfDocumentReference,
3871 font_reg: crate::pdf_compat::IndirectFontRef,
3872 font_bold: crate::pdf_compat::IndirectFontRef,
3873 run: &AnalysisRun,
3874 dims: PdfPageDims,
3875 title: &str,
3876 version: &str,
3877 cocomo_fits_page1: bool,
3878 trim_page: bool,
3879) -> (
3880 crate::pdf_compat::PdfPageIndex,
3881 crate::pdf_compat::PdfLayerIndex,
3882 f32,
3883) {
3884 use crate::pdf_compat::{Color, Mm, Mm as PdfMm, Rgb};
3885 let PdfPageDims {
3886 w,
3887 h,
3888 margin,
3889 footer_h,
3890 row_h,
3891 tbl_hdr_h,
3892 } = dims;
3893
3894 if run.cocomo.is_none() || cocomo_fits_page1 {
3896 let page_h = if trim_page {
3897 measure_terminal_tc_page_height(run, w, h, margin, footer_h, row_h, tbl_hdr_h, false)
3898 } else {
3899 h
3900 };
3901 return pdf_render_tests_coverage_page(
3902 doc, font_reg, font_bold, run, w, page_h, margin, footer_h, title, version,
3903 );
3904 }
3905
3906 let page_h = if trim_page {
3907 measure_terminal_tc_page_height(run, w, h, margin, footer_h, row_h, tbl_hdr_h, true)
3908 } else {
3909 h
3910 };
3911 let (c2_page, c2_layer_idx) = doc.add_page(Mm(w), Mm(page_h), "Content");
3912 let c2_layer = doc.get_page(c2_page).get_layer(c2_layer_idx);
3913 let c2_ctx = PdfCtx {
3914 layer: &c2_layer,
3915 font_reg,
3916 font_bold,
3917 w,
3918 margin,
3919 row_h,
3920 tbl_hdr_h,
3921 };
3922 pdf_fill_rect(
3924 &c2_layer,
3925 0.0,
3926 page_h - 8.0,
3927 w,
3928 8.0,
3929 Rgb::new(0.098, 0.11, 0.15, None),
3930 );
3931 c2_layer.set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
3932 c2_layer.use_text(
3933 "oxide-sloc",
3934 9.0,
3935 PdfMm(margin),
3936 PdfMm(page_h - 5.5),
3937 font_bold,
3938 );
3939 c2_layer.set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
3940 c2_layer.use_text(
3941 pdf_trunc(&pdf_safe_str(title), 45),
3942 7.5,
3943 PdfMm(46.0),
3944 PdfMm(page_h - 5.5),
3945 font_reg,
3946 );
3947 pdf_draw_header_meta(
3948 &c2_layer,
3949 font_reg,
3950 w,
3951 margin,
3952 page_h - 5.5,
3953 &pdf_page_header_meta(run),
3954 );
3955 let cocomo_bottom = pdf_render_cocomo_section(&c2_ctx, run, page_h - 8.0 - 6.0);
3956 let tc_bottom = pdf_render_tc_inline(&c2_ctx, run, cocomo_bottom - 2.0, footer_h);
3958 pdf_fill_rect(
3960 &c2_layer,
3961 0.0,
3962 0.0,
3963 w,
3964 footer_h,
3965 Rgb::new(0.93, 0.91, 0.87, None),
3966 );
3967 c2_layer.set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)));
3968 c2_layer.use_text(
3969 format!("oxide-sloc v{version} | AGPL-3.0-or-later"),
3970 6.5,
3971 PdfMm(margin),
3972 PdfMm(3.0),
3973 font_reg,
3974 );
3975 (c2_page, c2_layer_idx, tc_bottom - 3.0)
3977}
3978
3979#[allow(
3989 clippy::cast_precision_loss,
3990 clippy::cast_possible_truncation,
3991 clippy::cast_sign_loss,
3992 clippy::too_many_lines
3993)]
3994pub fn write_pdf_from_run(run: &AnalysisRun, pdf_path: &Path) -> Result<()> {
3995 use crate::pdf_compat::{BuiltinFont, Mm, PdfDocument};
3996 use std::fs::File;
3997 use std::io::BufWriter;
3998
3999 const W: f32 = 297.0;
4000 const H: f32 = 210.0;
4001 const MARGIN: f32 = 10.0;
4002 const FOOTER_H: f32 = 10.0;
4003 const HDR_H: f32 = 13.5;
4004 const ROW_H: f32 = 5.5;
4005 const TBL_HDR_H: f32 = 6.0;
4006
4007 if let Some(parent) = pdf_path.parent() {
4008 fs::create_dir_all(parent)
4009 .with_context(|| format!("failed to create PDF directory {}", parent.display()))?;
4010 }
4011
4012 let title = pdf_safe_str(&run.effective_configuration.reporting.report_title);
4013 let ts = to_pt_hhmm(run.tool.timestamp_utc);
4014 let version = env!("CARGO_PKG_VERSION");
4015 let banner = run
4016 .effective_configuration
4017 .reporting
4018 .report_header_footer
4019 .as_deref();
4020
4021 let (doc, page1, layer1) =
4022 PdfDocument::new(format!("oxide-sloc: {title}"), Mm(W), Mm(H), "Content");
4023 let font_reg = doc
4024 .add_builtin_font(BuiltinFont::Helvetica)
4025 .map_err(|e| anyhow::anyhow!("printpdf font error: {e}"))?;
4026 let font_bold = doc
4027 .add_builtin_font(BuiltinFont::HelveticaBold)
4028 .map_err(|e| anyhow::anyhow!("printpdf font error: {e}"))?;
4029 let layer = doc.get_page(page1).get_layer(layer1);
4030
4031 let ctx = PdfCtx {
4032 layer: &layer,
4033 font_reg,
4034 font_bold,
4035 w: W,
4036 margin: MARGIN,
4037 row_h: ROW_H,
4038 tbl_hdr_h: TBL_HDR_H,
4039 };
4040 let roots_text_y = pdf_render_page1_header(&ctx, run, &ts, &title, H, HDR_H, banner);
4041 let row2_bot = pdf_render_summary_chips(&ctx, run, roots_text_y);
4042 let info_y = pdf_render_info_lines(&ctx, run, row2_bot);
4043 let tbl_top = info_y - 4.0;
4044 pdf_render_metric_tables(&ctx, run, tbl_top);
4045 let after_tables_y = tbl_top - 64.5 - 4.0;
4048 let after_style_y = run.style_summary.as_ref().map_or(after_tables_y, |ss| {
4049 if after_tables_y > FOOTER_H + 12.0 {
4050 pdf_render_style_section(&ctx, ss, after_tables_y)
4051 } else {
4052 after_tables_y
4053 }
4054 });
4055 let cocomo_fits_page1 = run.cocomo.is_some() && (after_style_y - 3.0) > FOOTER_H + 32.0;
4058 if cocomo_fits_page1 {
4059 pdf_render_cocomo_section(&ctx, run, after_style_y - 3.0);
4060 }
4061 pdf_render_page1_footer(&ctx, run, FOOTER_H, version, banner);
4062
4063 let hotspot_rows = build_hotspot_rows(run, 15);
4068 let has_per_file = !run.per_file_records.is_empty();
4069 let tc_page_gets_per_file = hotspot_rows.is_empty() && has_per_file;
4070 let trim_tc_page = !tc_page_gets_per_file;
4071
4072 let page_dims = PdfPageDims {
4076 w: W,
4077 h: H,
4078 margin: MARGIN,
4079 footer_h: FOOTER_H,
4080 row_h: ROW_H,
4081 tbl_hdr_h: TBL_HDR_H,
4082 };
4083 let cocomo_page_ctx = pdf_render_cocomo_or_tc_page(
4084 &doc,
4085 font_reg,
4086 font_bold,
4087 run,
4088 page_dims,
4089 &title,
4090 version,
4091 cocomo_fits_page1,
4092 trim_tc_page,
4093 );
4094
4095 let per_file_start = if hotspot_rows.is_empty() {
4102 Some(cocomo_page_ctx)
4103 } else {
4104 Some(pdf_render_hotspots_page(
4105 &doc,
4106 font_reg,
4107 font_bold,
4108 run,
4109 &hotspot_rows,
4110 W,
4111 H,
4112 MARGIN,
4113 FOOTER_H,
4114 &title,
4115 version,
4116 ))
4117 };
4118
4119 if !run.per_file_records.is_empty() {
4120 pdf_render_per_file_pages(
4122 &doc,
4123 font_reg,
4124 font_bold,
4125 run,
4126 W,
4127 H,
4128 MARGIN,
4129 FOOTER_H,
4130 ROW_H,
4131 TBL_HDR_H,
4132 &title,
4133 &ts,
4134 version,
4135 banner,
4136 per_file_start,
4137 );
4138 }
4139
4140 let author_rows = build_author_rows(run);
4143 if !author_rows.is_empty() {
4144 let capped: Vec<AuthorReportRow> = author_rows.into_iter().take(40).collect();
4145 pdf_render_ownership_page(
4146 &doc, font_reg, font_bold, run, &capped, W, H, MARGIN, FOOTER_H, &title, version,
4147 );
4148 }
4149
4150 doc.save(&mut BufWriter::new(File::create(pdf_path).with_context(
4151 || format!("cannot create PDF at {}", pdf_path.display()),
4152 )?))
4153 .map_err(|e| anyhow::anyhow!("printpdf save error: {e}"))?;
4154
4155 Ok(())
4156}
4157
4158const HELVETICA_WIDTHS: &[(char, u32, u32)] = &[
4166 (' ', 278, 278),
4167 ('!', 333, 278),
4168 ('"', 474, 355),
4169 ('#', 556, 556),
4170 ('$', 556, 556),
4171 ('%', 889, 889),
4172 ('&', 722, 667),
4173 ('\'', 278, 222),
4174 ('(', 333, 333),
4175 (')', 333, 333),
4176 ('*', 389, 389),
4177 ('+', 584, 584),
4178 (',', 278, 278),
4179 ('-', 333, 333),
4180 ('.', 278, 278),
4181 ('/', 278, 278),
4182 (':', 333, 278),
4183 (';', 333, 278),
4184 ('<', 584, 584),
4185 ('=', 584, 584),
4186 ('>', 584, 584),
4187 ('?', 556, 472),
4188 ('@', 975, 1015),
4189 ('A', 722, 667),
4190 ('B', 722, 667),
4191 ('C', 722, 722),
4192 ('D', 722, 722),
4193 ('E', 667, 667),
4194 ('F', 611, 611),
4195 ('G', 778, 778),
4196 ('H', 722, 722),
4197 ('I', 278, 278),
4198 ('J', 556, 500),
4199 ('K', 722, 667),
4200 ('L', 611, 556),
4201 ('M', 833, 833),
4202 ('N', 722, 722),
4203 ('O', 778, 778),
4204 ('P', 667, 667),
4205 ('Q', 778, 778),
4206 ('R', 722, 722),
4207 ('S', 667, 667),
4208 ('T', 611, 611),
4209 ('U', 722, 722),
4210 ('V', 667, 667),
4211 ('W', 944, 944),
4212 ('X', 667, 667),
4213 ('Y', 611, 611),
4214 ('Z', 611, 611),
4215 ('[', 333, 278),
4216 ('\\', 278, 278),
4217 (']', 333, 278),
4218 ('^', 584, 469),
4219 ('_', 556, 556),
4220 ('`', 278, 222),
4221 ('a', 556, 556),
4222 ('b', 611, 556),
4223 ('c', 556, 500),
4224 ('d', 611, 556),
4225 ('e', 556, 556),
4226 ('f', 333, 278),
4227 ('g', 611, 556),
4228 ('h', 611, 556),
4229 ('i', 278, 222),
4230 ('j', 278, 222),
4231 ('k', 556, 500),
4232 ('l', 278, 222),
4233 ('m', 889, 833),
4234 ('n', 611, 556),
4235 ('o', 611, 556),
4236 ('p', 611, 556),
4237 ('q', 611, 556),
4238 ('r', 389, 333),
4239 ('s', 556, 500),
4240 ('t', 333, 278),
4241 ('u', 611, 556),
4242 ('v', 556, 500),
4243 ('w', 778, 722),
4244 ('x', 556, 500),
4245 ('y', 556, 500),
4246 ('z', 500, 500),
4247 ('\u{00B7}', 278, 278), ];
4249
4250fn helvetica_advance(ch: char, bold: bool) -> u32 {
4254 if ch.is_ascii_digit() {
4255 return 556;
4256 }
4257 for &(glyph, bold_w, regular_w) in HELVETICA_WIDTHS {
4258 if glyph == ch {
4259 return if bold { bold_w } else { regular_w };
4260 }
4261 }
4262 if bold { 556 } else { 500 }
4263}
4264
4265#[allow(
4271 clippy::cast_precision_loss,
4272 reason = "bounded glyph-unit sum to mm width"
4273)]
4274fn helvetica_width_mm(text: &str, pt: f32, bold: bool) -> f32 {
4275 let units: u32 = text.chars().map(|ch| helvetica_advance(ch, bold)).sum();
4276 units as f32 * pt * (25.4 / 72.0) / 1000.0
4278}
4279
4280fn pdf_fill_rect(
4281 layer: &crate::pdf_compat::PdfLayerReference,
4282 x: f32,
4283 y: f32,
4284 w: f32,
4285 h: f32,
4286 color: crate::pdf_compat::Rgb,
4287) {
4288 layer.fill_rect(x, y, w, h, color);
4289}
4290
4291fn pdf_safe_str(s: &str) -> String {
4292 let mut out = String::with_capacity(s.len());
4293 for c in s.chars() {
4294 match c {
4295 '\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),
4303 _ => {} }
4305 }
4306 out
4307}
4308
4309fn pdf_trunc(s: &str, max: usize) -> String {
4310 if s.len() <= max {
4311 s.to_string()
4312 } else {
4313 format!("{}...", &s[..max.saturating_sub(3)])
4314 }
4315}
4316
4317fn pdf_trunc_end(s: &str, max: usize) -> String {
4320 if s.len() <= max {
4321 s.to_string()
4322 } else {
4323 format!("...{}", &s[s.len() - max.saturating_sub(3)..])
4324 }
4325}
4326
4327fn pdf_fmt_full(n: u64) -> String {
4328 let s = n.to_string();
4330 let mut out = String::with_capacity(s.len() + s.len() / 3);
4331 for (i, ch) in s.chars().rev().enumerate() {
4332 if i > 0 && i % 3 == 0 {
4333 out.push(',');
4334 }
4335 out.push(ch);
4336 }
4337 out.chars().rev().collect()
4338}
4339
4340pub fn write_pdf_from_html(html_path: &Path, pdf_path: &Path) -> Result<()> {
4350 eprintln!("[oxide-sloc][pdf] starting");
4351
4352 let absolute_html = html_path
4353 .canonicalize()
4354 .with_context(|| format!("failed to canonicalize {}", html_path.display()))?;
4355 eprintln!(
4357 "[oxide-sloc][pdf] html = {}",
4358 absolute_html.to_string_lossy().trim_start_matches(r"\\?\")
4359 );
4360
4361 let absolute_pdf = if pdf_path.is_absolute() {
4362 pdf_path.to_path_buf()
4363 } else {
4364 std::env::current_dir()
4365 .context("failed to resolve current working directory")?
4366 .join(pdf_path)
4367 };
4368 eprintln!("[oxide-sloc][pdf] pdf = {}", absolute_pdf.display());
4369
4370 if let Some(parent) = absolute_pdf.parent() {
4371 fs::create_dir_all(parent).with_context(|| {
4372 format!("failed to create PDF output directory {}", parent.display())
4373 })?;
4374 }
4375
4376 match write_pdf_via_cdp(&absolute_html, &absolute_pdf) {
4377 Ok(()) => {}
4378 Err(cdp_err) => {
4379 eprintln!("[oxide-sloc][pdf] CDP failed ({cdp_err:#}), trying wkhtmltopdf fallback");
4380 write_pdf_via_wkhtmltopdf(&absolute_html, &absolute_pdf).with_context(|| {
4381 format!(
4382 "PDF generation failed via both CDP ({cdp_err:#}) and wkhtmltopdf. \
4383 Install a Chromium-based browser (Chrome, Edge, Brave) or wkhtmltopdf \
4384 on the server, or set SLOC_BROWSER to the browser executable path."
4385 )
4386 })?;
4387 }
4388 }
4389
4390 eprintln!("[oxide-sloc][pdf] done");
4391 Ok(())
4392}
4393
4394fn normalize_browser_env_path(raw: &str) -> PathBuf {
4395 let trimmed = raw.trim();
4396 #[cfg(windows)]
4397 {
4398 let bytes = trimmed.as_bytes();
4399 if bytes.len() >= 3
4400 && bytes[0] == b'/'
4401 && bytes[2] == b'/'
4402 && bytes[1].is_ascii_alphabetic()
4403 {
4404 let drive = (bytes[1] as char).to_ascii_uppercase();
4405 let rest = &trimmed[3..];
4406 return PathBuf::from(format!("{drive}:/{rest}"));
4407 }
4408 }
4409 PathBuf::from(trimmed)
4410}
4411
4412fn discover_browser_from_env() -> Option<PathBuf> {
4413 for var_name in ["SLOC_BROWSER", "BROWSER"] {
4414 if let Ok(path) = std::env::var(var_name) {
4415 let candidate = normalize_browser_env_path(&path);
4416 if candidate.is_file() {
4417 return Some(candidate);
4418 }
4419 }
4420 }
4421 None
4422}
4423
4424fn discover_browser() -> Option<PathBuf> {
4425 if let Some(p) = discover_browser_from_env() {
4426 return Some(p);
4427 }
4428
4429 let names = [
4430 "chromium",
4431 "chromium-browser",
4432 "google-chrome",
4433 "google-chrome-stable",
4434 "microsoft-edge",
4435 "msedge",
4436 "brave",
4437 "brave-browser",
4438 "vivaldi",
4439 "opera",
4440 "opera-stable",
4441 ];
4442
4443 for name in names {
4444 if let Some(path) = which_in_path(name) {
4445 return Some(path);
4446 }
4447 }
4448
4449 #[cfg(windows)]
4450 {
4451 for candidate in windows_browser_candidates() {
4452 if candidate.is_file() {
4453 return Some(candidate);
4454 }
4455 }
4456 }
4457
4458 #[cfg(not(windows))]
4461 {
4462 for candidate in linux_browser_candidates() {
4463 if candidate.is_file() {
4464 return Some(candidate);
4465 }
4466 }
4467
4468 if let Some(path) = which_subprocess(&[
4471 "chromium-browser",
4472 "chromium",
4473 "google-chrome",
4474 "google-chrome-stable",
4475 "microsoft-edge",
4476 "brave-browser",
4477 ]) {
4478 return Some(path);
4479 }
4480 }
4481
4482 None
4483}
4484
4485#[cfg(windows)]
4489fn push_chromium_app_browsers(paths: &mut Vec<PathBuf>, base: &Path) {
4490 paths.push(
4491 base.join("Google")
4492 .join("Chrome")
4493 .join("Application")
4494 .join("chrome.exe"),
4495 );
4496 paths.push(
4497 base.join("Microsoft")
4498 .join("Edge")
4499 .join("Application")
4500 .join("msedge.exe"),
4501 );
4502 paths.push(
4503 base.join("BraveSoftware")
4504 .join("Brave-Browser")
4505 .join("Application")
4506 .join("brave.exe"),
4507 );
4508 paths.push(base.join("Vivaldi").join("Application").join("vivaldi.exe"));
4509}
4510
4511#[cfg(windows)]
4512fn windows_browser_candidates() -> Vec<PathBuf> {
4513 let mut paths = Vec::new();
4514
4515 let program_files = std::env::var_os("ProgramFiles");
4516 let program_files_x86 = std::env::var_os("ProgramFiles(x86)");
4517 let local_app_data = std::env::var_os("LocalAppData");
4518
4519 for base in [program_files, program_files_x86].into_iter().flatten() {
4520 let base = PathBuf::from(base);
4521 push_chromium_app_browsers(&mut paths, &base);
4522 paths.push(base.join("Opera").join("launcher.exe"));
4523 paths.push(base.join("Opera GX").join("launcher.exe"));
4524 }
4525
4526 if let Some(base) = local_app_data {
4527 let base = PathBuf::from(base);
4528 push_chromium_app_browsers(&mut paths, &base);
4529 paths.push(base.join("Programs").join("Opera").join("launcher.exe"));
4530 paths.push(base.join("Programs").join("Opera GX").join("launcher.exe"));
4531 }
4532
4533 paths
4534}
4535
4536#[cfg(not(windows))]
4537fn linux_browser_candidates() -> Vec<PathBuf> {
4538 vec![
4539 PathBuf::from("/snap/bin/chromium"),
4541 PathBuf::from("/snap/bin/chromium-browser"),
4542 PathBuf::from("/usr/bin/chromium"),
4544 PathBuf::from("/usr/bin/chromium-browser"),
4545 PathBuf::from("/usr/bin/google-chrome"),
4546 PathBuf::from("/usr/bin/google-chrome-stable"),
4547 PathBuf::from("/usr/bin/microsoft-edge"),
4548 PathBuf::from("/usr/bin/microsoft-edge-stable"),
4549 PathBuf::from("/usr/bin/brave-browser"),
4550 PathBuf::from("/usr/bin/brave-browser-stable"),
4551 PathBuf::from("/usr/lib/chromium-browser/chromium-browser"),
4553 PathBuf::from("/usr/lib/chromium/chromium"),
4554 PathBuf::from("/usr/lib/chromium/chrome"),
4555 PathBuf::from("/opt/google/chrome/google-chrome"),
4557 PathBuf::from("/opt/google/chrome-beta/google-chrome"),
4558 PathBuf::from("/opt/google/chrome-unstable/google-chrome"),
4559 PathBuf::from("/usr/local/bin/chromium"),
4561 PathBuf::from("/usr/local/bin/chromium-browser"),
4562 PathBuf::from("/usr/local/bin/google-chrome"),
4563 PathBuf::from("/var/lib/flatpak/exports/bin/org.chromium.Chromium"),
4565 PathBuf::from("/usr/share/flatpak/exports/bin/org.chromium.Chromium"),
4566 ]
4567}
4568
4569#[cfg(not(windows))]
4572fn which_subprocess(names: &[&str]) -> Option<PathBuf> {
4573 for name in names {
4574 if let Ok(out) = std::process::Command::new("which").arg(name).output()
4575 && out.status.success()
4576 {
4577 let s = String::from_utf8_lossy(&out.stdout);
4578 let path = PathBuf::from(s.trim());
4579 if path.is_file() {
4580 return Some(path);
4581 }
4582 }
4583 }
4584 None
4585}
4586
4587fn which_in_path(exe: &str) -> Option<PathBuf> {
4588 let path_var = std::env::var_os("PATH")?;
4589 for dir in std::env::split_paths(&path_var) {
4590 let candidate = dir.join(exe);
4591 if candidate.is_file() {
4592 return Some(candidate);
4593 }
4594 #[cfg(windows)]
4595 {
4596 let candidate = dir.join(format!("{exe}.exe"));
4597 if candidate.is_file() {
4598 return Some(candidate);
4599 }
4600 }
4601 }
4602 None
4603}
4604
4605fn file_url(path: &Path) -> String {
4606 let raw = path.to_string_lossy().replace('\\', "/");
4607 let normalized = if raw.starts_with('/') {
4608 raw
4609 } else {
4610 format!("/{raw}")
4611 };
4612
4613 let mut encoded = String::with_capacity(normalized.len() + 8);
4614 for byte in normalized.bytes() {
4615 match byte {
4616 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'/' | b'-' | b'_' | b'.' | b'~' | b':' => {
4617 encoded.push(byte as char);
4618 }
4619 _ => {
4620 let _ = write!(encoded, "%{byte:02X}");
4621 }
4622 }
4623 }
4624
4625 format!("file://{encoded}")
4626}
4627
4628fn file_row_view(file: &FileRecord) -> FileRow {
4629 FileRow {
4630 relative_path: file.relative_path.clone(),
4631 language: file.language.map_or_else(
4632 || "-".into(),
4633 |language| language.display_name().to_string(),
4634 ),
4635 total_physical_lines: file.raw_line_categories.total_physical_lines,
4636 code_lines: file.effective_counts.code_lines,
4637 comment_lines: file.effective_counts.comment_lines,
4638 blank_lines: file.effective_counts.blank_lines,
4639 mixed_lines_separate: file.effective_counts.mixed_lines_separate,
4640 functions: file.raw_line_categories.functions,
4641 classes: file.raw_line_categories.classes,
4642 variables: file.raw_line_categories.variables,
4643 imports: file.raw_line_categories.imports,
4644 test_count: file.raw_line_categories.test_count,
4645 test_assertion_count: file.raw_line_categories.test_assertion_count,
4646 test_suite_count: file.raw_line_categories.test_suite_count,
4647 line_cov_pct: file
4648 .coverage
4649 .as_ref()
4650 .map(|c| format!("{:.1}", c.line_pct()))
4651 .unwrap_or_default(),
4652 fn_cov_pct: file
4653 .coverage
4654 .as_ref()
4655 .filter(|c| c.functions_found > 0)
4656 .map(|c| format!("{:.1}", c.function_pct()))
4657 .unwrap_or_default(),
4658 branch_cov_pct: file
4659 .coverage
4660 .as_ref()
4661 .filter(|c| c.branches_found > 0)
4662 .map(|c| format!("{:.1}", c.branch_pct()))
4663 .unwrap_or_default(),
4664 cov_lines_detail: file.coverage.as_ref().map_or_else(String::new, |c| {
4665 format!("{}/{}", c.lines_hit, c.lines_found)
4666 }),
4667 status: format!("{:?}", file.status),
4668 status_class: format!("{:?}", file.status).to_ascii_lowercase(),
4669 warnings: if file.warnings.is_empty() {
4670 String::new()
4671 } else {
4672 file.warnings.join("; ")
4673 },
4674 }
4675}
4676
4677fn is_pacific_dst_report(dt: DateTime<Utc>) -> bool {
4678 use chrono::{Datelike, NaiveDate, NaiveTime, TimeZone, Weekday};
4679 let year = dt.year();
4680 let nth_sun = |month: u32, n: u32, hour: u32| {
4681 let mut count = 0u32;
4682 let mut day = 1u32;
4683 loop {
4684 let d = NaiveDate::from_ymd_opt(year, month, day).expect("valid");
4685 if d.weekday() == Weekday::Sun {
4686 count += 1;
4687 if count == n {
4688 return Utc.from_utc_datetime(
4689 &d.and_time(NaiveTime::from_hms_opt(hour, 0, 0).expect("valid")),
4690 );
4691 }
4692 }
4693 day += 1;
4694 }
4695 };
4696 let dst_start = nth_sun(3, 2, 10);
4697 let dst_end = nth_sun(11, 1, 9);
4698 dt >= dst_start && dt < dst_end
4699}
4700
4701fn to_pst_display(dt: DateTime<Utc>) -> String {
4702 let (offset, label) = if is_pacific_dst_report(dt) {
4703 (
4704 FixedOffset::west_opt(7 * 3600).expect("valid PDT offset"),
4705 "PDT",
4706 )
4707 } else {
4708 (
4709 FixedOffset::west_opt(8 * 3600).expect("valid PST offset"),
4710 "PST",
4711 )
4712 };
4713 format!(
4714 "{} {label}",
4715 dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
4716 )
4717}
4718
4719fn to_pt_hhmm(dt: DateTime<Utc>) -> String {
4721 let (offset, label) = if is_pacific_dst_report(dt) {
4722 (
4723 FixedOffset::west_opt(7 * 3600).expect("valid PDT offset"),
4724 "PDT",
4725 )
4726 } else {
4727 (
4728 FixedOffset::west_opt(8 * 3600).expect("valid PST offset"),
4729 "PST",
4730 )
4731 };
4732 format!(
4733 "{} {label}",
4734 dt.with_timezone(&offset).format("%Y-%m-%d %H:%M")
4735 )
4736}
4737
4738fn fmt_commit_date_pt(s: &str) -> String {
4741 use chrono::DateTime as ChronoDateTime;
4742 ChronoDateTime::parse_from_rfc3339(s).map_or_else(
4743 |_| s.replace('T', " "),
4744 |dt| to_pt_hhmm(dt.with_timezone(&Utc)),
4745 )
4746}
4747
4748fn build_warning_console(warnings: &[String]) -> String {
4749 if warnings.is_empty() {
4750 return "No top-level warnings.".to_string();
4751 }
4752
4753 warnings
4754 .iter()
4755 .enumerate()
4756 .map(|(index, warning)| {
4757 format!(
4758 "[{index:03}] {warning}",
4759 index = index + 1,
4760 warning = warning
4761 )
4762 })
4763 .collect::<Vec<_>>()
4764 .join("\n")
4765}
4766
4767fn summarize_warnings(warnings: &[String]) -> Vec<WarningSummaryRow> {
4768 let mut counts: BTreeMap<&'static str, usize> = BTreeMap::new();
4769 for warning in warnings {
4770 let key = if warning.contains("unsupported or undetected language") {
4771 "Unsupported or undetected text formats"
4772 } else if warning.contains("file exceeded max_file_size_bytes") {
4773 "Large files skipped by size limit"
4774 } else if warning.contains("binary file skipped by default") {
4775 "Binary assets skipped"
4776 } else if warning.contains("minified file skipped by policy") {
4777 "Minified files skipped by policy"
4778 } else if warning.contains("vendor file skipped by policy") {
4779 "Vendor files skipped by policy"
4780 } else if warning.contains("best effort") || warning.contains("unclosed string literal") {
4781 "Best-effort parse results"
4782 } else {
4783 "Other warnings"
4784 };
4785 *counts.entry(key).or_default() += 1;
4786 }
4787
4788 counts
4789 .into_iter()
4790 .map(|(label, count)| {
4791 let (tone_class, detail) = match label {
4792 "Unsupported or undetected text formats" => (
4793 "tone-neutral",
4794 "These are usually docs, manifests, templates, or formats that have not been promoted into first-class analyzers yet.",
4795 ),
4796 "Large files skipped by size limit" => (
4797 "tone-warn",
4798 "Artifacts and archives larger than the configured cap were skipped intentionally to keep runs fast and predictable.",
4799 ),
4800 "Binary assets skipped" => (
4801 "tone-neutral",
4802 "Binary bundles are excluded from source counting unless you explicitly opt into them.",
4803 ),
4804 "Minified files skipped by policy" => (
4805 "tone-warn",
4806 "Generated and minified assets are being filtered out to avoid inflating code totals.",
4807 ),
4808 "Vendor files skipped by policy" => (
4809 "tone-neutral",
4810 "Vendored third-party code is being excluded so the report stays focused on repository-owned source.",
4811 ),
4812 "Best-effort parse results" => (
4813 "tone-danger",
4814 "These files were analyzed, but the parser hit malformed or ambiguous content and fell back to a best-effort count.",
4815 ),
4816 _ => (
4817 "tone-danger",
4818 "Warnings in this bucket need manual review because they do not match one of the common policy-based skip reasons.",
4819 ),
4820 };
4821
4822 WarningSummaryRow {
4823 label: label.to_string(),
4824 count,
4825 tone_class: tone_class.to_string(),
4826 detail: detail.to_string(),
4827 }
4828 })
4829 .collect()
4830}
4831
4832fn classify_unsupported_path(path: &str) -> &'static str {
4834 let ext_lc = Path::new(path)
4835 .extension()
4836 .and_then(|e| e.to_str())
4837 .map(str::to_ascii_lowercase)
4838 .unwrap_or_default();
4839
4840 if ext_lc == "md"
4841 || path.ends_with("README")
4842 || path.ends_with("README.md")
4843 || path.ends_with("LICENSE")
4844 {
4845 "Documentation / text"
4846 } else if ext_lc == "json" || path.ends_with(".spdx.json") || path.ends_with("devkit.json") {
4847 "JSON manifests and config"
4848 } else if ext_lc == "toml"
4849 || path.ends_with("MANIFEST.in")
4850 || path.ends_with("requirements.txt")
4851 {
4852 "Project metadata and packaging"
4853 } else if ext_lc == "html" {
4854 "HTML templates"
4855 } else if ext_lc == "txt" {
4856 "Plain text assets"
4857 } else if ext_lc.is_empty() {
4858 "Extensionless or custom text files"
4859 } else {
4860 "Other unsupported text formats"
4861 }
4862}
4863
4864fn bucket_recommendation(label: &str) -> String {
4866 match label {
4867 "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(),
4868 "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(),
4869 "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(),
4870 "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(),
4871 "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(),
4872 "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(),
4873 _ => "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(),
4874 }
4875}
4876
4877fn bucket_description(label: &str) -> String {
4879 match label {
4880 "Documentation / text" => {
4881 "README, LICENSE, and markdown files — not source code.".to_string()
4882 }
4883 "JSON manifests and config" => {
4884 "JSON configuration or manifest files (package.json, lockfiles, etc.).".to_string()
4885 }
4886 "Project metadata and packaging" => {
4887 "TOML, requirements.txt, and MANIFEST.in files that describe package metadata."
4888 .to_string()
4889 }
4890 "HTML templates" => {
4891 "HTML files not covered by the built-in HTML analyzer (unexpected extension or path)."
4892 .to_string()
4893 }
4894 "Plain text assets" => "Plain .txt files that have no analyzable structure.".to_string(),
4895 "Extensionless or custom text files" => {
4896 "Files with no extension that could not be language-detected.".to_string()
4897 }
4898 _ => "Files with an unrecognized extension or format.".to_string(),
4899 }
4900}
4901
4902fn build_support_opportunities(warnings: &[String]) -> Vec<WarningOpportunityRow> {
4903 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
4904 let mut examples: BTreeMap<String, Vec<String>> = BTreeMap::new();
4905
4906 for warning in warnings {
4907 if !warning.contains("unsupported or undetected language") {
4908 continue;
4909 }
4910
4911 let path = warning
4912 .split_once(':')
4913 .map(|(path, _)| path.trim())
4914 .unwrap_or_default();
4915 if path.is_empty() {
4916 continue;
4917 }
4918
4919 let bucket = classify_unsupported_path(path);
4920 *counts.entry(bucket.to_string()).or_default() += 1;
4921
4922 let ex = examples.entry(bucket.to_string()).or_default();
4923 if ex.len() < 3 {
4924 let basename = Path::new(path)
4925 .file_name()
4926 .and_then(|n| n.to_str())
4927 .unwrap_or(path)
4928 .to_string();
4929 if !ex.contains(&basename) {
4930 ex.push(basename);
4931 }
4932 }
4933 }
4934
4935 let mut rows = counts.into_iter().collect::<Vec<_>>();
4936 rows.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
4937
4938 rows.into_iter()
4939 .map(|(label, count)| {
4940 let recommendation = bucket_recommendation(&label);
4941 let bucket_description = bucket_description(&label);
4942 let example_files = examples.remove(&label).unwrap_or_default();
4943 WarningOpportunityRow {
4944 label,
4945 count,
4946 recommendation,
4947 example_files,
4948 bucket_description,
4949 }
4950 })
4951 .collect()
4952}
4953
4954#[derive(Debug, Clone)]
4955struct LanguageRow {
4956 language: String,
4957 files: u64,
4958 total_physical_lines: u64,
4959 code_lines: u64,
4960 comment_lines: u64,
4961 blank_lines: u64,
4962 mixed_lines_separate: u64,
4963 functions: u64,
4964 classes: u64,
4965 variables: u64,
4966 imports: u64,
4967 test_count: u64,
4968 test_assertion_count: u64,
4969 test_suite_count: u64,
4970 test_density_str: String,
4971}
4972
4973#[derive(Debug, Clone)]
4974struct FileRow {
4975 relative_path: String,
4976 language: String,
4977 total_physical_lines: u64,
4978 code_lines: u64,
4979 comment_lines: u64,
4980 blank_lines: u64,
4981 mixed_lines_separate: u64,
4982 functions: u64,
4983 classes: u64,
4984 variables: u64,
4985 imports: u64,
4986 test_count: u64,
4987 test_assertion_count: u64,
4988 test_suite_count: u64,
4989 line_cov_pct: String,
4991 fn_cov_pct: String,
4993 branch_cov_pct: String,
4995 cov_lines_detail: String,
4997 status: String,
4998 status_class: String,
4999 warnings: String,
5000}
5001
5002#[derive(Debug, Clone)]
5003struct WarningSummaryRow {
5004 label: String,
5005 count: usize,
5006 tone_class: String,
5007 detail: String,
5008}
5009
5010#[derive(Debug, Clone)]
5011struct WarningOpportunityRow {
5012 label: String,
5013 count: usize,
5014 recommendation: String,
5015 example_files: Vec<String>,
5017 bucket_description: String,
5019}
5020
5021#[derive(Template)]
5022#[template(
5023 source = r##"<!doctype html>
5024<html lang="en">
5025<head>
5026 <meta charset="utf-8" />
5027 <meta name="viewport" content="width=device-width, initial-scale=1" />
5028 <title>{{ browser_title }}</title>
5029 <link rel="icon" href="{{ small_logo_uri }}" type="image/png" />
5030 <style nonce="{{ nonce }}">
5031 :root {
5032 --radius: 18px;
5033 --bg: #f5efe8;
5034 --surface: rgba(255,255,255,0.82);
5035 --surface-2: #fbf7f2;
5036 --surface-3: #efe6dc;
5037 --line: #e6d0bf;
5038 --line-strong: #dcb89f;
5039 --text: #43342d;
5040 --muted: #7b675b;
5041 --muted-2: #a08777;
5042 --nav: #b85d33;
5043 --nav-2: #7a371b;
5044 --accent: #6f9bff;
5045 --accent-2: #4a78ee;
5046 --oxide: #d37a4c;
5047 --oxide-2: #b35428;
5048 --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
5049 --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
5050 --good-bg: #e8f5ed;
5051 --good-text: #1a8f47;
5052 --warn-bg: #fff4dc;
5053 --warn-text: #9a6d00;
5054 --danger-bg: #fdebec;
5055 --danger-text: #cc4b4b;
5056 --info-bg: #fbf1e8;
5057 --info-text: #a5541f;
5058 }
5059 {% if let Some(hex) = accent_hex %}
5060 :root, body.dark-theme { --accent: {{ hex }}; --accent-2: {{ hex }}; }
5061 {% endif %}
5062 body.dark-theme {
5063 --bg: #1b1511;
5064 --surface: #261c17;
5065 --surface-2: #2d221d;
5066 --surface-3: #372922;
5067 --line: #524238;
5068 --line-strong: #6c5649;
5069 --text: #f5ece6;
5070 --muted: #c7b7aa;
5071 --muted-2: #aa9485;
5072 --nav: #b85d33;
5073 --nav-2: #7a371b;
5074 --accent: #6f9bff;
5075 --accent-2: #4a78ee;
5076 --oxide: #d37a4c;
5077 --oxide-2: #b35428;
5078 --shadow: 0 18px 42px rgba(0,0,0,0.28);
5079 --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
5080 --good-bg: #163927;
5081 --good-text: #8fe2a8;
5082 --warn-bg: #3c2d11;
5083 --warn-text: #f3cb75;
5084 --danger-bg: #3d1f1f;
5085 --danger-text: #ff9f9f;
5086 --info-bg: #33241b;
5087 --info-text: #e6a879;
5088 }
5089 * { box-sizing: border-box; }
5090 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); }
5091 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; }
5092 .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); }
5093 .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; }
5094 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; flex: 0 0 auto; }
5095 .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)); }
5096 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
5097 .background-watermarks img { position: absolute; opacity: 0.15; filter: blur(0.3px); user-select: none; max-width: none; }
5098 .code-particles { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
5099 .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; }
5100 @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)); } }
5101 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
5102 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; letter-spacing: -0.01em; }
5103 .brand-subtitle { color: rgba(255,255,255,0.72); font-size: 11px; line-height: 1.2; margin-top: 2px; letter-spacing: 0.01em; }
5104 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
5105 .nav-project-pill, .nav-pill, .theme-toggle, .header-button {
5106 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;
5107 }
5108 .nav-project-pill { pointer-events: auto; width: 100%; max-width: 300px; justify-content: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
5109 .nav-project-label { color: rgba(255,255,255,0.72); text-transform: uppercase; letter-spacing: 0.09em; font-size: 10px; font-weight: 800; }
5110 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size: 13px; }
5111 .nav-status { display:flex; align-items:center; justify-content:flex-end; gap:10px; flex-wrap:nowrap; min-width:0; }
5112 @media (max-width: 1400px) { .nav-status { gap: 6px; } .header-button, .theme-toggle { padding: 0 10px; } }
5113 @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; } }
5114 .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; }
5115 .theme-toggle:hover, .header-button:hover { background: rgba(255,255,255,0.18); transform: translateY(-1px); }
5116 .theme-toggle { width: 38px; justify-content:center; padding:0; }
5117 .nav-dropdown-wrap { position: relative; }
5118 .nav-dropdown-wrap::after { content: ''; position: absolute; left: 0; right: 0; bottom: -6px; height: 6px; }
5119 .nav-dropdown-trigger { }
5120 .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); }
5121 .nav-dropdown-wrap:hover .nav-dropdown-menu, .nav-dropdown-wrap:focus-within .nav-dropdown-menu { display: flex; flex-direction: column; gap: 2px; }
5122 .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; }
5123 .nav-dropdown-item:hover { background: rgba(255,255,255,0.12); }
5124 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
5125 .theme-toggle .icon-sun { display:none; }
5126 body.dark-theme .theme-toggle .icon-sun { display:block; }
5127 body.dark-theme .theme-toggle .icon-moon { display:none; }
5128 .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;}
5129 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
5130 .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);}
5131 .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;}
5132 .settings-close:hover{color:var(--text);background:var(--surface-2);}
5133 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
5134 .settings-modal-body{padding:14px 16px 16px;}
5135 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
5136 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
5137 .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;}
5138 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
5139 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
5140 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
5141 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
5142 .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;}
5143 .tz-select:focus{border-color:var(--oxide);}
5144 .page { max-width: 1720px; margin: 0 auto; padding: 32px 24px 40px; }
5145 @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
5146 /* Uniform two-row card strip. JS pads the card count to even (revealing a
5147 reserve card when odd) and sets the column count to n/2, so the cards form
5148 exactly two full rows with every column aligned and every card the same
5149 width — no oversized card, no empty trailing cell. */
5150 .summary-grid { display:grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap:10px; align-items:stretch; }
5151 .panel, .metric, .warning-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
5152 .panel { padding: 20px; }
5153 .metric { padding: 11px 12px 20px; position: relative; cursor: help; transition: transform 0.15s ease, box-shadow 0.15s ease; min-height: 70px; }
5154 .metric:hover { transform: translateY(-3px); box-shadow: var(--shadow-strong); }
5155 .metric-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); }
5156 .section-kicker { font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted-2); }
5157 .metric-value { margin-top: 6px; }
5158 .metric-big { display:block; font-size: 20px; font-weight: 900; color: var(--oxide); line-height: 1.15; letter-spacing: -0.02em; }
5159 .metric-exact { position: absolute; bottom: 6px; right: 10px; font-size: 12px; font-weight: 600; color: var(--muted); font-family: ui-monospace, monospace; }
5160 .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); }
5161 .metric-tooltip::after { content: ''; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); border: 5px solid transparent; border-top-color: var(--text); }
5162 .metric:hover .metric-tooltip { opacity: 1; transform: translateX(-50%) translateY(0); }
5163 .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); }
5164 .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:16px; }
5165 .hero h1 { margin:0 0 8px; font-size: 28px; letter-spacing: -0.04em; }
5166 .run-id-row { display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap:10px; margin-top:16px; }
5167 @media(max-width:960px) { .run-id-row { grid-template-columns: 1fr 1fr; } }
5168 @media(max-width:560px) { .run-id-row { grid-template-columns: 1fr; } }
5169 .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; }
5170 .run-id-chip[data-copy] { cursor:pointer; }
5171 a.run-id-chip-link { text-decoration:none; cursor:pointer; }
5172 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); }
5173 .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
5174 .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
5175 .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; }
5176 .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
5177 .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
5178 .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
5179 .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; }
5180 body.dark-theme .submodule-state-badge { color:var(--accent); background:rgba(111,155,255,0.13); border-color:rgba(111,155,255,0.28); }
5181 .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; }
5182 .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
5183 .run-id-chip:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
5184 a.run-id-chip-link:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
5185 .chip-copy-icon { display:inline-block; margin-left:5px; font-size:10px; opacity:0.55; vertical-align:middle; }
5186 .chip-label-icon { display:inline-block; vertical-align:middle; margin-right:3px; margin-top:-1px; opacity:0.8; }
5187 .chip-popout-icon { display:inline-block; vertical-align:middle; margin-left:4px; opacity:0.6; flex-shrink:0; }
5188 .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; }
5189 body.dark-theme .run-id-short-badge { color:var(--muted-2); }
5190 .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
5191 @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
5192 .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
5193 .subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
5194 .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%; }
5195 .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; }
5196 .meta-chip:last-child { border-right:none; }
5197 .meta-chip b { color:var(--text); font-weight:700; }
5198 .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); }
5199 body.dark-theme .prev-scan-banner { box-shadow:0 4px 16px rgba(0,0,0,0.3); }
5200 .prev-scan-banner-empty { flex-direction:row; align-items:center; gap:8px; font-size:13px; color:var(--muted); font-style:italic; }
5201 .prev-scan-banner-top { display:flex; flex-direction:column; gap:4px; }
5202 .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; }
5203 .prev-scan-ts { font-weight:500; text-transform:none; letter-spacing:0; color:var(--muted); }
5204 .prev-scan-count { font-weight:500; text-transform:none; letter-spacing:0; color:var(--muted); }
5205 .prev-scan-summary { font-size:13px; font-weight:600; color:var(--text); }
5206 .prev-scan-summary b { font-weight:900; }
5207 .delta-neutral-text { color:var(--muted); }
5208 .delta-up { color:#2a6846; }
5209 .delta-down { color:#b23030; }
5210 body.dark-theme .delta-up { color:#5aba8a; }
5211 body.dark-theme .delta-down { color:#e07070; }
5212 .delta-card-row { display:grid; grid-template-columns:repeat(7,1fr); gap:12px; width:100%; }
5213 @media(max-width:1000px){ .delta-card-row { grid-template-columns:repeat(4,1fr); } }
5214 @media(max-width:540px){ .delta-card-row { grid-template-columns:repeat(2,1fr); } }
5215 .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); }
5216 .delta-card-inline:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.22); z-index:10; }
5217 body.dark-theme .delta-card-inline { box-shadow:0 2px 8px rgba(0,0,0,0.2); }
5218 body.dark-theme .delta-card-inline:hover { box-shadow:0 12px 32px rgba(0,0,0,0.55); }
5219 .delta-card-val { font-size:20px; font-weight:900; color:var(--oxide); line-height:1.2; }
5220 .delta-card-val.pos { color:#2a6846; }
5221 .delta-card-val.neg { color:#b23030; }
5222 .delta-card-val.mod { color:#7a5a10; }
5223 body.dark-theme .delta-card-val.pos { color:#5aba8a; }
5224 body.dark-theme .delta-card-val.neg { color:#e07070; }
5225 body.dark-theme .delta-card-val.mod { color:#d4a843; }
5226 .delta-card-lbl { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-top:4px; }
5227 .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); }
5228 .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
5229 .delta-card-inline:hover .delta-card-tip { opacity:1; transform:translateX(-50%) translateY(0); }
5230 .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); }
5231 .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); }
5232 body.dark-theme .delta-panel-link { box-shadow:0 2px 6px rgba(0,0,0,0.2); }
5233 body.dark-theme .delta-panel-link:hover { box-shadow:0 6px 18px rgba(0,0,0,0.45); }
5234 .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; }
5235 .toolbar { display:flex; flex-wrap:wrap; justify-content:space-between; gap: 12px; align-items: center; margin-bottom: 16px; }
5236 .toolbar-left { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
5237 .search { min-width: 280px; padding: 10px 12px; border-radius: 10px; border:1px solid var(--line-strong); background: var(--surface-2); color:var(--text); }
5238 .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
5239 .pill { padding: 6px 10px; border-radius: 999px; border:1px solid var(--line); background: var(--surface-2); font-size: 12px; font-weight: 700; }
5240 .pill.good { background: var(--good-bg); color: var(--good-text); }
5241 .pill.info { background: var(--info-bg); color: var(--info-text); }
5242 .export-group { display:flex; gap:6px; align-items:center; }
5243 .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; }
5244 .export-btn:hover { background:var(--accent); color:#fff; border-color:var(--accent); }
5245 .page-size-row { display:flex; align-items:center; gap:6px; }
5246 .page-size-label { font-size:13px; color:var(--muted); font-weight:600; }
5247 .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; }
5248 .page-count-label { font-size:12px; color:var(--muted); white-space:nowrap; }
5249 .pagination-bar { display:flex; align-items:center; justify-content:center; gap:14px; padding:10px 0 2px; }
5250 .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; }
5251 .pager-btn:hover:not(:disabled) { background:var(--accent); color:#fff; border-color:var(--accent); }
5252 .pager-btn:disabled { opacity:.4; cursor:default; }
5253 .pager-info { font-size:13px; color:var(--muted); font-weight:600; min-width:120px; text-align:center; }
5254 .pager-edge { font-size:12px; padding:5px 10px; }
5255 .pager-jump-wrap { font-size:13px; color:var(--muted); font-weight:600; display:flex; align-items:center; gap:5px; white-space:nowrap; }
5256 .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; }
5257 .pager-jump::-webkit-inner-spin-button,.pager-jump::-webkit-outer-spin-button { -webkit-appearance:none; margin:0; }
5258 .table-shell { border: 1px solid var(--line); border-radius: 16px; overflow: auto; background: var(--surface-2); max-height: 900px; }
5259 /* Clip wrapper: hides the scrollbar track that hangs 8px past the right edge */
5260 .table-shell-clip { overflow: hidden !important; max-height: none !important; }
5261 /* Skipped-files scroll pane: auto so no phantom space when content is short */
5262 #skipped-shell { overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; scrollbar-color: var(--line-strong) var(--surface-2); }
5263 #per-file-table tbody tr:last-child td, #skipped-table tbody tr:last-child td { border-bottom: none; }
5264 #skipped-shell::-webkit-scrollbar { width: 8px; }
5265 #skipped-shell::-webkit-scrollbar-track { background: var(--surface-2); }
5266 #skipped-shell::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 4px; }
5267 table { width: 100%; border-collapse: collapse; font-size: 14px; }
5268 th, td { text-align: left; padding: 11px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
5269 th { color: var(--muted); font-weight: 800; background: var(--surface-2); cursor: pointer; position: sticky; top: 0; z-index: 1; white-space: nowrap; }
5270 /* Per-file detail table — auto layout so File column sizes to content */
5271 .table-resizable { table-layout: auto; }
5272 .table-resizable th { position: sticky; top: 0; z-index: 2; overflow: hidden; white-space: nowrap; min-width: 52px; }
5273 .table-resizable td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
5274 .table-resizable td.mono { overflow: visible; text-overflow: unset; white-space: nowrap; }
5275 #skipped-table { table-layout: fixed; width: 100%; }
5276 #skipped-table th, #skipped-table td { padding: 7px 8px; }
5277 #skipped-table td:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
5278 /* Hotspots table — overflow visible so header tooltips can escape the cell */
5279 #hotspots-table th { overflow: visible; }
5280 .hs-hint { color: var(--muted); font-style: italic; }
5281 .section-desc { font-size:13px; color:var(--muted); line-height:1.6; margin:0 0 4px; padding:0 4px; }
5282 .table-hint { font-size:12px; color:var(--muted); margin:0 0 12px; padding:0 4px; }
5283 .own-files-title { font-size:18px; font-weight:850; letter-spacing:-0.02em; color:var(--text); margin:26px 0 4px; }
5284 .own-files-sub { font-size:13px; color:var(--muted); line-height:1.55; margin:0 0 16px; padding:0 2px; }
5285 .own-details { border:1px solid var(--line); border-radius:14px; margin-bottom:10px; background:var(--surface-2); overflow:hidden; transition:box-shadow .2s ease, transform .2s ease; }
5286 .own-details:hover { box-shadow:0 8px 24px rgba(77,44,20,0.13); transform:translateY(-1px); }
5287 .own-details > summary { cursor:pointer; padding:14px 18px; list-style:none; display:flex; align-items:center; gap:14px; }
5288 .own-details > summary::-webkit-details-marker { display:none; }
5289 .own-details > summary::before { content:'\25B6'; color:var(--muted); font-size:10px; transition:transform .15s ease; flex:0 0 auto; }
5290 .own-details[open] > summary::before { transform:rotate(90deg); }
5291 .lb-rank { flex:0 0 auto; min-width:30px; height:30px; padding:0 6px; display:inline-flex; align-items:center; justify-content:center; border-radius:50%; font-weight:800; font-size:14px; color:var(--muted); background:var(--surface-3); border:1px solid var(--line); font-variant-numeric:tabular-nums; }
5292 .lb-r1 { background:linear-gradient(135deg,#f7d774,#e0a92e); color:#5a3d00; border-color:#e0a92e; box-shadow:0 3px 10px rgba(224,169,46,0.45); }
5293 .lb-r2 { background:linear-gradient(135deg,#e6e7ea,#b9bcc4); color:#3d4048; border-color:#b9bcc4; box-shadow:0 3px 10px rgba(150,153,160,0.35); }
5294 .lb-r3 { background:linear-gradient(135deg,#eabd92,#cd7f4c); color:#4d2a0e; border-color:#cd7f4c; box-shadow:0 3px 10px rgba(205,127,76,0.35); }
5295 .lb-avatar { flex:0 0 auto; width:36px; height:36px; border-radius:50%; display:inline-flex; align-items:center; justify-content:center; color:#fff; font-weight:800; font-size:14px; text-shadow:0 1px 2px rgba(0,0,0,0.25); }
5296 .lb-name { flex:0 0 210px; font-size:16px; font-weight:800; color:var(--text); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
5297 /* Contributor profile links (ownership table, leaderboard, hotspot owner column) — inherit the
5298 surrounding weight/size, tint oxide, underline only on hover. */
5299 a.author-link { color:var(--oxide); text-decoration:none; border-bottom:1px solid transparent; transition:border-color 0.15s ease; }
5300 a.author-link:hover { border-bottom-color:var(--oxide); }
5301 .lb-name a.author-link { color:inherit; }
5302 .lb-name a.author-link:hover { color:var(--oxide); }
5303 .lb-bar-wrap { flex:1 1 auto; height:12px; min-width:60px; background:var(--surface-3); border-radius:7px; overflow:hidden; box-shadow:inset 0 1px 2px rgba(0,0,0,0.08); }
5304 .lb-bar { display:block; height:100%; border-radius:7px; transition:width .5s cubic-bezier(.16,1,.3,1); }
5305 .lb-stats { flex:0 0 auto; font-size:13px; color:var(--muted); white-space:nowrap; font-variant-numeric:tabular-nums; }
5306 .lb-stats strong { color:var(--oxide); font-size:16px; font-weight:900; }
5307 .own-files-shell { margin:2px 14px 14px; max-height:420px; }
5308 @media (max-width:760px) { .lb-name { flex-basis:120px; font-size:14px; } .lb-bar-wrap { display:none; } .lb-stats { font-size:12px; } }
5309 /* Column-header explainer tooltip (shared visual language with .stat-chip-tip) */
5310 .col-tip { position: absolute; top: calc(100% + 9px); left: 0; z-index: 60; width: max-content; max-width: 270px;
5311 background: var(--text); color: var(--bg); padding: 9px 12px; border-radius: 9px;
5312 font-size: 11.5px; font-weight: 500; line-height: 1.5; letter-spacing: normal; text-transform: none;
5313 white-space: normal; text-align: left; box-shadow: 0 10px 30px rgba(0,0,0,0.22);
5314 opacity: 0; pointer-events: none; transition: opacity .18s ease; }
5315 .col-tip.col-tip-r { left: auto; right: 0; }
5316 .col-tip strong { color: var(--bg); }
5317 .col-tip::after { content: ''; position: absolute; bottom: 100%; left: 16px;
5318 border: 6px solid transparent; border-bottom-color: var(--text); }
5319 .col-tip.col-tip-r::after { left: auto; right: 16px; }
5320 #hotspots-table th:hover .col-tip { opacity: 1; }
5321 /* Column resize handle */
5322 .col-resize-handle { position: absolute; top: 0; right: 0; bottom: 0; width: 6px; cursor: col-resize; z-index: 10; }
5323 .col-resize-handle:hover, .col-resize-handle.dragging { background: rgba(211,122,76,0.3); }
5324 #per-file-table { table-layout: fixed; width: 100%; min-width: 0; }
5325 #per-file-table th, #per-file-table td { padding: 8px 6px; }
5326 /* File column: pinned, truncates long paths */
5327 #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; }
5328 #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; }
5329 #per-file-table th:nth-child(2) { width: 6%; }
5330 /* 12 numeric columns share the remaining 68%: 26+6+12×5.67≈98% total */
5331 #per-file-table th:nth-child(n+3) { width: 5.67%; }
5332 /* Override mono class overflow so file paths truncate */
5333 #per-file-table td.mono { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
5334 #per-file-table tbody tr:hover td:first-child { background: rgba(255,247,238,0.6); }
5335 body.dark-theme #per-file-table tbody tr:hover td:first-child { background: rgba(255,255,255,0.03); }
5336 /* Language breakdown: auto layout with resizable columns — headers size to content */
5337 #lang-breakdown-table { width: 100%; min-width: 760px; }
5338 #lang-breakdown-table th, #lang-breakdown-table td { padding: 8px 6px; font-size: 13px; }
5339 /* Skipped table: extend truncation to all cells, not just the first column */
5340 #skipped-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 0; }
5341 /* Support opportunities table: fixed layout so Category/Count columns don't grow */
5342 .support-table { table-layout: fixed; width: 100%; }
5343 .support-table th:first-child { width: 20%; }
5344 .support-table th:nth-child(2) { width: 6%; }
5345 .support-table th:nth-child(3) { width: 24%; }
5346 .support-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 0; vertical-align: top; padding-top: 10px; padding-bottom: 10px; }
5347 /* Description and example columns must wrap — content can be long */
5348 .support-table td:nth-child(3) { white-space: normal; overflow: visible; text-overflow: unset; max-width: none; line-height: 1.45; }
5349 .support-table td:last-child { white-space: normal; overflow: visible; text-overflow: unset; max-width: none; }
5350 .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; }
5351 .support-recommendation { color: var(--muted); font-size: 11px; margin: 6px 0 0; line-height: 1.5; }
5352 .num-col { text-align: right !important; }
5353 /* Per-file coverage table: fixed layout keeps numeric columns compact and off the shell border */
5354 .cov-file-table { table-layout: fixed; }
5355 .cov-file-table th:not(:first-child), .cov-file-table td:not(:first-child) { width: 150px; }
5356 .cov-file-table th.num-col, .cov-file-table td.num-col { padding-right: 16px; }
5357 tbody tr:hover { background: rgba(255, 247, 238, 0.6); }
5358 body.dark-theme tbody tr:hover { background: rgba(255,255,255,0.03); }
5359 tr:last-child td { border-bottom: none; }
5360 .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
5361 .small { color: var(--muted); font-size: 13px; }
5362 .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; }
5363 .status-analyzedexact { background: var(--good-bg); color: var(--good-text); border-color: rgba(28,135,70,0.18); }
5364 .status-analyzedbesteffort, .status-skippedbypolicy { background: var(--warn-bg); color: var(--warn-text); border-color: rgba(146,96,0,0.18); }
5365 .status-skippedunsupported, .status-skippedbinary { background: var(--danger-bg); color: var(--danger-text); border-color: rgba(179,59,59,0.18); }
5366 .stack { display:grid; gap:22px; }
5367 .summary-strip { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin-bottom:18px; }
5368 @media(max-width:800px) { .summary-strip { grid-template-columns:repeat(2,1fr) !important; } }
5369 .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); }
5370 .test-density-num { font-size:22px; font-weight:900; color:var(--oxide); line-height:1; }
5371 .test-density-meta { display:flex; flex-direction:column; gap:2px; }
5372 .test-density-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); }
5373 .test-density-sub { font-size:11px; color:var(--muted-2); }
5374 .test-density-badge { margin-left:auto; padding:4px 12px; border-radius:999px; font-size:12px; font-weight:700; }
5375 .test-density-badge.good { background:var(--good-bg); color:var(--good-text); }
5376 .test-density-badge.warn { background:var(--warn-bg); color:var(--warn-text); }
5377 .test-density-badge.danger { background:var(--danger-bg); color:var(--danger-text); }
5378 .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(196,92,16,0.20); color:var(--info-text); font-size:13px; line-height:1.5; }
5379 .info-callout-icon { flex:0 0 auto; font-size:15px; margin-top:1px; }
5380 .info-callout code { background:rgba(196,92,16,0.12); border-radius:4px; padding:1px 5px; font-size:12px; }
5381 body.dark-theme .info-callout { background:rgba(211,122,76,0.10); border-color:rgba(211,122,76,0.25); }
5382 .empty-state-row td { text-align:center; padding:20px; color:var(--muted-2); font-size:13px; font-style:italic; }
5383 /* auto-fit so a lone gauge card (line-only coverage) spans the full width instead of 1/3 */
5384 .cov-gauge-row { display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:16px; margin-bottom:18px; }
5385 @media(max-width:700px) { .cov-gauge-row { grid-template-columns:1fr; } }
5386 .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; }
5387 .cov-gauge-card:hover { transform:translateY(-3px); box-shadow:0 10px 28px rgba(77,44,20,0.15); z-index:10; }
5388 .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); }
5389 .cov-gauge-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
5390 .cov-gauge-card:hover .cov-gauge-tip { opacity:1; transform:translateX(-50%) translateY(0); }
5391 .cov-gauge-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); }
5392 .cov-gauge-val { font-size:32px; font-weight:900; line-height:1; }
5393 .cov-gauge-track { height:8px; border-radius:4px; background:var(--line); overflow:hidden; }
5394 .cov-gauge-fill { height:100%; border-radius:4px; transition:width .5s ease; }
5395 .cov-gauge-sub { font-size:11px; color:var(--muted); }
5396 .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); }
5397 .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
5398 .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
5399 .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-top:4px; }
5400 .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); }
5401 .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
5402 .stat-chip:hover .stat-chip-tip { opacity:1; transform:translateX(-50%) translateY(0); }
5403 .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; }
5404 .cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
5405 .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); }
5406 .cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
5407 .cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; transform:translateY(0); }
5408 .report-stack { display:grid; gap: 18px; align-items:start; }
5409 pre { background: var(--surface-2); border: 1px solid var(--line); border-radius: 16px; padding: 16px; overflow: auto; font-size: 12px; color: var(--text); }
5410 .warn-list { margin: 0; padding-left: 18px; line-height: 1.6; }
5411 .sort-indicator { color: var(--muted-2); font-size: 11px; margin-left: 6px; }
5412 .warning-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
5413 .warning-card { padding: 10px 12px; }
5414 .warning-card h3 { margin: 0 0 4px; font-size: 12px; font-weight: 700; }
5415 .warning-card .count { font-size: 16px; font-weight: 800; margin-bottom: 4px; }
5416 .tone-neutral .count { color: var(--text); }
5417 .tone-warn .count { color: var(--warn-text); }
5418 .tone-danger .count { color: var(--danger-text); }
5419 .tone-neutral .warning-count { color: var(--oxide); }
5420 .tone-warn .warning-count { color: var(--warn-text); }
5421 .tone-danger .warning-count { color: var(--danger-text); }
5422 .support-note { color: var(--muted); font-size: 11px; line-height: 1.45; }
5423 .support-table th { cursor: default; }
5424 details { border: 1px solid var(--line); border-radius: 14px; background: var(--surface-2); }
5425 summary { cursor: pointer; padding: 14px 16px; font-weight: 700; }
5426 details > div { padding: 0 16px 16px; }
5427 .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; }
5428 .warning-console-actions { display:flex; gap:10px; flex-wrap:wrap; margin-top: 12px; }
5429 .warning-console.hidden { display:none; }
5430 @media (max-width: 1200px) {
5431 .warning-grid { grid-template-columns: 1fr 1fr; }
5432 }
5433 @media (max-width: 960px) {
5434 .top-nav-inner { grid-template-columns: 1fr; }
5435 .nav-project-slot, .nav-status { justify-content:flex-start; }
5436 .warning-grid, .report-stack { grid-template-columns: 1fr; }
5437 .hero-top { flex-direction: column; }
5438 .search { min-width: 100%; width: 100%; }
5439 }
5440 @media (max-width: 640px) {
5441 .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
5442 }
5443 /* ── Report header / footer identification banner ─────────────────── */
5444 .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; }
5445 .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; }
5446 body.has-report-banner .top-nav { top: 27px; }
5447 body.has-report-banner { padding-bottom: 27px; }
5448 /* ── Print & PDF export ──────────────────────────────────────────── */
5449 @page { size: A4 landscape; margin: 0.35in 0.5in; }
5450
5451 @media print {
5452 *, *::before, *::after {
5453 -webkit-print-color-adjust: exact !important;
5454 print-color-adjust: exact !important;
5455 box-sizing: border-box !important;
5456 }
5457
5458 html, body {
5459 background: #f5efe8 !important;
5460 min-height: auto !important;
5461 width: 100% !important;
5462 }
5463
5464 /* Report id banner — fixed position repeats the banner on every printed page */
5465 .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; }
5466 .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; }
5467 body.has-report-banner .top-nav { top: 0 !important; }
5468 body.has-report-banner { padding-bottom: 0 !important; }
5469 /* Hide interactive UI-chrome; keep section heading text visible */
5470 .top-nav, .hero-actions,
5471 .background-watermarks, #code-particles,
5472 .header-button, .theme-toggle,
5473 .nav-dropdown-wrap, .config-actions,
5474 .warnings-show-link, .warning-console-actions,
5475 .toolbar .pill-row, .toolbar .export-group,
5476 input[type="search"], button { display: none !important; }
5477 /* Show toolbar as a plain block so h2 headings are visible */
5478 .toolbar { display: block !important; margin-bottom: 8px !important; }
5479 .toolbar-left { display: block !important; }
5480
5481 /* Remove page-level layout constraints */
5482 .page {
5483 max-width: none !important;
5484 width: 100% !important;
5485 padding: 0 !important;
5486 margin: 0 !important;
5487 }
5488
5489 .panel, .hero, .section,
5490 .saved-report-shell, .saved-panel, .report-shell, .stack {
5491 max-width: none !important;
5492 width: 100% !important;
5493 box-shadow: none !important;
5494 border: 1px solid #ddd !important;
5495 border-radius: 10px !important;
5496 margin-bottom: 10px !important;
5497 overflow: visible !important;
5498 }
5499
5500 /* Force grids to their full-width column counts regardless of viewport */
5501 .summary-grid {
5502 display: grid !important;
5503 grid-template-columns: repeat(5, minmax(0, 1fr)) !important;
5504 gap: 10px !important;
5505 }
5506
5507 .warning-grid {
5508 display: grid !important;
5509 grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
5510 gap: 8px !important;
5511 }
5512
5513 .report-stack {
5514 display: grid !important;
5515 gap: 12px !important;
5516 align-items: start !important;
5517 }
5518
5519 /* Metric cards */
5520 .metric {
5521 box-shadow: none !important;
5522 border: 1px solid #e0d0c0 !important;
5523 border-radius: 8px !important;
5524 break-inside: avoid !important;
5525 padding: 10px 12px 22px !important;
5526 min-height: 0 !important;
5527 }
5528
5529 .metric-big { font-size: 20px !important; }
5530 .metric-exact { font-size: 10px !important; bottom: 5px !important; right: 8px !important; }
5531 .metric-label { font-size: 10px !important; }
5532
5533 /* Page break control — small atomic cards stay together; large panels
5534 and tables flow freely so they never force blank pages. */
5535 .metric, .warning-card, .run-id-chip { break-inside: avoid !important; }
5536 .hero, .panel, .stack { break-inside: auto !important; }
5537 section { break-inside: auto !important; }
5538 /* Keep each chart panel whole — browser moves it to the next page rather than
5539 slicing through the middle of a canvas. */
5540 .chart-section { break-inside: avoid !important; }
5541 /* Keep the summary grid on the same page as the hero header when possible */
5542 .summary-grid { break-before: avoid !important; }
5543 /* Section headings never orphan at the bottom of a page */
5544 h2, h3 { break-after: avoid !important; orphans: 3; widows: 3; }
5545 /* Keep the first few rows of a table with the header */
5546 thead { break-after: avoid !important; }
5547
5548 /* Language charts — table layout is inherently side-by-side */
5549 #lang-overview-charts table { display: inline-table !important; }
5550 #lang-overview-charts td { vertical-align: top !important; }
5551
5552 /* Tables */
5553 .table-shell {
5554 max-height: none !important;
5555 overflow: visible !important;
5556 width: 100% !important;
5557 break-inside: auto !important;
5558 }
5559
5560 table {
5561 width: 100% !important;
5562 table-layout: auto !important;
5563 font-size: 10px !important;
5564 border-collapse: collapse !important;
5565 orphans: 4 !important;
5566 widows: 4 !important;
5567 }
5568
5569 /* Remove the screen-layout min-width so tables scale to page width */
5570 #per-file-table, #skipped-table { min-width: 0 !important; }
5571 /* Release sticky column positioning (not meaningful on paper) */
5572 #per-file-table th:first-child,
5573 #per-file-table td:first-child { position: static !important; }
5574 /* Show ALL rows — JS pagination hides rows via inline style; !important overrides it */
5575 #per-file-table tbody tr, #skipped-table tbody tr, #hotspots-table tbody tr { display: table-row !important; }
5576 /* Hide pagination controls — not interactive in PDF */
5577 .page-size-row, .pagination-bar { display: none !important; }
5578 /* Header tooltips and the interaction hint are screen-only */
5579 .col-tip { display: none !important; }
5580 .hs-hint { display: none !important; }
5581
5582 thead { display: table-header-group; }
5583 tr { break-inside: avoid !important; }
5584
5585 th {
5586 position: relative !important;
5587 font-size: 9px !important;
5588 font-weight: 700 !important;
5589 color: #333 !important;
5590 padding: 5px 8px !important;
5591 background: rgba(211,122,76,0.18) !important;
5592 white-space: normal !important;
5593 }
5594 /* Resize handles are screen-only — hide them in print */
5595 .col-resize-handle { display: none !important; }
5596 /* Sort indicators are redundant on paper */
5597 .sort-indicator { display: none !important; }
5598
5599 td {
5600 white-space: normal !important;
5601 overflow-wrap: anywhere !important;
5602 word-break: break-word !important;
5603 padding: 5px 8px !important;
5604 font-size: 10px !important;
5605 border-bottom: 1px solid #e8d8c8 !important;
5606 }
5607
5608 pre, code {
5609 white-space: pre-wrap !important;
5610 overflow-wrap: anywhere !important;
5611 word-break: break-word !important;
5612 font-size: 9px !important;
5613 max-height: none !important;
5614 }
5615
5616 .warning-card {
5617 box-shadow: none !important;
5618 border: 1px solid #ddd !important;
5619 break-inside: avoid !important;
5620 padding: 10px !important;
5621 }
5622
5623 .hero-top { flex-direction: row !important; }
5624
5625 .run-id-row { flex-wrap: wrap !important; gap: 4px !important; }
5626 .run-id-chip { font-size: 9px !important; padding: 4px 8px !important; border-left-width: 2px !important; }
5627 .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; }
5628 .meta-chip { flex: 1 !important; justify-content: center !important; font-size: 9px !important; padding: 0 8px !important; border-right: 1px solid #ccc !important; }
5629 .meta-chip:last-child { border-right: none !important; }
5630
5631 .report-footer {
5632 border-top: 1px solid #ccc !important;
5633 margin-top: 12px !important;
5634 font-size: 10px !important;
5635 }
5636
5637 /* Collapse all <details> in print except the warnings block */
5638 details { border: 1px solid #ddd !important; border-radius: 8px !important; }
5639 details > summary { display: block !important; font-size: 10px !important; }
5640 details > div { display: none !important; }
5641 .warning-console { display: none !important; }
5642 .warning-console-actions { display: none !important; }
5643 /* Always expand the run-warnings details in PDF */
5644 details.warnings-details > div { display: block !important; }
5645 details.warnings-details .warning-console {
5646 display: block !important;
5647 max-height: none !important;
5648 overflow: visible !important;
5649 font-size: 8px !important;
5650 white-space: pre-wrap !important;
5651 word-break: break-all !important;
5652 }
5653 details.warnings-details .code-block-toolbar { display: none !important; }
5654
5655 /* Pill badges */
5656 .pill { font-size: 9px !important; padding: 2px 6px !important; min-height: auto !important; }
5657
5658 /* Support opportunities table */
5659 .support-table td:first-child { font-weight: 600; font-size: 10px !important; }
5660
5661 /* Hide canvas-based interactive chart sections; replaced by pre-rendered variants */
5662 .chart-section { display: none !important; }
5663 .charts-grid { display: none !important; }
5664 /* Pre-rendered chart variants — no forced page break; flow naturally after hero section */
5665 .pdf-variants-root { display: block !important; padding: 0 !important; }
5666 .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; }
5667 .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; }
5668 .pdf-variant-grid { display: grid !important; grid-template-columns: 1fr 1fr !important; gap: 6px !important; }
5669 /* Single-column chart (scatter, etc.) — centre and constrain width in print */
5670 .pdf-variant-grid.single-col { grid-template-columns: 1fr !important; }
5671 .pdf-variant-grid.single-col .pdf-variant-panel { max-width: 62% !important; margin: 0 auto !important; }
5672 .pdf-variant-panel { break-inside: avoid !important; }
5673 .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; }
5674 .pdf-variant-img { width: 100% !important; height: auto !important; display: block !important; border-radius: 5px !important; border: 1px solid #ddd !important; }
5675 }
5676
5677
5678 .warnings-show-link {
5679 display: inline-flex;
5680 align-items: center;
5681 gap: 8px;
5682 padding: 8px 12px;
5683 border-radius: 10px;
5684 border: 1px solid rgba(111, 144, 255, 0.35);
5685 background: #eef3ff;
5686 color: #2f5fe3 !important;
5687 font-weight: 800;
5688 text-decoration: none;
5689 box-shadow: inset 0 1px 0 rgba(255,255,255,0.45);
5690 }
5691
5692 body.dark-theme .warnings-show-link {
5693 background: #1c2847;
5694 color: #a9c1ff !important;
5695 border-color: rgba(169, 193, 255, 0.32);
5696 }
5697
5698 .effective-config-note {
5699 margin: 8px 0 0;
5700 color: var(--muted);
5701 font-size: 14px;
5702 line-height: 1.6;
5703 }
5704 .config-actions { display: flex; gap: 8px; flex-shrink: 0; }
5705 .config-pre-wrap { position: relative; }
5706 .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; }
5707 body.dark-theme .config-pre { background: #0e0c0a; color: #b8f0b8; }
5708 .code-block-toolbar { display:flex; justify-content:flex-end; margin-bottom:6px; }
5709 .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; }
5710 .code-copy-btn:hover { background: rgba(184,93,51,0.08); color: var(--oxide-2); border-color: rgba(184,93,51,0.30); }
5711 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); }
5712 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); }
5713
5714
5715 .page {
5716 position: relative;
5717 z-index: 1;
5718 }
5719 .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; }
5720
5721 /* ── Chart controls & containers ───────────────────────────────────── */
5722 .chart-section { }
5723 .chart-controls { display:flex; gap:12px; align-items:center; flex-wrap:wrap; margin-bottom:14px; }
5724 .chart-controls label { font-size:13px; font-weight:700; color:var(--muted); display:flex; align-items:center; gap:6px; }
5725 .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; }
5726 .chart-select:focus { border-color:var(--accent); }
5727 .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; }
5728 .chart-expand-btn:hover { background:var(--surface-2); color:var(--text); }
5729 .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; }
5730 .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); }
5731 .chart-modal-title { font-size:15px; font-weight:800; text-transform:uppercase; letter-spacing:.05em; color:var(--text); margin:0 0 2px; display:block; }
5732 .chart-modal-subtitle { font-size:13px; font-weight:600; color:var(--muted); margin:0 0 16px; display:block; letter-spacing:.02em; }
5733 .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; }
5734 .chart-modal-close:hover { opacity:.7; }
5735 .chart-modal-header { display:flex; align-items:center; gap:12px; flex-wrap:nowrap; margin:0 0 16px; padding-right:44px; }
5736 .chart-modal-header .chart-modal-title { flex:1 1 auto; margin:0; min-width:0; }
5737 body.dark-theme .chart-modal { background:var(--surface); }
5738 .chart-container { width:100%; overflow:visible; }
5739 .charts-grid { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:18px; align-items:stretch; }
5740 .charts-grid > .panel { margin:0; min-width:0; display:flex; flex-direction:column; }
5741 .charts-grid .chart-section > div { display:flex; flex-direction:column; flex:1; }
5742 .charts-grid .chart-container { flex:1; min-height:180px; }
5743 .chart-pre { min-height:72px; }
5744 @media (max-width:820px) { .charts-grid { grid-template-columns:1fr; } }
5745 .r-lang-overview { display:flex; gap:40px; align-items:center; justify-content:center; flex-wrap:wrap; padding:8px 0 16px; }
5746 .r-lang-overview-cell { display:flex; flex-direction:column; align-items:center; gap:8px; flex:1 1 280px; max-width:480px; }
5747 .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; }
5748 .r-lang-overview svg { display:block; max-width:100%; height:auto; }
5749 .rchit { cursor:pointer; transition:opacity .17s,filter .17s,transform .17s; transform-box:fill-box; transform-origin:center center; }
5750 .rchit:hover { filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18)); transform:scale(1.05); }
5751 .lang-bar-row { cursor:pointer; transition:transform .2s cubic-bezier(.34,1.56,.64,1); }
5752 .lang-bar-row:hover { transform:translateY(-2px); }
5753 .lang-bar-row .rchit:hover { filter:none; transform:none; }
5754 .lang-bar-row:hover .rchit { filter:brightness(1.12); transform:scaleY(1.22); }
5755 #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; }
5756 .chart-tab-bar { display:flex; gap:6px; margin-bottom:12px; flex-wrap:wrap; }
5757 .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; }
5758 .chart-tab:hover { background:var(--surface-3); color:var(--text); }
5759 .chart-tab.active { background:var(--accent); color:#fff; border-color:var(--accent); }
5760 .chart-locked-card { display:none; padding:20px 24px; border-radius:14px; background:var(--info-bg); border:1px solid rgba(196,92,16,0.28); color:var(--info-text); font-size:14px; line-height:1.6; }
5761 .chart-locked-card a { color:var(--accent-2); font-weight:700; }
5762 .chart-locked-card h3 { margin:0 0 6px; font-size:15px; }
5763
5764 /* Print: hide interactive controls; keep SVGs; show locked card for history mode */
5765 @media print {
5766 .chart-controls, .chart-tab-bar { display:none !important; }
5767 /* Single-column grid: each chart gets full page width, renders shorter, fits on one page */
5768 .charts-grid { grid-template-columns: 1fr !important; gap: 10px !important; }
5769 /* Cap canvas height so a single chart never overflows a landscape page */
5770 canvas { max-width: 100% !important; max-height: 280px !important; }
5771 .chart-container { width: 100% !important; overflow: visible !important; }
5772 .chart-container svg { max-height:300px !important; }
5773 /* chart-locked-card: do NOT force display:block — let JS-set visibility carry
5774 into print (hidden in normal mode; visible only when history mode is active).
5775 When it does show, use readable dark text instead of accent blue. */
5776 .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; }
5777 .chart-locked-card h3 { color:#222 !important; font-size:13px !important; }
5778 .chart-locked-card a, .chart-locked-card strong { color:#1a4fa0 !important; }
5779 .chart-locked-card code { background:rgba(0,0,0,0.08) !important; padding:1px 4px !important; border-radius:3px !important; }
5780 }
5781
5782 /* PDF-only chart variants container — hidden on screen, rendered in print */
5783 .pdf-variants-root{display:none;}
5784 .pdf-variant-group{margin-bottom:12px;background:#faf6f0;border:1px solid #e0d0c0;border-radius:10px;padding:12px 14px;}
5785 .pdf-variant-group-title{font-size:15px;font-weight:800;color:#3d2d26;margin:0 0 8px;padding-bottom:5px;border-bottom:2px solid #d37a4c;}
5786 .pdf-variant-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;}
5787 /* Solo charts (one per row) — constrained width, centred */
5788 .pdf-variant-grid.single-col{grid-template-columns:1fr;}
5789 .pdf-variant-grid.single-col .pdf-variant-panel{max-width:62%;margin:0 auto;}
5790 .pdf-variant-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#7b675b;margin:0 0 3px;}
5791 .pdf-variant-img{width:100%;height:auto;display:block;border-radius:6px;border:1px solid #ddd;}
5792
5793 #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%);}
5794 #rpt-loading-overlay.fade-out{opacity:0;pointer-events:none;}
5795 /* Drifting color blobs — transform/opacity only (GPU composited, no per-frame repaint) */
5796 .rpt-bg-blob{position:absolute;border-radius:50%;filter:blur(64px);opacity:.5;pointer-events:none;will-change:transform;}
5797 .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;}
5798 .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;}
5799 .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;}
5800 @keyframes rpt-drift-a{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(9vw,7vw,0) scale(1.18);}}
5801 @keyframes rpt-drift-b{0%,100%{transform:translate3d(0,0,0) scale(1.06);}50%{transform:translate3d(-8vw,-6vw,0) scale(.88);}}
5802 @keyframes rpt-drift-c{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(-7vw,8vw,0) scale(1.22);}}
5803 body.dark-theme #rpt-loading-overlay{background:radial-gradient(125% 125% at 50% 0%,#241810 0%,#1a120b 45%,#130c06 100%);}
5804 body.dark-theme .rpt-bg-blob{opacity:.36;}
5805 .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;}
5806 @keyframes rpt-card-in{from{opacity:0;transform:translateY(14px) scale(.96);}to{opacity:1;transform:none;}}
5807 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);}
5808 /* Logo is static — no bounce (kept GPU-cheap) */
5809 .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;}
5810 .rpt-spinner-wrap{position:relative;width:90px;height:90px;}
5811 .rpt-spinner-track{position:absolute;inset:0;border-radius:50%;border:5px solid rgba(196,92,16,.12);}
5812 .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));}
5813 @keyframes rpt-spin{to{transform:rotate(360deg);}}
5814 .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;}
5815 body.dark-theme .rpt-spinner-track{border-color:rgba(196,92,16,.2);}
5816 body.dark-theme .rpt-spinner-pct{color:#e8932f;}
5817 .rpt-load-divider{width:54px;height:1px;background:linear-gradient(90deg,transparent,rgba(196,92,16,.22),transparent);}
5818 .rpt-loading-text{font-size:15px;font-weight:600;letter-spacing:.08em;display:flex;align-items:baseline;gap:2px;}
5819 .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;}
5820 @keyframes rpt-text-shimmer{to{background-position:-220% center;}}
5821 .rpt-dot{display:inline-block;color:#c45c10;-webkit-text-fill-color:#c45c10;animation:rpt-bounce 1.7s ease-in-out infinite;opacity:0;}
5822 .rpt-dot:nth-child(2){animation-delay:.28s;}
5823 .rpt-dot:nth-child(3){animation-delay:.56s;}
5824 @keyframes rpt-bounce{0%,60%,100%{opacity:0;transform:translateY(0);}30%{opacity:1;transform:translateY(-5px);}}
5825 .rpt-status{font-size:12.5px;font-weight:600;letter-spacing:.02em;color:var(--muted,#8a7060);min-height:16px;text-align:center;}
5826 .rpt-status-in{animation:rpt-status-pop .38s ease both;}
5827 @keyframes rpt-status-pop{from{opacity:0;transform:translateY(4px);}to{opacity:1;transform:none;}}
5828 .rpt-progress{width:100%;height:6px;border-radius:99px;background:rgba(196,92,16,.12);overflow:hidden;}
5829 .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;}
5830 body.dark-theme .rpt-progress{background:rgba(196,92,16,.2);}
5831 .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;}
5832 .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;}
5833 .rpt-feed-line::before{content:'>';color:#c45c10;font-weight:700;}
5834 @keyframes rpt-feed-in{from{opacity:0;transform:translateX(-6px);}to{opacity:.82;transform:none;}}
5835 body.dark-theme .rpt-feed{color:rgba(204,172,150,.62);}
5836 body.dark-theme .rpt-load-divider{background:linear-gradient(90deg,transparent,rgba(196,92,16,.28),transparent);}
5837 @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;}}
5838 /* ── Code Style Analysis section ── */
5839 .style-guide-adherence{margin-top:26px;padding:18px 20px 20px;border:1px solid var(--line);border-radius:12px;background:var(--surface-2);}
5840 .style-guide-adherence-title{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:14px;}
5841 .style-guide-grid{display:grid;gap:14px;}
5842 .style-guide-row{display:grid;grid-template-columns:150px 1fr 52px;align-items:center;gap:14px;padding:10px 12px;border-radius:8px;cursor:default;position:relative;transition:transform .18s ease,box-shadow .18s ease,background .18s ease;}
5843 .style-guide-row:hover{transform:translateY(-2px);box-shadow:0 6px 22px rgba(77,44,20,0.18);background:var(--surface);}
5844 .style-guide-label{font-size:12px;font-weight:800;color:var(--text);text-align:right;white-space:nowrap;}
5845 .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);}
5846 .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;}
5847 .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;}
5848 .style-guide-row:hover .style-guide-fill{filter:brightness(1.12);}
5849 .style-guide-score{font-size:12px;font-weight:800;color:var(--oxide);text-align:right;white-space:nowrap;}
5850 .style-guide-desc{font-size:10px;color:var(--muted);margin-top:2px;grid-column:2/3;}
5851 .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);}
5852 .style-bar-tip::after{content:'';position:absolute;top:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-top-color:var(--text);}
5853 .style-guide-row:hover .style-bar-tip{opacity:1;}
5854 .style-metrics-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:18px 0 0;}
5855 @media(max-width:800px){.style-metrics-strip{grid-template-columns:repeat(2,1fr);}}
5856 .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);}
5857 .style-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);}
5858 .style-chip-val{font-size:18px;font-weight:900;color:var(--oxide);}
5859 .style-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:3px;}
5860 .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;}
5861 .style-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
5862 .style-chip:hover .style-chip-tip{opacity:1;}
5863 .style-file-table{width:100%;border-collapse:collapse;font-size:12px;table-layout:fixed;}
5864 .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;}
5865 .style-file-table th:hover{background:var(--surface-2);color:var(--text);}
5866 .style-sort-ind{display:inline-block;margin-left:4px;font-size:9px;opacity:.4;vertical-align:middle;}
5867 .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);}
5868 .style-file-table td{padding:6px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
5869 .style-file-table tr:hover td{background:var(--surface-2);}
5870 .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;}
5871 .style-score-fill{position:absolute;left:0;top:0;height:100%;border-radius:4px;background:linear-gradient(90deg,var(--oxide),var(--oxide-2));}
5872 .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;}
5873 a.style-badge:hover{background:var(--oxide);color:#fff !important;transform:translateY(-1px);box-shadow:0 3px 10px rgba(77,44,20,.24);}
5874 .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(196,92,16,0.20);}
5875 .style-lang-tabs{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px;}
5876 .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;}
5877 .style-lang-tab:hover{background:var(--surface-2);}
5878 .style-lang-tab.active{background:var(--oxide);color:#fff;border-color:var(--oxide);}
5879 .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;}
5880 .style-row-warn td{background:rgba(178,48,48,0.06)!important;}
5881 .style-row-warn td:first-child{border-left:3px solid #b23030;}
5882 .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;}
5883 .style-sig-more:hover{background:var(--oxide);color:#fff;}
5884 .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;}
5885 .style-sig-info-btn:hover{color:var(--oxide);}
5886 .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;}
5887 .style-sig-pop-title{font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px;}
5888 .style-sig-pop-row{display:flex;gap:8px;padding:4px 0;border-bottom:1px solid var(--line);}
5889 .style-sig-pop-row:last-child{border-bottom:none;}
5890 .style-sig-pop-key{color:var(--muted);font-weight:700;white-space:nowrap;flex-shrink:0;}
5891 .style-sig-pop-val{color:var(--text);}
5892 .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;}
5893 .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;}
5894 .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;}
5895 .style-sig-info-close:hover{color:var(--text);}
5896 .style-sig-info-grid{display:grid;grid-template-columns:max-content 1fr;gap:6px 14px;margin-top:14px;font-size:13px;}
5897 .style-sig-info-name{color:var(--oxide);font-weight:700;padding:2px 0;}
5898 .style-sig-info-desc{color:var(--text);padding:2px 0;}
5899 body.dark-theme .style-guide-track{background:var(--surface-3);}
5900 body.dark-theme .style-chip{background:var(--surface-2);}
5901 body.dark-theme .style-file-table th{background:var(--surface-3);}
5902 body.dark-theme .style-heuristic-note{border-color:rgba(211,122,76,0.25);}
5903 body.dark-theme .style-lang-tab{background:var(--surface-2);color:var(--text);}
5904 body.dark-theme .style-lang-tab.active{background:var(--oxide);color:#fff;}
5905 body.dark-theme .style-sig-chip{background:var(--surface-3);color:var(--muted);}
5906 body.dark-theme .style-sig-pop{background:var(--surface);box-shadow:0 8px 24px rgba(0,0,0,.4);}
5907 body.dark-theme .style-sig-info-modal{background:var(--surface);}
5908 .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;}
5909 .sig-tip.visible{opacity:1;}
5910 .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);}
5911 .sig-tip-hd{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:rgba(240,235,228,.5);margin-bottom:5px;}
5912 .sig-tip-row{display:flex;gap:8px;align-items:baseline;}
5913 .sig-tip-k{color:#e07b3a;font-weight:700;white-space:nowrap;flex-shrink:0;}
5914 .sig-tip-v{color:#f0ebe4;}
5915</style>
5916<script nonce="{{ nonce }}">{{ chart_js|safe }}</script>
5917</head>
5918<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
5919 <div id="rpt-loading-overlay" aria-live="polite" aria-label="Loading report">
5920 <div class="rpt-bg-blob rpt-blob-a" aria-hidden="true"></div>
5921 <div class="rpt-bg-blob rpt-blob-b" aria-hidden="true"></div>
5922 <div class="rpt-bg-blob rpt-blob-c" aria-hidden="true"></div>
5923 <div class="rpt-load-card">
5924 <img src="{{ small_logo_uri }}" alt="oxide-sloc" class="rpt-load-logo" />
5925 <div class="rpt-spinner-wrap">
5926 <div class="rpt-spinner-track"></div>
5927 <div class="rpt-spinner"></div>
5928 <div class="rpt-spinner-pct" id="rpt-pct">0%</div>
5929 </div>
5930 <div class="rpt-load-divider"></div>
5931 <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>
5932 <div class="rpt-status" id="rpt-status">Initializing analysis engine</div>
5933 <div class="rpt-progress"><div class="rpt-progress-bar" id="rpt-progress-bar"></div></div>
5934 <div class="rpt-feed" id="rpt-feed" aria-hidden="true"></div>
5935 </div>
5936 </div>
5937 <script nonce="{{ nonce }}">
5938 (function(){
5939 var ov=document.getElementById('rpt-loading-overlay');if(!ov)return;
5940 var statusEl=document.getElementById('rpt-status'),bar=document.getElementById('rpt-progress-bar'),pct=document.getElementById('rpt-pct'),feed=document.getElementById('rpt-feed');
5941 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'];
5942 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'];
5943 var mi=0,li=0,prog=0,ready=false,start=Date.now(),MIN=1700;
5944 function setProg(p){prog=p;if(bar)bar.style.transform='scaleX('+(p/100).toFixed(3)+')';if(pct)pct.textContent=Math.round(p)+'%';}
5945 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++;}
5946 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);}
5947 nextMsg();addLog();setProg(6);
5948 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);
5949 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);}
5950 window.__rptFinish=function(){ready=true;setTimeout(done,Math.max(0,MIN-(Date.now()-start)));};
5951 })();
5952 </script>
5953 <div class="background-watermarks" aria-hidden="true">
5954 <img src="{{ logo_text_uri }}" alt="" />
5955 <img src="{{ logo_text_uri }}" alt="" />
5956 <img src="{{ logo_text_uri }}" alt="" />
5957 <img src="{{ logo_text_uri }}" alt="" />
5958 <img src="{{ logo_text_uri }}" alt="" />
5959 <img src="{{ logo_text_uri }}" alt="" />
5960 <img src="{{ logo_text_uri }}" alt="" />
5961 <img src="{{ logo_text_uri }}" alt="" />
5962 <img src="{{ logo_text_uri }}" alt="" />
5963 <img src="{{ logo_text_uri }}" alt="" />
5964 <img src="{{ logo_text_uri }}" alt="" />
5965 <img src="{{ logo_text_uri }}" alt="" />
5966 </div>
5967 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
5968 {% if let Some(banner) = report_header_footer %}
5969 <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
5970 {% endif %}
5971 <div class="top-nav">
5972 <div class="top-nav-inner">
5973 <a class="brand" href="/" data-local-brand="1">
5974 {% if let Some(uri) = custom_logo_uri %}
5975 <img class="brand-logo" src="{{ uri }}" alt="logo" />
5976 {% else %}
5977 <img class="brand-logo" src="{{ small_logo_uri }}" alt="OxideSLOC logo" />
5978 {% endif %}
5979 <div class="brand-copy">
5980 {% if let Some(name) = company_name %}
5981 <div class="brand-title">{{ name }}</div>
5982 {% else %}
5983 <div class="brand-title">OxideSLOC</div>
5984 {% endif %}
5985 <div class="brand-subtitle">Saved HTML report</div>
5986 </div>
5987 </a>
5988 <div class="nav-project-slot">
5989 <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ title }}</span></div>
5990 </div>
5991 <div class="nav-status">
5992 <button type="button" class="header-button" data-copy-link>Copy link</button>
5993 <button type="button" class="header-button" data-share-report>Share</button>
5994 <div class="nav-dropdown-wrap">
5995 <button type="button" class="header-button nav-dropdown-trigger" aria-haspopup="true">Export ▾</button>
5996 <div class="nav-dropdown-menu">
5997 <button type="button" class="nav-dropdown-item" data-export-csv>Export CSV</button>
5998 <button type="button" class="nav-dropdown-item" data-export-xls>Export Excel</button>
5999 </div>
6000 </div>
6001 <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>
6002 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
6003 <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>
6004 </button>
6005 <button type="button" class="theme-toggle" data-theme-toggle aria-label="Toggle theme" title="Toggle theme">
6006 <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>
6007 <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>
6008 </button>
6009 </div>
6010 </div>
6011 </div>
6012
6013 <div class="page">
6014 <section class="hero panel">
6015 <div class="hero-top">
6016 <div>
6017 <div class="section-kicker">Saved report artifact</div>
6018 <div style="display:flex;align-items:baseline;gap:18px;flex-wrap:wrap;">
6019 <h1>{{ title }}</h1>
6020 <span class="run-id-short-badge" title="Short run ID \u2014 matches the ID shown in View Reports">{{ run_id_short }}</span>
6021 </div>
6022 </div>
6023 </div>
6024 <div class="run-id-row">
6025 <span class="run-id-chip" data-copy="{{ run.tool.run_id }}">
6026 <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>
6027 <span class="run-id-chip-value">{{ run.tool.run_id }}</span>
6028 <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
6029 </span>
6030 {% if let Some(long_commit) = run.git_commit_long %}
6031 {% if let Some(commit_url) = git_commit_url %}
6032 <a class="run-id-chip run-id-chip-link" href="{{ commit_url }}" target="_blank" rel="noopener noreferrer" title="Open commit in source control">
6033 <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>
6034 <span class="run-id-chip-value">{{ long_commit }}</span>
6035 <span class="chip-tooltip">Opens commit in source control — new tab</span>
6036 </a>
6037 {% else %}
6038 <span class="run-id-chip" data-copy="{{ long_commit }}">
6039 <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>
6040 <span class="run-id-chip-value">{{ long_commit }}</span>
6041 <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
6042 </span>
6043 {% endif %}
6044 {% else %}
6045 <span class="run-id-chip muted-chip">
6046 <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>
6047 <span class="run-id-chip-value">Not detected</span>
6048 <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
6049 </span>
6050 {% endif %}
6051 {% if let Some(branch) = run.git_branch %}
6052 {% if let Some(branch_url) = git_branch_url %}
6053 <a class="run-id-chip run-id-chip-link" href="{{ branch_url }}" target="_blank" rel="noopener noreferrer" title="Open branch in source control">
6054 <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>
6055 <span class="run-id-chip-value">{{ branch }}</span>
6056 <span class="chip-tooltip">Opens branch in source control — new tab</span>
6057 </a>
6058 {% else %}
6059 <span class="run-id-chip">
6060 <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>
6061 <span class="run-id-chip-value">{{ branch }}</span>
6062 <span class="chip-tooltip">Git branch scanned for this report</span>
6063 </span>
6064 {% endif %}
6065 {% else %}
6066 {% if is_sub_report %}
6067 <span class="run-id-chip">
6068 <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>
6069 <span class="run-id-chip-value"><span class="submodule-state-badge">detached HEAD</span></span>
6070 <span class="chip-tooltip">Submodules are pinned to a specific commit — no branch ref</span>
6071 </span>
6072 {% else %}
6073 <span class="run-id-chip muted-chip">
6074 <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>
6075 <span class="run-id-chip-value">Not detected</span>
6076 <span class="chip-tooltip">No Git branch was found for this scan</span>
6077 </span>
6078 {% endif %}
6079 {% endif %}
6080 {% if let Some(author) = run.git_commit_author %}
6081 <span class="run-id-chip" data-author="{{ author }}">
6082 <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>
6083 <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
6084 <span class="chip-tooltip">Author of the most recent commit in this repository</span>
6085 </span>
6086 {% else %}
6087 <span class="run-id-chip muted-chip">
6088 <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>
6089 <span class="run-id-chip-value">Not detected</span>
6090 <span class="chip-tooltip">No commit author was found for this scan</span>
6091 </span>
6092 {% endif %}
6093 </div>
6094
6095 <div class="meta">
6096 <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
6097 <span class="meta-chip">Scanned <b>{{ scan_time_pst }}</b></span>
6098 <span class="meta-chip">OS <b>{{ run.environment.operating_system }} / {{ run.environment.architecture }}</b></span>
6099 <span class="meta-chip">Files analyzed <b>{{ run.summary_totals.files_analyzed }}</b></span>
6100 <span class="meta-chip">Files skipped <b>{{ run.summary_totals.files_skipped }}</b></span>
6101 </div>
6102
6103 {% if has_delta %}
6104 <div class="prev-scan-banner" aria-label="Changes vs. previous scan">
6105 <div class="prev-scan-banner-top">
6106 <div class="prev-scan-meta">
6107 <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>
6108 {% 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 %}
6109 <span class="prev-scan-ts">{{ prev_scan_label }}</span>
6110 {% if prev_scan_count > 0 %}
6111 <span class="prev-scan-count">· {{ prev_scan_count }} scan{% if prev_scan_count != 1 %}s{% endif %} total</span>
6112 {% endif %}
6113 </div>
6114 <div class="prev-scan-summary">
6115 Code before: <b data-raw="{{ prev_code_lines }}">{{ prev_code_lines }}</b>
6116 →
6117 Code now: <b data-raw="{{ run.summary_totals.code_lines }}">{{ run.summary_totals.code_lines }}</b>
6118 ·
6119 <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>
6120
6121 <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>
6122 </div>
6123 </div>
6124 <div class="delta-card-row">
6125 <div class="delta-card-inline {% if delta_code_added > 0 %}pos{% endif %}">
6126 <div class="delta-card-val pos">+{{ delta_code_added|commas }}</div>
6127 <div class="delta-card-lbl">Lines added</div>
6128 <div class="delta-card-tip">Code lines added since {{ prev_scan_label }}</div>
6129 </div>
6130 <div class="delta-card-inline {% if delta_code_removed > 0 %}neg{% endif %}">
6131 <div class="delta-card-val neg">−{{ delta_code_removed|commas }}</div>
6132 <div class="delta-card-lbl">Lines removed</div>
6133 <div class="delta-card-tip">Code lines removed since {{ prev_scan_label }}</div>
6134 </div>
6135 <div class="delta-card-inline">
6136 <div class="delta-card-val">{{ delta_unmodified_lines|commas }}</div>
6137 <div class="delta-card-lbl">Unmodified lines</div>
6138 <div class="delta-card-tip">Code lines unchanged since {{ prev_scan_label }}</div>
6139 </div>
6140 <div class="delta-card-inline {% if delta_files_modified > 0 %}mod{% endif %}">
6141 <div class="delta-card-val mod">{{ delta_files_modified|commas }}</div>
6142 <div class="delta-card-lbl">Files modified</div>
6143 <div class="delta-card-tip">Files with at least one line changed</div>
6144 </div>
6145 <div class="delta-card-inline {% if delta_files_added > 0 %}pos{% endif %}">
6146 <div class="delta-card-val pos">{{ delta_files_added|commas }}</div>
6147 <div class="delta-card-lbl">Files added</div>
6148 <div class="delta-card-tip">New files added since {{ prev_scan_label }}</div>
6149 </div>
6150 <div class="delta-card-inline {% if delta_files_removed > 0 %}neg{% endif %}">
6151 <div class="delta-card-val neg">{{ delta_files_removed|commas }}</div>
6152 <div class="delta-card-lbl">Files removed</div>
6153 <div class="delta-card-tip">Files deleted since {{ prev_scan_label }}</div>
6154 </div>
6155 <div class="delta-card-inline">
6156 <div class="delta-card-val">{{ delta_files_unchanged|commas }}</div>
6157 <div class="delta-card-lbl">Files unchanged</div>
6158 <div class="delta-card-tip">Files with no changes since {{ prev_scan_label }}</div>
6159 </div>
6160 <div class="delta-card-inline">
6161 <div class="delta-card-val">{{ delta_files_total|commas }}</div>
6162 <div class="delta-card-lbl">Files total</div>
6163 <div class="delta-card-tip">Total files across both scans (modified + added + removed + unchanged)</div>
6164 </div>
6165 </div>
6166 </div>
6167 {% else %}
6168 <div class="prev-scan-banner prev-scan-banner-empty" aria-label="No previous scan">
6169 <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>
6170 No previous scan found for this project — this report is the baseline.
6171 </div>
6172 {% endif %}
6173
6174 <div class="summary-grid">
6175 <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>
6176 <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>
6177 <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>
6178 <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>
6179 <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>
6180 <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>
6181 <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>
6182 <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>
6183 <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>
6184 <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>
6185 <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>
6186 <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>
6187 {% 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 %}
6188 {% 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 %}
6189 {% 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 %}
6190 {% 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 %}
6191 {% 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 %}
6192 <!-- Reserve "pad" card: revealed by JS only when the visible card count is
6193 odd, so the strip always has an even number of cards that fill exactly
6194 two aligned rows (no oversized card, no empty trailing cell). -->
6195 <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>
6196 </div>
6197 </section>
6198
6199 <!-- ── PDF-only pre-rendered chart variants (hidden on screen) ─────── -->
6200 <div id="pdf-variants" class="pdf-variants-root"></div>
6201
6202 <div class="report-stack">
6203 <!-- ── Chart row 1: Overview + Composition ───────────────────────── -->
6204 <div class="charts-grid">
6205 <section class="panel stack chart-section">
6206 <div>
6207 <div class="toolbar">
6208 <div class="toolbar-left"><h2>Project Overview</h2></div>
6209 <button class="chart-expand-btn" id="overview-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
6210 </div>
6211 <div class="chart-pre">
6212 <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>
6213 <div class="chart-controls">
6214 <label>Y Axis:
6215 <select class="chart-select" id="overview-y-axis">
6216 <option value="code">Code Lines</option>
6217 <option value="comments">Comment Lines</option>
6218 <option value="blanks">Blank Lines</option>
6219 <option value="physical">Total Physical Lines</option>
6220 <option value="files">File Count</option>
6221 </select>
6222 </label>
6223 <label>X Axis / Mode:
6224 <select class="chart-select" id="overview-x-mode">
6225 <option value="languages">Languages</option>
6226 {% if has_submodule_data %}<option value="submodules">Submodules</option>{% endif %}
6227 <option value="history-commits">Per Commit (Web UI)</option>
6228 <option value="history-tags">Per Tag (Web UI)</option>
6229 <option value="history-releases">Per Release (Web UI)</option>
6230 <option value="history-repos">Other Repos (Web UI)</option>
6231 </select>
6232 </label>
6233 </div>
6234 </div>
6235 <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>
6236 <div class="chart-locked-card" id="overview-chart-locked">
6237 <h3>Historical trend requires the web UI</h3>
6238 <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>
6239 </div>
6240 </div>
6241 </section>
6242
6243 <section class="panel stack chart-section">
6244 <div>
6245 <div class="toolbar">
6246 <div class="toolbar-left"><h2>Language Composition</h2></div>
6247 <button class="chart-expand-btn" id="comp-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
6248 </div>
6249 <div class="chart-pre">
6250 <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>
6251 <div class="chart-tab-bar">
6252 <button type="button" class="chart-tab active" data-comp-tab="absolute">Absolute Lines</button>
6253 <button type="button" class="chart-tab" data-comp-tab="pct">Composition %</button>
6254 </div>
6255 </div>
6256 <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>
6257 </div>
6258 </section>
6259 </div>
6260
6261 <!-- ── Chart row 2: Scatter + Semantic ───────────────────────────── -->
6262 <div class="charts-grid">
6263 <section class="panel stack chart-section">
6264 <div>
6265 <div class="toolbar">
6266 <div class="toolbar-left"><h2>Files vs Code Lines</h2></div>
6267 <button class="chart-expand-btn" id="scatter-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
6268 </div>
6269 <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>
6270 <div id="scatter-chart" class="chart-container" style="position:relative;height:224px;"><canvas id="canvas-scatter"></canvas></div>
6271 </div>
6272 </section>
6273
6274 <section class="panel stack chart-section">
6275 <div>
6276 <div class="toolbar">
6277 <div class="toolbar-left"><h2>Semantic Metrics</h2></div>
6278 {% if has_semantic_data %}
6279 <button class="chart-expand-btn" id="semantic-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
6280 {% endif %}
6281 </div>
6282 <p style="margin:0 0 14px;color:var(--muted);font-size:13px;">Detected structural elements per language. Select a metric to explore.</p>
6283 {% if has_semantic_data %}
6284 <div class="chart-controls">
6285 <label>Metric:
6286 <select class="chart-select" id="semantic-metric">
6287 <option value="functions">Functions</option>
6288 <option value="classes">Classes / Types</option>
6289 <option value="variables">Variables</option>
6290 <option value="imports">Imports</option>
6291 <option value="tests">Tests</option>
6292 </select>
6293 </label>
6294 </div>
6295 <div id="semantic-chart" class="chart-container" style="position:relative;height:234px;"><canvas id="canvas-semantic"></canvas></div>
6296 {% else %}
6297 <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;">
6298 <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>
6299 </div>
6300 {% endif %}
6301 </div>
6302 </section>
6303 <section class="panel stack chart-section">
6304 <div>
6305 <div class="toolbar">
6306 <div class="toolbar-left"><h2>Comment Density</h2></div>
6307 <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
6308 </div>
6309 <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>
6310 <div id="density-chart" class="chart-container" style="position:relative;min-height:150px;"><canvas id="canvas-density"></canvas></div>
6311 </div>
6312 </section>
6313
6314 <section class="panel stack chart-section">
6315 <div>
6316 <div class="toolbar">
6317 <div class="toolbar-left"><h2>File Size Distribution</h2></div>
6318 <button class="chart-expand-btn" id="filesize-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
6319 </div>
6320 <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>
6321 <div id="filesize-chart" class="chart-container" style="position:relative;min-height:150px;"><canvas id="canvas-filesize"></canvas></div>
6322 </div>
6323 </section>
6324 </div>
6325
6326 <!-- ── Tests & Coverage ──────────────────────────────────────────── -->
6327 <section class="panel stack">
6328 <div>
6329 <div class="toolbar">
6330 <div class="toolbar-left"><h2>Tests & Coverage</h2></div>
6331 {% if has_coverage_data %}<div class="pill-row"><span class="pill good">LCOV coverage data present</span></div>{% endif %}
6332 </div>
6333 <div class="summary-strip">
6334 <div class="stat-chip">
6335 <div class="stat-chip-val" data-fmt="{{ run.summary_totals.test_count }}">{{ run.summary_totals.test_count|commas }}</div>
6336 <div class="stat-chip-label">Test Functions</div>
6337 <div class="stat-chip-tip">Lexically detected test case / function definitions (GTest, PyTest, JUnit, Unity, etc.)</div>
6338 <span class="stat-chip-exact">{{ run.summary_totals.test_count|commas }}</span>
6339 </div>
6340 <div class="stat-chip">
6341 <div class="stat-chip-val" data-fmt="{{ test_assertion_count }}">{{ test_assertion_count|commas }}</div>
6342 <div class="stat-chip-label">Assertions</div>
6343 <div class="stat-chip-tip">Test assertion call lines (ASSERT_EQ, EXPECT_TRUE, assertEquals, Assert.AreEqual, assert_eq!, etc.)</div>
6344 <span class="stat-chip-exact">{{ test_assertion_count|commas }}</span>
6345 </div>
6346 <div class="stat-chip">
6347 <div class="stat-chip-val" data-fmt="{{ test_suite_count }}">{{ test_suite_count|commas }}</div>
6348 <div class="stat-chip-label">Test Suites</div>
6349 <div class="stat-chip-tip">Test suite / fixture / group declarations (TEST_GROUP, BOOST_AUTO_TEST_SUITE, [TestClass], etc.)</div>
6350 <span class="stat-chip-exact">{{ test_suite_count|commas }}</span>
6351 </div>
6352 <div class="stat-chip">
6353 <div class="stat-chip-val">{{ test_files_count|commas }} / {{ run.summary_totals.files_analyzed|commas }}</div>
6354 <div class="stat-chip-label">Test Files</div>
6355 <div class="stat-chip-tip">Files containing at least one detected test definition out of total analyzed files</div>
6356 </div>
6357 </div>
6358 <div class="summary-strip" style="margin-top:0;">
6359 <div class="stat-chip">
6360 <div class="stat-chip-val">{{ test_density }}</div>
6361 <div class="stat-chip-label">Tests per 1K SLOC</div>
6362 <div class="stat-chip-tip">Workspace-wide test density: test functions ÷ code lines × 1000</div>
6363 </div>
6364 <div class="stat-chip">
6365 <div class="stat-chip-val" style="font-size:15px;word-break:break-word;line-height:1.2;">{{ most_tested_lang }}</div>
6366 <div class="stat-chip-label">Most Tested Language</div>
6367 <div class="stat-chip-tip">Language with the highest absolute test function count</div>
6368 </div>
6369 <div class="stat-chip">
6370 <div class="stat-chip-val">{{ langs_with_tests }}</div>
6371 <div class="stat-chip-label">Languages with Tests</div>
6372 <div class="stat-chip-tip">Number of distinct languages where test definitions were detected</div>
6373 </div>
6374 <div class="stat-chip">
6375 {% 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 %}
6376 <div class="stat-chip-label">Line Coverage</div>
6377 <div class="stat-chip-tip">Overall line coverage from LCOV data — run with --lcov-path to populate</div>
6378 </div>
6379 </div>
6380 {% if has_coverage_data %}
6381 <div class="cov-gauge-row">
6382 <div class="cov-gauge-card">
6383 <div class="cov-gauge-label">Line Coverage</div>
6384 <div class="cov-gauge-val" style="color:var(--{{ cov_line_class }}-text);">{{ cov_line_pct }}%</div>
6385 <div class="cov-gauge-track"><div class="cov-gauge-fill" style="width:{{ cov_line_pct }}%;background:var(--{{ cov_line_class }}-text);"></div></div>
6386 <div class="cov-gauge-sub">Lines hit / instrumented</div>
6387 <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>
6388 </div>
6389 {% if has_fn_coverage %}
6390 <div class="cov-gauge-card">
6391 <div class="cov-gauge-label">Function Coverage</div>
6392 <div class="cov-gauge-val" style="color:var(--{{ cov_fn_class }}-text);">{{ cov_fn_pct }}%</div>
6393 <div class="cov-gauge-track"><div class="cov-gauge-fill" style="width:{{ cov_fn_pct }}%;background:var(--{{ cov_fn_class }}-text);"></div></div>
6394 <div class="cov-gauge-sub">Functions hit / found</div>
6395 </div>
6396 {% endif %}
6397 {% if has_branch_coverage %}
6398 <div class="cov-gauge-card">
6399 <div class="cov-gauge-label">Branch Coverage</div>
6400 <div class="cov-gauge-val" style="color:var(--{{ cov_branch_class }}-text);">{{ cov_branch_pct }}%</div>
6401 <div class="cov-gauge-track"><div class="cov-gauge-fill" style="width:{{ cov_branch_pct }}%;background:var(--{{ cov_branch_class }}-text);"></div></div>
6402 <div class="cov-gauge-sub">Branches hit / found</div>
6403 </div>
6404 {% endif %}
6405 </div>
6406 {% endif %}
6407 <div class="table-shell" style="margin-top:16px;">
6408 <table data-sort-table style="min-width:560px;">
6409 <thead>
6410 <tr>
6411 <th data-sort-type="text">Language</th>
6412 <th data-sort-type="number">Test Fns</th>
6413 <th data-sort-type="number">Assertions</th>
6414 <th data-sort-type="number">Suites</th>
6415 <th data-sort-type="text">Density (per 1K SLOC)</th>
6416 </tr>
6417 </thead>
6418 <tbody>
6419 {% for row in language_rows %}
6420 {% if row.test_count > 0 || row.test_assertion_count > 0 %}
6421 <tr>
6422 <td>{{ row.language }}</td>
6423 <td>{{ row.test_count|commas }}</td>
6424 <td>{{ row.test_assertion_count|commas }}</td>
6425 <td>{{ row.test_suite_count|commas }}</td>
6426 <td>{{ row.test_density_str }}</td>
6427 </tr>
6428 {% endif %}
6429 {% endfor %}
6430 {% if run.summary_totals.test_count == 0 && test_assertion_count == 0 %}
6431 <tr class="empty-state-row"><td colspan="5">No test functions or assertions detected in this scan</td></tr>
6432 {% endif %}
6433 </tbody>
6434 </table>
6435 </div>
6436 {% if has_coverage_data %}
6437 <div style="display:flex;align-items:center;gap:10px;margin:16px 0 8px;">
6438 <h3 style="margin:0;font-size:14px;font-weight:800;color:var(--text);">Per-File Coverage</h3>
6439 <span class="pill good" style="font-size:10px;">{{ file_rows.len() }} files with data</span>
6440 </div>
6441 <div class="table-shell">
6442 <table data-sort-table class="cov-file-table" style="min-width:560px;">
6443 <thead>
6444 <tr>
6445 <th data-sort-type="text">File</th>
6446 <th class="num-col" data-sort-type="number">Line Cov %</th>
6447 <th class="num-col" data-sort-type="text">Lines Hit / Found</th>
6448 {% if has_fn_coverage %}<th class="num-col" data-sort-type="number">Fn Cov %</th>{% endif %}
6449 {% if has_branch_coverage %}<th class="num-col" data-sort-type="number">Branch Cov %</th>{% endif %}
6450 </tr>
6451 </thead>
6452 <tbody>
6453 {% for row in file_rows %}
6454 {% if !row.line_cov_pct.is_empty() %}
6455 <tr>
6456 <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>
6457 <td class="num-col">{{ row.line_cov_pct }}%</td>
6458 <td class="num-col" style="font-size:11px;color:var(--muted);">{{ row.cov_lines_detail }}</td>
6459 {% if has_fn_coverage %}<td class="num-col">{% if !row.fn_cov_pct.is_empty() %}{{ row.fn_cov_pct }}%{% else %}—{% endif %}</td>{% endif %}
6460 {% if has_branch_coverage %}<td class="num-col">{% if !row.branch_cov_pct.is_empty() %}{{ row.branch_cov_pct }}%{% else %}—{% endif %}</td>{% endif %}
6461 </tr>
6462 {% endif %}
6463 {% endfor %}
6464 {% if file_rows.is_empty() %}
6465 <tr class="empty-state-row"><td colspan="5">No per-file coverage data available</td></tr>
6466 {% endif %}
6467 </tbody>
6468 </table>
6469 </div>
6470 {% else %}
6471 <div class="info-callout">
6472 <span class="info-callout-icon">ℹ</span>
6473 <span>No code coverage detected. Re-run with <code>--lcov-path coverage.info</code> to see line, function, and branch coverage here.</span>
6474 </div>
6475 {% endif %}
6476 </div>
6477 </section>
6478
6479 <!-- ── Multi-Language Code Style Analysis ───────────────────────── -->
6480 {% if has_style_data %}
6481 {% if let Some(ss) = style_summary %}
6482 <section class="panel stack">
6483 <div>
6484 <div class="toolbar">
6485 <div class="toolbar-left"><h2>Code Style Analysis</h2></div>
6486 <div class="pill-row"><span class="pill info">{{ style_lang_count }} language group(s) · Lexical heuristics</span></div>
6487 </div>
6488 <div class="style-heuristic-note">
6489 <span class="info-callout-icon">ℹ</span>
6490 <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>
6491 </div>
6492 <!-- Summary chips -->
6493 <div class="style-metrics-strip">
6494 <div class="style-chip">
6495 <div class="style-chip-val">{{ ss.files_analyzed }}</div>
6496 <div class="style-chip-label">Files Analyzed</div>
6497 <div class="style-chip-tip">Total files with style data</div>
6498 </div>
6499 <div class="style-chip">
6500 <div class="style-chip-val">{{ style_lang_count }}</div>
6501 <div class="style-chip-label">Language Groups</div>
6502 <div class="style-chip-tip">Distinct language families detected</div>
6503 </div>
6504 <div class="style-chip">
6505 <div class="style-chip-val">{{ ss.common_indent_style }}</div>
6506 <div class="style-chip-label">Common Indent</div>
6507 <div class="style-chip-tip">Most prevalent indentation across all files</div>
6508 </div>
6509 <div class="style-chip">
6510 <div class="style-chip-val">{{ ss.line_col_compliant_pct }}%</div>
6511 <div class="style-chip-label">{{ ss.col_threshold }}-Col Compliant</div>
6512 <div class="style-chip-tip">Files where ≤5% of lines exceed {{ ss.col_threshold }} chars</div>
6513 </div>
6514 </div>
6515 <!-- Language selector tab strip -->
6516 <div class="style-guide-adherence">
6517 <div class="style-guide-adherence-title">Style Guide Adherence by Language</div>
6518 <div id="style-lang-tabs" class="style-lang-tabs"></div>
6519 <div class="style-guide-grid" id="style-guide-bars"></div>
6520 </div>
6521 <!-- Per-file style table -->
6522 <div style="margin-top:22px;">
6523 <div class="toolbar" style="margin-bottom:8px;">
6524 <div class="toolbar-left">
6525 <span style="font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);">Per-File Style Details</span>
6526 <input id="sft-search" class="search" type="search" placeholder="Filter files, languages, guides..." style="margin-left:12px;" />
6527 <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>
6528 </div>
6529 </div>
6530 <div class="table-scroll-wrap">
6531 <table class="style-file-table" id="style-file-table">
6532 <thead>
6533 <tr>
6534 <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>
6535 <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>
6536 <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>
6537 <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>
6538 <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>
6539 <th style="width:13%;" title="Hover a row to see all signals \u2014 signal name and detected value.">Signals</th>
6540 </tr>
6541 </thead>
6542 <tbody id="style-file-tbody">
6543 <tr><td colspan="6" style="text-align:center;color:var(--muted);padding:18px;">Loading...</td></tr>
6544 </tbody>
6545 </table>
6546 </div>
6547 <div id="sft-pagination" class="pagination-bar">
6548 <button id="sft-first" class="pager-btn pager-edge" disabled title="First page">⇤ First</button>
6549 <button id="sft-prev" class="pager-btn" disabled>← Prev</button>
6550 <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>
6551 <span id="sft-page-info" class="pager-info"></span>
6552 <button id="sft-next" class="pager-btn">Next →</button>
6553 <button id="sft-last" class="pager-btn pager-edge" title="Last page">Last ⇥</button>
6554 </div>
6555 </div>
6556 </div>
6557 </section>
6558 {% endif %}
6559 {% endif %}
6560
6561 <!-- ── Submodule Breakdown (2-column, conditional) ─────────────── -->
6562 {% if has_submodule_data %}
6563 <div class="charts-grid">
6564 <section class="panel stack chart-section">
6565 <div>
6566 <div class="toolbar">
6567 <div class="toolbar-left"><h2>Submodule Breakdown</h2></div>
6568 <button class="chart-expand-btn" id="sub-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
6569 </div>
6570 <div class="chart-controls">
6571 <label>Y Axis:
6572 <select class="chart-select" id="sub-y-axis">
6573 <option value="code">Code Lines</option>
6574 <option value="comment">Comment Lines</option>
6575 <option value="blank">Blank Lines</option>
6576 <option value="physical">Total Physical Lines</option>
6577 <option value="files">File Count</option>
6578 </select>
6579 </label>
6580 <label>Sort:
6581 <select class="chart-select" id="sub-sort">
6582 <option value="desc">Value ↓</option>
6583 <option value="asc">Value ↑</option>
6584 <option value="name">Name A→Z</option>
6585 </select>
6586 </label>
6587 </div>
6588 <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>
6589 </div>
6590 </section>
6591 <section class="panel stack chart-section">
6592 <div>
6593 <div class="toolbar">
6594 <div class="toolbar-left"><h2>Submodule Composition</h2></div>
6595 <button class="chart-expand-btn" id="sub-comp-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
6596 </div>
6597 <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>
6598 <div id="submodule-donut" style="width:100%;padding:4px 0;overflow:hidden;"></div>
6599 </div>
6600 </section>
6601 </div>
6602 {% endif %}
6603
6604 {% if has_cocomo %}
6605 <section class="panel" id="cocomo-section">
6606 <div class="toolbar">
6607 <div class="toolbar-left">
6608 <h2>Constructive Cost Model — COCOMO I</h2>
6609 <span class="cocomo-mode-pill-wrap" style="margin-left:12px;">
6610 <span class="pill" style="background:var(--surface-3);color:var(--muted);border:1px solid var(--line);font-size:11px;">{{ cocomo_mode_label }} mode</span>
6611 <span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
6612 </span>
6613 </div>
6614 </div>
6615 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
6616 <div class="stat-chip">
6617 <div class="stat-chip-label">Person-months</div>
6618 <div class="stat-chip-val">{{ cocomo_effort_str|commas }}</div>
6619 <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>
6620 </div>
6621 <div class="stat-chip">
6622 <div class="stat-chip-label">Schedule (months)</div>
6623 <div class="stat-chip-val">{{ cocomo_duration_str|commas }}</div>
6624 <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>
6625 </div>
6626 <div class="stat-chip">
6627 <div class="stat-chip-label">Avg. Team Size</div>
6628 <div class="stat-chip-val">{{ cocomo_staff_str|commas }}</div>
6629 <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>
6630 </div>
6631 <div class="stat-chip">
6632 <div class="stat-chip-label">Input KSLOC</div>
6633 <div class="stat-chip-val">{{ cocomo_ksloc_str|commas }}K</div>
6634 <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>
6635 </div>
6636 </div>
6637 <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>
6638 </section>
6639 {% endif %}
6640
6641 {% if has_hotspots %}
6642 <section class="panel" id="hotspots-section">
6643 <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>
6644 <p class="section-desc">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.{% if has_ownership %} The <strong>Author</strong> column shows each file's primary author from git blame (only shown when code-ownership attribution ran).{% endif %}</p>
6645 <p class="table-hint hs-hint">Click a column header to sort; drag its right edge to resize; hover a header for what it means.</p>
6646 <div class="table-shell">
6647 <table id="hotspots-table" data-sort-table class="table-resizable hotspots-table">
6648 <colgroup><col>{% if has_ownership %}<col>{% endif %}<col><col><col><col></colgroup>
6649 <thead><tr>
6650 <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>
6651 {% if has_ownership %}<th data-sort-type="text">Author<span class="col-tip">Primary author of this file — the contributor who owns the most of its lines, per git blame.</span><div class="col-resize-handle"></div></th>{% endif %}
6652 <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>
6653 <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>
6654 <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>
6655 <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>
6656 </tr></thead>
6657 <tbody>
6658 {% for h in hotspot_rows %}
6659 <tr>
6660 <td class="mono" title="{{ h.path }}">{{ h.path }}</td>
6661 {% if has_ownership %}<td>{% match h.owner_profile_url %}{% when Some with (url) %}<a class="author-link" href="{{ url }}" target="_blank" rel="noopener noreferrer">{{ h.owner }}</a>{% when None %}{{ h.owner }}{% endmatch %}</td>{% endif %}
6662 <td class="num-col">{{ h.code_lines|commas }}</td>
6663 <td class="num-col">{{ h.commit_count }}</td>
6664 <td class="num-col" style="font-weight:700;color:var(--oxide);">{{ h.score|commas }}</td>
6665 <td class="num-col" style="color:var(--muted);">{{ h.last_commit_date }}</td>
6666 </tr>
6667 {% endfor %}
6668 </tbody>
6669 </table>
6670 </div>
6671 <div id="hotspots-pagination" class="pagination-bar">
6672 <button id="hs-first" class="pager-btn pager-edge" disabled title="First page">⇤ First</button>
6673 <button id="hs-prev" class="pager-btn" disabled>← Prev</button>
6674 <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>
6675 <span id="hs-page-info" class="pager-info"></span>
6676 <button id="hs-next" class="pager-btn">Next →</button>
6677 <button id="hs-last" class="pager-btn pager-edge" title="Last page">Last ⇥</button>
6678 </div>
6679 </section>
6680 {% endif %}
6681
6682 {% if has_ownership %}
6683 <section class="panel" id="ownership-section">
6684 <div class="toolbar"><div class="toolbar-left"><h2>Code Ownership</h2><input id="ownership-search" class="search" type="search" placeholder="Filter authors..." /></div></div>
6685 <p class="section-desc">Per-author line ownership from <strong>git blame</strong> — each physical line is attributed to the author who last touched it (<code>-w -M -C</code>, <code>.mailmap</code> honoured), split into <strong>code / comment / blank</strong>. Same-email identities are merged automatically; cross-account merging is a later step.</p>
6686 <div class="summary-strip own-summary-strip">
6687 <div class="stat-chip"><div class="stat-chip-val">{{ own_contributors|commas }}</div><div class="stat-chip-label">Contributors</div><div class="stat-chip-tip">Distinct authors owning at least one line in this scan, after identity merges.</div></div>
6688 <div class="stat-chip"><div class="stat-chip-val">{{ own_top_name }}</div><div class="stat-chip-label">Top Owner · {{ own_top_pct_str }}% of code</div><div class="stat-chip-tip">The single contributor owning the largest share of code lines.</div></div>
6689 <div class="stat-chip"><div class="stat-chip-val">{{ own_bus_factor }}</div><div class="stat-chip-label">Bus Factor</div><div class="stat-chip-tip">Fewest contributors who together own at least half of the code — a low number means knowledge is concentrated in very few people.</div></div>
6690 <div class="stat-chip"><div class="stat-chip-val" data-fmt="{{ own_total_code }}">{{ own_total_code|commas }}</div><div class="stat-chip-label">Total Code Lines</div><div class="stat-chip-tip">Physical code lines attributed across all contributors.</div></div>
6691 <div class="stat-chip"><div class="stat-chip-val" data-fmt="{{ own_dev_code }}">{{ own_dev_code|commas }}</div><div class="stat-chip-label">Development Code</div><div class="stat-chip-tip">Code lines owned in non-test files (total code minus code in files detected as tests).</div></div>
6692 <div class="stat-chip"><div class="stat-chip-val" data-fmt="{{ own_test_code }}">{{ own_test_code|commas }}</div><div class="stat-chip-label">Test Code · {{ own_test_pct_str }}% of code</div><div class="stat-chip-tip">Code lines owned in files classified as tests (detected test functions/assertions or a test-path convention).</div></div>
6693 <div class="stat-chip"><div class="stat-chip-val" data-fmt="{{ own_total_comment }}">{{ own_total_comment|commas }}</div><div class="stat-chip-label">Total Comment Lines</div><div class="stat-chip-tip">Physical comment / documentation lines attributed across all contributors.</div></div>
6694 </div>
6695 <p class="table-hint hs-hint">Click a column header to sort; drag its right edge to resize.</p>
6696 <div class="table-shell">
6697 <table id="ownership-table" data-sort-table class="table-resizable">
6698 <colgroup><col><col><col><col><col><col><col><col></colgroup>
6699 <thead><tr>
6700 <th data-sort-type="text">Author<div class="col-resize-handle"></div></th>
6701 <th data-sort-type="text">Email<div class="col-resize-handle"></div></th>
6702 <th data-sort-type="number" class="num-col">Code<div class="col-resize-handle"></div></th>
6703 <th data-sort-type="number" class="num-col">Comment<div class="col-resize-handle"></div></th>
6704 <th data-sort-type="number" class="num-col">Blank<div class="col-resize-handle"></div></th>
6705 <th data-sort-type="number" class="num-col">Total<div class="col-resize-handle"></div></th>
6706 <th data-sort-type="number" class="num-col">Code %<div class="col-resize-handle"></div></th>
6707 <th data-sort-type="number" class="num-col">Files owned<div class="col-resize-handle"></div></th>
6708 </tr></thead>
6709 <tbody>
6710 {% for a in ownership_rows %}
6711 <tr>
6712 <td>{% match a.profile_url %}{% when Some with (url) %}<a class="author-link" href="{{ url }}" target="_blank" rel="noopener noreferrer">{{ a.name }}</a>{% when None %}{{ a.name }}{% endmatch %}</td>
6713 <td class="mono" style="color:var(--muted);">{{ a.email }}</td>
6714 <td class="num-col">{{ a.code|commas }}</td>
6715 <td class="num-col">{{ a.comment|commas }}</td>
6716 <td class="num-col">{{ a.blank|commas }}</td>
6717 <td class="num-col">{{ a.total|commas }}</td>
6718 <td class="num-col" style="font-weight:700;color:var(--oxide);">{{ a.code_pct_str }}%</td>
6719 <td class="num-col">{{ a.files_owned }}</td>
6720 </tr>
6721 {% endfor %}
6722 </tbody>
6723 </table>
6724 </div>
6725 <h3 class="own-files-title">Contributor Leaderboard</h3>
6726 <p class="own-files-sub">Ranked by <strong>code lines owned</strong>. Expand a contributor to see the files they own (files where they are the top blame owner), with the lines they own in each — tying ownership to the specific files, including the Git Hotspots.</p>
6727 {% for a in ownership_rows %}
6728 {% if !a.files.is_empty() %}
6729 <details class="own-details">
6730 <summary>
6731 <span class="lb-rank {{ a.rank_class }}">{{ a.rank }}</span>
6732 <span class="lb-avatar" style="background:{{ a.color }};">{{ a.initials }}</span>
6733 <span class="lb-name">{% match a.profile_url %}{% when Some with (url) %}<a class="author-link" href="{{ url }}" target="_blank" rel="noopener noreferrer">{{ a.name }}</a>{% when None %}{{ a.name }}{% endmatch %}</span>
6734 <span class="lb-bar-wrap"><span class="lb-bar" style="width:{{ a.code_pct_str }}%;background:{{ a.color }};"></span></span>
6735 <span class="lb-stats"><strong>{{ a.code|commas }}</strong> code lines · {{ a.files_owned }} file{% if a.files_owned != 1 %}s{% endif %} · {{ a.code_pct_str }}%</span>
6736 </summary>
6737 <div class="table-shell own-files-shell">
6738 <table class="table-resizable">
6739 <colgroup><col><col><col><col><col><col></colgroup>
6740 <thead><tr>
6741 <th data-sort-type="text">File</th>
6742 <th data-sort-type="number" class="num-col">Code</th>
6743 <th data-sort-type="number" class="num-col">Comment</th>
6744 <th data-sort-type="number" class="num-col">Blank</th>
6745 <th data-sort-type="number" class="num-col">Total</th>
6746 <th data-sort-type="text" class="num-col">Last changed</th>
6747 </tr></thead>
6748 <tbody>
6749 {% for f in a.files %}
6750 <tr>
6751 <td class="mono" title="{{ f.path }}">{{ f.path }}</td>
6752 <td class="num-col">{{ f.code|commas }}</td>
6753 <td class="num-col">{{ f.comment|commas }}</td>
6754 <td class="num-col">{{ f.blank|commas }}</td>
6755 <td class="num-col">{{ f.total|commas }}</td>
6756 <td class="num-col" style="color:var(--muted);">{{ f.last_changed }}</td>
6757 </tr>
6758 {% endfor %}
6759 </tbody>
6760 </table>
6761 </div>
6762 </details>
6763 {% endif %}
6764 {% endfor %}
6765 </section>
6766 {% endif %}
6767
6768 <section class="panel stack">
6769 <div>
6770 <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>
6771 <div id="report-lang-overview" style="margin:0 0 16px;"></div>
6772 <div class="table-shell">
6773 <table id="lang-breakdown-table" data-sort-table class="table-resizable">
6774 <colgroup>
6775 <col><col><col><col><col><col><col><col><col><col><col><col><col><col>
6776 </colgroup>
6777 <thead>
6778 <tr>
6779 <th data-sort-type="text">Language<div class="col-resize-handle"></div></th>
6780 <th data-sort-type="number" class="num-col">Files<div class="col-resize-handle"></div></th>
6781 <th data-sort-type="number" class="num-col">Physical<div class="col-resize-handle"></div></th>
6782 <th data-sort-type="number" class="num-col">Code<div class="col-resize-handle"></div></th>
6783 <th data-sort-type="number" class="num-col">Comments<div class="col-resize-handle"></div></th>
6784 <th data-sort-type="number" class="num-col">Blank<div class="col-resize-handle"></div></th>
6785 <th data-sort-type="number" class="num-col">Mixed<div class="col-resize-handle"></div></th>
6786 <th data-sort-type="number" class="num-col">Functions<div class="col-resize-handle"></div></th>
6787 <th data-sort-type="number" class="num-col">Classes<div class="col-resize-handle"></div></th>
6788 <th data-sort-type="number" class="num-col">Variables<div class="col-resize-handle"></div></th>
6789 <th data-sort-type="number" class="num-col">Imports<div class="col-resize-handle"></div></th>
6790 <th data-sort-type="number" class="num-col">Tests<div class="col-resize-handle"></div></th>
6791 <th data-sort-type="number" class="num-col">Assertions<div class="col-resize-handle"></div></th>
6792 <th data-sort-type="number" class="num-col">Suites<div class="col-resize-handle"></div></th>
6793 </tr>
6794 </thead>
6795 <tbody>
6796 {% for row in language_rows %}
6797 <tr>
6798 <td title="{{ row.language }}">{{ row.language }}</td>
6799 <td class="num-col">{{ row.files|commas }}</td>
6800 <td class="num-col">{{ row.total_physical_lines|commas }}</td>
6801 <td class="num-col">{{ row.code_lines|commas }}</td>
6802 <td class="num-col">{{ row.comment_lines|commas }}</td>
6803 <td class="num-col">{{ row.blank_lines|commas }}</td>
6804 <td class="num-col">{{ row.mixed_lines_separate|commas }}</td>
6805 <td class="num-col">{{ row.functions|commas }}</td>
6806 <td class="num-col">{{ row.classes|commas }}</td>
6807 <td class="num-col">{{ row.variables|commas }}</td>
6808 <td class="num-col">{{ row.imports|commas }}</td>
6809 <td class="num-col">{{ row.test_count|commas }}</td>
6810 <td class="num-col">{{ row.test_assertion_count|commas }}</td>
6811 <td class="num-col">{{ row.test_suite_count|commas }}</td>
6812 </tr>
6813 {% endfor %}
6814 </tbody>
6815 </table>
6816 </div>
6817 </div>
6818 </section>
6819
6820 <section class="panel stack">
6821 <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>
6822 <div class="table-shell table-shell-clip">
6823 <div id="per-file-shell">
6824 <table id="per-file-table" data-sort-table class="table-resizable">
6825 <colgroup>
6826 <col><col><col><col><col><col><col><col><col><col><col><col><col><col>
6827 </colgroup>
6828 <thead>
6829 <tr>
6830 <th data-sort-type="text">File<div class="col-resize-handle"></div></th>
6831 <th data-sort-type="text">Language<div class="col-resize-handle"></div></th>
6832 <th data-sort-type="number" class="num-col">Physical<div class="col-resize-handle"></div></th>
6833 <th data-sort-type="number" class="num-col">Code<div class="col-resize-handle"></div></th>
6834 <th data-sort-type="number" class="num-col">Comments<div class="col-resize-handle"></div></th>
6835 <th data-sort-type="number" class="num-col">Blank<div class="col-resize-handle"></div></th>
6836 <th data-sort-type="number" class="num-col">Mixed<div class="col-resize-handle"></div></th>
6837 <th data-sort-type="number" class="num-col">Functions<div class="col-resize-handle"></div></th>
6838 <th data-sort-type="number" class="num-col">Classes<div class="col-resize-handle"></div></th>
6839 <th data-sort-type="number" class="num-col">Variables<div class="col-resize-handle"></div></th>
6840 <th data-sort-type="number" class="num-col">Imports<div class="col-resize-handle"></div></th>
6841 <th data-sort-type="number" class="num-col">Tests<div class="col-resize-handle"></div></th>
6842 <th data-sort-type="number" class="num-col">Assertions<div class="col-resize-handle"></div></th>
6843 <th data-sort-type="number" class="num-col">Suites<div class="col-resize-handle"></div></th>
6844 {% 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 %}
6845 </tr>
6846 </thead>
6847 <tbody>
6848 {% for row in file_rows %}
6849 <tr>
6850 <td class="mono" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
6851 <td title="{{ row.language }}">{{ row.language }}</td>
6852 <td class="num-col">{{ row.total_physical_lines }}</td>
6853 <td class="num-col">{{ row.code_lines }}</td>
6854 <td class="num-col">{{ row.comment_lines }}</td>
6855 <td class="num-col">{{ row.blank_lines }}</td>
6856 <td class="num-col">{{ row.mixed_lines_separate }}</td>
6857 <td class="num-col">{{ row.functions }}</td>
6858 <td class="num-col">{{ row.classes }}</td>
6859 <td class="num-col">{{ row.variables }}</td>
6860 <td class="num-col">{{ row.imports }}</td>
6861 <td class="num-col">{{ row.test_count }}</td>
6862 <td class="num-col">{{ row.test_assertion_count }}</td>
6863 <td class="num-col">{{ row.test_suite_count }}</td>
6864 {% if has_coverage_data %}<td class="num-col">{{ row.line_cov_pct }}</td><td class="num-col">{{ row.fn_cov_pct }}</td>{% endif %}
6865 </tr>
6866 {% endfor %}
6867 </tbody>
6868 </table>
6869 </div>
6870 </div>
6871 <div id="per-file-pagination" class="pagination-bar">
6872 <button id="pf-first" class="pager-btn pager-edge" disabled title="First page">⇤ First</button>
6873 <button id="pf-prev" class="pager-btn" disabled>← Prev</button>
6874 <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>
6875 <span id="pf-page-info" class="pager-info"></span>
6876 <button id="pf-next" class="pager-btn">Next →</button>
6877 <button id="pf-last" class="pager-btn pager-edge" title="Last page">Last ⇥</button>
6878 </div>
6879 </section>
6880
6881 <section class="panel stack">
6882 <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>
6883 <div class="table-shell table-shell-clip" style="margin-top:6px;">
6884 <div id="skipped-shell">
6885 <table id="skipped-table" data-sort-table class="table-resizable">
6886 <thead>
6887 <tr>
6888 <th data-sort-type="text" style="width:42%">File</th>
6889 <th data-sort-type="text" style="width:20%">Status</th>
6890 <th data-sort-type="text" style="width:38%">Warnings</th>
6891 </tr>
6892 </thead>
6893 <tbody>
6894 {% for row in skipped_rows %}
6895 <tr>
6896 <td class="mono" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
6897 <td><span class="status-tag status-{{ row.status_class }}">{{ row.status }}</span></td>
6898 <td class="small" title="{{ row.warnings }}">{{ row.warnings }}</td>
6899 </tr>
6900 {% endfor %}
6901 </tbody>
6902 </table>
6903 </div>
6904 </div>
6905 <div id="skipped-pagination" class="pagination-bar">
6906 <button id="sk-first" class="pager-btn pager-edge" disabled title="First page">⇤ First</button>
6907 <button id="sk-prev" class="pager-btn" disabled>← Prev</button>
6908 <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>
6909 <span id="sk-page-info" class="pager-info"></span>
6910 <button id="sk-next" class="pager-btn">Next →</button>
6911 <button id="sk-last" class="pager-btn pager-edge" title="Last page">Last ⇥</button>
6912 </div>
6913 </section>
6914
6915 <section class="panel stack">
6916 <div>
6917 <div class="toolbar">
6918 <div class="toolbar-left"><h2>Diagnostics & Configuration</h2></div>
6919 {% 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 %}
6920 </div>
6921 <p class="effective-config-note">Warning summary, support improvement opportunities, raw diagnostic output, and the exact configuration in effect for this scan.</p>
6922 </div>
6923
6924 {% if !is_sub_report %}
6925 <div style="margin-top:-14px;">
6926 <h3 style="margin:0 0 4px;">Warnings overview</h3>
6927 <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>
6928 {% if !has_run_warnings %}
6929 <div class="pill good">No top-level warnings.</div>
6930 {% else %}
6931 <div class="table-shell">
6932 <table class="support-table">
6933 <thead>
6934 <tr><th style="width:30%;">Category</th><th style="width:8%;">Count</th><th>What this means</th></tr>
6935 </thead>
6936 <tbody>
6937 {% for row in warning_summary_rows %}
6938 <tr class="{{ row.tone_class }}">
6939 <td style="font-weight:700;" title="{{ row.label }}">{{ row.label }}</td>
6940 <td class="warning-count" style="font-weight:800;">{{ row.count }}</td>
6941 <td class="small" style="color:var(--muted);">{{ row.detail }}</td>
6942 </tr>
6943 {% endfor %}
6944 </tbody>
6945 </table>
6946 </div>
6947 {% endif %}
6948 </div>
6949
6950 <div>
6951 <h3 style="margin:0 0 4px;">Skipped file categories</h3>
6952 <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>
6953 {% if warning_opportunity_rows.is_empty() %}
6954 <div class="pill good">No unsupported text-format buckets detected.</div>
6955 {% else %}
6956 <div class="table-shell">
6957 <table class="support-table">
6958 <thead>
6959 <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>
6960 </thead>
6961 <tbody>
6962 {% for row in warning_opportunity_rows %}
6963 <tr>
6964 <td style="font-weight:700;" title="{{ row.label }}">{{ row.label }}</td>
6965 <td style="font-weight:800;color:var(--oxide);">{{ row.count }}</td>
6966 <td class="small" style="color:var(--muted);">{{ row.bucket_description }}</td>
6967 <td>
6968 {% if !row.example_files.is_empty() %}
6969 <div style="margin-bottom:6px;">
6970 {% for f in row.example_files %}<span class="support-example-file">{{ f }}</span> {% endfor %}
6971 {% 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 %}
6972 </div>
6973 {% endif %}
6974 <p class="support-recommendation">{{ row.recommendation }}</p>
6975 </td>
6976 </tr>
6977 {% endfor %}
6978 </tbody>
6979 </table>
6980 </div>
6981 {% endif %}
6982 </div>
6983
6984 <div>
6985 <details open class="warnings-details">
6986 <summary>Detailed run warnings ({{ warning_count }})</summary>
6987 <div>
6988 <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>
6989 {% if !has_run_warnings %}
6990 <div class="pill good">No top-level warnings.</div>
6991 {% else %}
6992 <div class="code-block-toolbar">
6993 <button type="button" class="code-copy-btn" id="warning-console-copy-btn" aria-label="Copy warnings">Copy</button>
6994 </div>
6995 <pre class="warning-console" id="warning-console-full" style="max-height:210px;">{{ warning_console_full }}</pre>
6996 {% endif %}
6997 </div>
6998 </details>
6999 </div>
7000 {% endif %}
7001
7002 <div>
7003 <details open>
7004 <summary>Effective configuration</summary>
7005 <div>
7006 <div style="display:flex;gap:8px;margin-bottom:10px;">
7007 <button type="button" class="export-btn" data-copy-config>Copy</button>
7008 <button type="button" class="export-btn" data-download-config>Download</button>
7009 </div>
7010 <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>
7011 <div class="config-pre-wrap">
7012 <div class="code-block-toolbar">
7013 <button type="button" class="code-copy-btn" id="config-inline-copy-btn" aria-label="Copy configuration">Copy</button>
7014 </div>
7015 <pre class="config-pre" id="config-json-block">{{ config_json }}</pre>
7016 </div>
7017 </div>
7018 </details>
7019 </div>
7020 </section>
7021 </div>
7022 </div>
7023
7024 <div id="r-tt" aria-hidden="true"></div>
7025 <script nonce="{{ nonce }}">
7026 // Hide "View PDF" button and block brand-link navigation when opened as a local file
7027 (function () {
7028 var pdfBtn = document.getElementById('nav-view-pdf-btn');
7029 if (pdfBtn && window.location.protocol === 'file:') {
7030 pdfBtn.style.display = 'none';
7031 }
7032 var brand = document.querySelector('a[data-local-brand]');
7033 if (brand && window.location.protocol === 'file:') {
7034 brand.addEventListener('click', function (e) { e.preventDefault(); });
7035 }
7036 })();
7037
7038 (function () {
7039 var body = document.body;
7040 var storageKey = 'oxide-sloc-theme';
7041 var themeToggle = document.querySelector('[data-theme-toggle]');
7042 var copyLinkButtons = Array.prototype.slice.call(document.querySelectorAll('[data-copy-link]'));
7043 var shareButtons = Array.prototype.slice.call(document.querySelectorAll('[data-share-report]'));
7044 var printButtons = Array.prototype.slice.call(document.querySelectorAll('[data-print-report]'));
7045
7046 function applyTheme(theme) {
7047 body.classList.toggle('dark-theme', theme === 'dark');
7048 }
7049
7050 function currentTheme() {
7051 return body.classList.contains('dark-theme') ? 'dark' : 'light';
7052 }
7053
7054 try {
7055 var saved = localStorage.getItem(storageKey);
7056 if (saved === 'dark' || saved === 'light') {
7057 applyTheme(saved);
7058 }
7059 } catch (e) {}
7060
7061 if (themeToggle) {
7062 themeToggle.addEventListener('click', function () {
7063 var next = currentTheme() === 'dark' ? 'light' : 'dark';
7064 applyTheme(next);
7065 try { localStorage.setItem(storageKey, next); } catch (e) {}
7066 });
7067 }
7068
7069 function copyText(value) {
7070 if (!value) return;
7071 if (navigator.clipboard && navigator.clipboard.writeText) {
7072 navigator.clipboard.writeText(value).catch(function () {});
7073 }
7074 }
7075
7076 copyLinkButtons.forEach(function (button) {
7077 button.addEventListener('click', function () {
7078 copyText(window.location.href);
7079 });
7080 });
7081
7082 shareButtons.forEach(function (button) {
7083 button.addEventListener('click', function () {
7084 if (navigator.share) {
7085 navigator.share({ title: document.title, url: window.location.href }).catch(function () {});
7086 } else {
7087 copyText(window.location.href);
7088 }
7089 });
7090 });
7091
7092 printButtons.forEach(function (button) {
7093 button.addEventListener('click', function () {
7094 window.print();
7095 });
7096 });
7097
7098 // "View PDF" nav button.
7099 // Priority order:
7100 // 1. data-standalone-pdf attr — pre-generated PDF in the same directory
7101 // (set when oxide-sloc CLI was invoked with both --html-out and
7102 // --pdf-out). Opens the file directly; works in Jenkins HTML Publisher.
7103 // 2. Server route (/runs/pdf/<id>) — oxide-sloc web server generates
7104 // the PDF on demand via headless Chrome. Checked via HEAD request.
7105 // 3. Neither available — inform the user how to generate a PDF via CLI.
7106 var pdfNavBtn = document.getElementById('nav-view-pdf-btn');
7107 if (pdfNavBtn) {
7108 pdfNavBtn.addEventListener('click', function (e) {
7109 e.preventDefault();
7110 var standaloneUrl = pdfNavBtn.getAttribute('data-standalone-pdf');
7111 if (standaloneUrl) {
7112 window.open(standaloneUrl, '_blank', 'noopener');
7113 return;
7114 }
7115 var serverUrl = pdfNavBtn.getAttribute('href');
7116 var xhr = new XMLHttpRequest();
7117 xhr.open('HEAD', serverUrl, true);
7118 xhr.onreadystatechange = function () {
7119 if (xhr.readyState === 4) {
7120 if (xhr.status >= 200 && xhr.status < 300) {
7121 window.open(serverUrl, '_blank', 'noopener');
7122 } else {
7123 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.');
7124 }
7125 }
7126 };
7127 xhr.onerror = function () {
7128 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.');
7129 };
7130 xhr.send();
7131 });
7132 }
7133
7134 var copyConfigBtn = document.querySelector('[data-copy-config]');
7135 var downloadConfigBtn = document.querySelector('[data-download-config]');
7136 var configBlock = document.getElementById('config-json-block');
7137 var inlineCopyBtn = document.getElementById('config-inline-copy-btn');
7138 function handleConfigCopy(btn) {
7139 if (!btn || !configBlock) return;
7140 btn.addEventListener('click', function (e) {
7141 e.stopPropagation();
7142 copyText(configBlock.textContent);
7143 var orig = btn.textContent;
7144 btn.textContent = 'Copied!';
7145 setTimeout(function () { btn.textContent = orig; }, 1600);
7146 });
7147 }
7148 handleConfigCopy(copyConfigBtn);
7149 handleConfigCopy(inlineCopyBtn);
7150
7151 var warnCopyBtn = document.getElementById('warning-console-copy-btn');
7152 var warnBlock = document.getElementById('warning-console-full');
7153 if (warnCopyBtn && warnBlock) {
7154 warnCopyBtn.addEventListener('click', function () {
7155 copyText(warnBlock.textContent);
7156 var orig = warnCopyBtn.textContent;
7157 warnCopyBtn.textContent = 'Copied!';
7158 setTimeout(function () { warnCopyBtn.textContent = orig; }, 1600);
7159 });
7160 }
7161
7162 if (downloadConfigBtn && configBlock) {
7163 downloadConfigBtn.addEventListener('click', function (e) {
7164 e.stopPropagation();
7165 var blob = new Blob([configBlock.textContent], { type: 'application/json' });
7166 var url = URL.createObjectURL(blob);
7167 var a = document.createElement('a');
7168 a.href = url; a.download = 'effective-config.json';
7169 document.body.appendChild(a); a.click();
7170 document.body.removeChild(a);
7171 setTimeout(function () { URL.revokeObjectURL(url); }, 200);
7172 });
7173 }
7174
7175 function detectType(value) {
7176 // Strip thousands separators so comma-formatted numbers (e.g. "121,542")
7177 // still sort numerically rather than lexicographically.
7178 var v = value.trim().replace(/,/g, '');
7179 return /^-?\d+(?:\.\d+)?$/.test(v) ? parseFloat(v) : value.trim().toLowerCase();
7180 }
7181
7182 document.querySelectorAll('[data-sort-table]').forEach(function (table) {
7183 var headers = Array.prototype.slice.call(table.querySelectorAll('th'));
7184 var allMarkers = [];
7185 headers.forEach(function (th, idx) {
7186 var direction = 1;
7187 var marker = document.createElement('span');
7188 marker.className = 'sort-indicator';
7189 marker.textContent = ' \u2195';
7190 th.style.cursor = 'pointer';
7191 th.appendChild(marker);
7192 allMarkers.push(marker);
7193 th.addEventListener('click', function (e) {
7194 if (e.target.closest && e.target.closest('.col-resize-handle')) return;
7195 var tbody = table.tBodies[0];
7196 var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
7197 rows.sort(function (a, b) {
7198 var av = detectType((a.children[idx].textContent || '').trim());
7199 var bv = detectType((b.children[idx].textContent || '').trim());
7200 if (av < bv) return -1 * direction;
7201 if (av > bv) return 1 * direction;
7202 return 0;
7203 });
7204 rows.forEach(function (row) { tbody.appendChild(row); });
7205 allMarkers.forEach(function(m) { m.textContent = ' \u2195'; });
7206 direction = direction * -1;
7207 marker.textContent = direction === -1 ? ' \u2191' : ' \u2193';
7208 table.dispatchEvent(new CustomEvent('sloc-sorted'));
7209 });
7210 });
7211 });
7212
7213 // ── Column resize for all table-resizable tables ──────────────────────────
7214 (function() {
7215 document.querySelectorAll('.table-resizable').forEach(function(table) {
7216 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
7217 var ths = Array.prototype.slice.call(table.querySelectorAll('thead th'));
7218 ths.forEach(function(th, i) {
7219 var handle = th.querySelector('.col-resize-handle');
7220 if (!handle || !cols[i]) return;
7221 var startX, startW;
7222 handle.addEventListener('mousedown', function(e) {
7223 e.stopPropagation(); e.preventDefault();
7224 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
7225 handle.classList.add('dragging');
7226 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
7227 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
7228 document.addEventListener('mousemove', onMove);
7229 document.addEventListener('mouseup', onUp);
7230 });
7231 });
7232 });
7233 })();
7234
7235 document.querySelectorAll('[data-table-filter]').forEach(function (input) {
7236 var table = document.getElementById(input.getAttribute('data-table-filter'));
7237 if (!table) return;
7238 var filterTimer = null;
7239 var rowCache = null;
7240 input.addEventListener('input', function () {
7241 clearTimeout(filterTimer);
7242 var q = input.value.toLowerCase();
7243 filterTimer = setTimeout(function () {
7244 if (!rowCache) {
7245 rowCache = Array.prototype.map.call(table.tBodies[0].rows, function (row) {
7246 return { row: row, text: row.textContent.toLowerCase() };
7247 });
7248 }
7249 rowCache.forEach(function (item) {
7250 item.row.style.display = q === '' || item.text.indexOf(q) >= 0 ? '' : 'none';
7251 });
7252 }, 200);
7253 });
7254 });
7255
7256 // ── Per-file table pagination ────────────────────────────────────────────
7257 (function () {
7258 var table = document.getElementById('per-file-table');
7259 if (!table) return;
7260 var tbody = table.tBodies[0];
7261 var searchInput = document.getElementById('per-file-search');
7262 var pageSizeSelect = document.getElementById('per-file-page-size');
7263 var firstBtn = document.getElementById('pf-first');
7264 var prevBtn = document.getElementById('pf-prev');
7265 var nextBtn = document.getElementById('pf-next');
7266 var lastBtn = document.getElementById('pf-last');
7267 var pageInfo = document.getElementById('pf-page-info');
7268 var jumpInput = document.getElementById('pf-page-jump');
7269 var pageTotal = document.getElementById('pf-page-total');
7270 var countLabel = document.getElementById('per-file-count-label');
7271 var filteredRows = [];
7272 var currentPage = 1;
7273 var totalAll = tbody.rows.length;
7274
7275 function getPageSize() {
7276 var v = pageSizeSelect ? pageSizeSelect.value : '20';
7277 return v === 'all' ? Infinity : parseInt(v, 10);
7278 }
7279
7280 function applyFilter() {
7281 var q = searchInput ? searchInput.value.toLowerCase() : '';
7282 var rows = Array.prototype.slice.call(tbody.rows);
7283 filteredRows = q === '' ? rows : rows.filter(function (row) {
7284 return row.textContent.toLowerCase().indexOf(q) >= 0;
7285 });
7286 currentPage = 1;
7287 render();
7288 }
7289
7290 function render() {
7291 var ps = getPageSize();
7292 var total = filteredRows.length;
7293 var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(total / ps));
7294 if (currentPage > totalPages) currentPage = totalPages;
7295 if (currentPage < 1) currentPage = 1;
7296 var start = ps === Infinity ? 0 : (currentPage - 1) * ps;
7297 var end = ps === Infinity ? total : Math.min(start + ps, total);
7298 Array.prototype.forEach.call(tbody.rows, function (row) { row.style.display = 'none'; });
7299 for (var i = start; i < end; i++) { filteredRows[i].style.display = ''; }
7300 if (pageInfo) {
7301 if (total === 0) {
7302 pageInfo.textContent = 'No results';
7303 } else if (ps === Infinity) {
7304 pageInfo.textContent = 'All ' + total.toLocaleString() + ' files';
7305 } else {
7306 pageInfo.textContent = (start + 1) + '\u2013' + end + ' of ' + total.toLocaleString() + ' files';
7307 }
7308 }
7309 if (countLabel) {
7310 countLabel.textContent = (total < totalAll && total > 0) ? '(' + total.toLocaleString() + ' matching)' : '';
7311 }
7312 var edgeDisabled = ps === Infinity;
7313 if (firstBtn) firstBtn.disabled = currentPage <= 1 || edgeDisabled;
7314 if (prevBtn) prevBtn.disabled = currentPage <= 1 || edgeDisabled;
7315 if (nextBtn) nextBtn.disabled = currentPage >= totalPages || edgeDisabled;
7316 if (lastBtn) lastBtn.disabled = currentPage >= totalPages || edgeDisabled;
7317 if (jumpInput) { jumpInput.value = currentPage; jumpInput.max = totalPages; jumpInput.disabled = edgeDisabled; }
7318 if (pageTotal) pageTotal.textContent = totalPages.toLocaleString();
7319 }
7320
7321 if (searchInput) {
7322 var filterTimer = null;
7323 searchInput.addEventListener('input', function () {
7324 clearTimeout(filterTimer);
7325 filterTimer = setTimeout(applyFilter, 200);
7326 });
7327 }
7328 if (pageSizeSelect) {
7329 pageSizeSelect.addEventListener('change', function () { currentPage = 1; render(); });
7330 }
7331 if (firstBtn) {
7332 firstBtn.addEventListener('click', function () { currentPage = 1; render(); });
7333 }
7334 if (prevBtn) {
7335 prevBtn.addEventListener('click', function () { if (currentPage > 1) { currentPage--; render(); } });
7336 }
7337 if (nextBtn) {
7338 nextBtn.addEventListener('click', function () {
7339 var ps = getPageSize();
7340 var totalPages = ps === Infinity ? 1 : Math.ceil(filteredRows.length / ps);
7341 if (currentPage < totalPages) { currentPage++; render(); }
7342 });
7343 }
7344 if (lastBtn) {
7345 lastBtn.addEventListener('click', function () {
7346 var ps = getPageSize();
7347 currentPage = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
7348 render();
7349 });
7350 }
7351 if (jumpInput) {
7352 function pfJump() {
7353 var ps = getPageSize();
7354 var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
7355 var v = parseInt(jumpInput.value, 10);
7356 if (!isNaN(v)) { currentPage = Math.max(1, Math.min(v, totalPages)); render(); }
7357 }
7358 jumpInput.addEventListener('change', pfJump);
7359 jumpInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') pfJump(); });
7360 }
7361 table.addEventListener('sloc-sorted', function () { applyFilter(); });
7362 window._pfPaginationReset = function () { currentPage = 1; applyFilter(); };
7363 applyFilter();
7364 })();
7365
7366 // ── Skipped-files table pagination ───────────────────────────────────────
7367 (function () {
7368 var table = document.getElementById('skipped-table');
7369 if (!table) return;
7370 var tbody = table.tBodies[0];
7371 var searchInput = document.getElementById('skipped-search');
7372 var pageSizeSelect = document.getElementById('skipped-page-size');
7373 var firstBtn = document.getElementById('sk-first');
7374 var prevBtn = document.getElementById('sk-prev');
7375 var nextBtn = document.getElementById('sk-next');
7376 var lastBtn = document.getElementById('sk-last');
7377 var pageInfo = document.getElementById('sk-page-info');
7378 var jumpInput = document.getElementById('sk-page-jump');
7379 var pageTotal = document.getElementById('sk-page-total');
7380 var countLabel = document.getElementById('skipped-count-label');
7381 var filteredRows = [];
7382 var currentPage = 1;
7383 var totalAll = tbody.rows.length;
7384
7385 function getPageSize() {
7386 var v = pageSizeSelect ? pageSizeSelect.value : '10';
7387 return v === 'all' ? Infinity : parseInt(v, 10);
7388 }
7389
7390 function applyFilter() {
7391 var q = searchInput ? searchInput.value.toLowerCase() : '';
7392 var rows = Array.prototype.slice.call(tbody.rows);
7393 filteredRows = q === '' ? rows : rows.filter(function (row) {
7394 return row.textContent.toLowerCase().indexOf(q) >= 0;
7395 });
7396 currentPage = 1;
7397 render();
7398 }
7399
7400 function render() {
7401 var ps = getPageSize();
7402 var total = filteredRows.length;
7403 var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(total / ps));
7404 if (currentPage > totalPages) currentPage = totalPages;
7405 if (currentPage < 1) currentPage = 1;
7406 var start = ps === Infinity ? 0 : (currentPage - 1) * ps;
7407 var end = ps === Infinity ? total : Math.min(start + ps, total);
7408 Array.prototype.forEach.call(tbody.rows, function (row) { row.style.display = 'none'; });
7409 for (var i = start; i < end; i++) { filteredRows[i].style.display = ''; }
7410 if (pageInfo) {
7411 if (total === 0) {
7412 pageInfo.textContent = 'No results';
7413 } else if (ps === Infinity) {
7414 pageInfo.textContent = 'All ' + total.toLocaleString() + ' files';
7415 } else {
7416 pageInfo.textContent = (start + 1) + '\u2013' + end + ' of ' + total.toLocaleString() + ' files';
7417 }
7418 }
7419 if (countLabel) {
7420 countLabel.textContent = (total < totalAll && total > 0) ? '(' + total.toLocaleString() + ' matching)' : '';
7421 }
7422 var edgeDisabled = ps === Infinity;
7423 if (firstBtn) firstBtn.disabled = currentPage <= 1 || edgeDisabled;
7424 if (prevBtn) prevBtn.disabled = currentPage <= 1 || edgeDisabled;
7425 if (nextBtn) nextBtn.disabled = currentPage >= totalPages || edgeDisabled;
7426 if (lastBtn) lastBtn.disabled = currentPage >= totalPages || edgeDisabled;
7427 if (jumpInput) { jumpInput.value = currentPage; jumpInput.max = totalPages; jumpInput.disabled = edgeDisabled; }
7428 if (pageTotal) pageTotal.textContent = totalPages.toLocaleString();
7429 }
7430
7431 if (searchInput) {
7432 var filterTimer = null;
7433 searchInput.addEventListener('input', function () {
7434 clearTimeout(filterTimer);
7435 filterTimer = setTimeout(applyFilter, 200);
7436 });
7437 }
7438 if (pageSizeSelect) {
7439 pageSizeSelect.addEventListener('change', function () { currentPage = 1; render(); });
7440 }
7441 if (firstBtn) {
7442 firstBtn.addEventListener('click', function () { currentPage = 1; render(); });
7443 }
7444 if (prevBtn) {
7445 prevBtn.addEventListener('click', function () { if (currentPage > 1) { currentPage--; render(); } });
7446 }
7447 if (nextBtn) {
7448 nextBtn.addEventListener('click', function () {
7449 var ps = getPageSize();
7450 var totalPages = ps === Infinity ? 1 : Math.ceil(filteredRows.length / ps);
7451 if (currentPage < totalPages) { currentPage++; render(); }
7452 });
7453 }
7454 if (lastBtn) {
7455 lastBtn.addEventListener('click', function () {
7456 var ps = getPageSize();
7457 currentPage = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
7458 render();
7459 });
7460 }
7461 if (jumpInput) {
7462 function skJump() {
7463 var ps = getPageSize();
7464 var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
7465 var v = parseInt(jumpInput.value, 10);
7466 if (!isNaN(v)) { currentPage = Math.max(1, Math.min(v, totalPages)); render(); }
7467 }
7468 jumpInput.addEventListener('change', skJump);
7469 jumpInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') skJump(); });
7470 }
7471 table.addEventListener('sloc-sorted', function () { applyFilter(); });
7472 applyFilter();
7473 })();
7474
7475 // ── Hotspots table pagination ────────────────────────────────────────────
7476 (function () {
7477 var table = document.getElementById('hotspots-table');
7478 if (!table) return;
7479 var tbody = table.tBodies[0];
7480 var searchInput = document.getElementById('hotspots-search');
7481 var pageSizeSelect = document.getElementById('hotspots-page-size');
7482 var firstBtn = document.getElementById('hs-first');
7483 var prevBtn = document.getElementById('hs-prev');
7484 var nextBtn = document.getElementById('hs-next');
7485 var lastBtn = document.getElementById('hs-last');
7486 var pageInfo = document.getElementById('hs-page-info');
7487 var jumpInput = document.getElementById('hs-page-jump');
7488 var pageTotal = document.getElementById('hs-page-total');
7489 var countLabel = document.getElementById('hotspots-count-label');
7490 var filteredRows = [];
7491 var currentPage = 1;
7492 var totalAll = tbody.rows.length;
7493
7494 function getPageSize() {
7495 var v = pageSizeSelect ? pageSizeSelect.value : '15';
7496 return v === 'all' ? Infinity : parseInt(v, 10);
7497 }
7498
7499 function applyFilter() {
7500 var q = searchInput ? searchInput.value.toLowerCase() : '';
7501 var rows = Array.prototype.slice.call(tbody.rows);
7502 filteredRows = q === '' ? rows : rows.filter(function (row) {
7503 return row.textContent.toLowerCase().indexOf(q) >= 0;
7504 });
7505 currentPage = 1;
7506 render();
7507 }
7508
7509 function render() {
7510 var ps = getPageSize();
7511 var total = filteredRows.length;
7512 var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(total / ps));
7513 if (currentPage > totalPages) currentPage = totalPages;
7514 if (currentPage < 1) currentPage = 1;
7515 var start = ps === Infinity ? 0 : (currentPage - 1) * ps;
7516 var end = ps === Infinity ? total : Math.min(start + ps, total);
7517 Array.prototype.forEach.call(tbody.rows, function (row) { row.style.display = 'none'; });
7518 for (var i = start; i < end; i++) { filteredRows[i].style.display = ''; }
7519 if (pageInfo) {
7520 if (total === 0) {
7521 pageInfo.textContent = 'No results';
7522 } else if (ps === Infinity) {
7523 pageInfo.textContent = 'All ' + total.toLocaleString() + ' files';
7524 } else {
7525 pageInfo.textContent = (start + 1) + '-' + end + ' of ' + total.toLocaleString() + ' files';
7526 }
7527 }
7528 if (countLabel) {
7529 countLabel.textContent = (total < totalAll && total > 0) ? '(' + total.toLocaleString() + ' matching)' : '';
7530 }
7531 var edgeDisabled = ps === Infinity;
7532 if (firstBtn) firstBtn.disabled = currentPage <= 1 || edgeDisabled;
7533 if (prevBtn) prevBtn.disabled = currentPage <= 1 || edgeDisabled;
7534 if (nextBtn) nextBtn.disabled = currentPage >= totalPages || edgeDisabled;
7535 if (lastBtn) lastBtn.disabled = currentPage >= totalPages || edgeDisabled;
7536 if (jumpInput) { jumpInput.value = currentPage; jumpInput.max = totalPages; jumpInput.disabled = edgeDisabled; }
7537 if (pageTotal) pageTotal.textContent = totalPages.toLocaleString();
7538 }
7539
7540 if (searchInput) {
7541 var filterTimer = null;
7542 searchInput.addEventListener('input', function () {
7543 clearTimeout(filterTimer);
7544 filterTimer = setTimeout(applyFilter, 200);
7545 });
7546 }
7547 if (pageSizeSelect) {
7548 pageSizeSelect.addEventListener('change', function () { currentPage = 1; render(); });
7549 }
7550 if (firstBtn) {
7551 firstBtn.addEventListener('click', function () { currentPage = 1; render(); });
7552 }
7553 if (prevBtn) {
7554 prevBtn.addEventListener('click', function () { if (currentPage > 1) { currentPage--; render(); } });
7555 }
7556 if (nextBtn) {
7557 nextBtn.addEventListener('click', function () {
7558 var ps = getPageSize();
7559 var totalPages = ps === Infinity ? 1 : Math.ceil(filteredRows.length / ps);
7560 if (currentPage < totalPages) { currentPage++; render(); }
7561 });
7562 }
7563 if (lastBtn) {
7564 lastBtn.addEventListener('click', function () {
7565 var ps = getPageSize();
7566 currentPage = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
7567 render();
7568 });
7569 }
7570 if (jumpInput) {
7571 function hsJump() {
7572 var ps = getPageSize();
7573 var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
7574 var v = parseInt(jumpInput.value, 10);
7575 if (!isNaN(v)) { currentPage = Math.max(1, Math.min(v, totalPages)); render(); }
7576 }
7577 jumpInput.addEventListener('change', hsJump);
7578 jumpInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') hsJump(); });
7579 }
7580 table.addEventListener('sloc-sorted', function () { applyFilter(); });
7581 applyFilter();
7582 })();
7583 })();
7584
7585 (function randomizeWatermarks() {
7586 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
7587 if (!wms.length) return;
7588 var placed = [];
7589 function tooClose(t, l) {
7590 for (var i = 0; i < placed.length; i++) {
7591 var dt = Math.abs(placed[i][0] - t);
7592 var dl = Math.abs(placed[i][1] - l);
7593 if (dt < 18 && dl < 18) return true;
7594 }
7595 return false;
7596 }
7597 function pick(leftBias) {
7598 for (var attempt = 0; attempt < 40; attempt++) {
7599 var t = Math.random() * 90;
7600 var l = leftBias ? Math.random() * 50 : 40 + Math.random() * 55;
7601 if (!tooClose(t, l)) { placed.push([t, l]); return [t, l]; }
7602 }
7603 var fb = [Math.random() * 90, Math.random() * 95];
7604 placed.push(fb);
7605 return fb;
7606 }
7607 var half = Math.floor(wms.length / 2);
7608 wms.forEach(function (img, i) {
7609 var pos = pick(i < half);
7610 var sz = Math.floor(Math.random() * 80 + 110);
7611 var rot = (Math.random() * 360).toFixed(1);
7612 var op = (Math.random() * 0.07 + 0.10).toFixed(2);
7613 img.style.cssText = 'width:' + sz + 'px;top:' + pos[0].toFixed(1) + '%;left:' + pos[1].toFixed(1) + '%;transform:rotate(' + rot + 'deg);opacity:' + op + ';';
7614 });
7615 })();
7616
7617 (function spawnCodeParticles() {
7618 var container = document.getElementById('code-particles');
7619 if (!container) return;
7620 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'];
7621 for (var i = 0; i < 38; i++) {
7622 (function (idx) {
7623 var el = document.createElement('span');
7624 el.className = 'code-particle';
7625 el.textContent = snippets[idx % snippets.length];
7626 var left = Math.random() * 94 + 2;
7627 var top = Math.random() * 88 + 6;
7628 var dur = (Math.random() * 10 + 9).toFixed(1);
7629 var delay = (Math.random() * 18).toFixed(1);
7630 var rot = (Math.random() * 26 - 13).toFixed(1);
7631 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
7632 el.style.left = left.toFixed(1) + '%';
7633 el.style.top = top.toFixed(1) + '%';
7634 el.style.setProperty('--rot', rot + 'deg');
7635 el.style.setProperty('--op', op);
7636 el.style.animationDuration = dur + 's';
7637 el.style.animationDelay = '-' + delay + 's';
7638 container.appendChild(el);
7639 })(i);
7640 }
7641 })();
7642 // ── Metric number formatting ─────────────────────────────────────────────
7643 (function () {
7644 function fmtBig(n) {
7645 if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
7646 if (n >= 1e4) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
7647 return n.toLocaleString();
7648 }
7649 function fmtExact(n) { return n.toLocaleString(); }
7650 document.querySelectorAll('[data-metric-value]').forEach(function (el) {
7651 var n = parseInt(el.getAttribute('data-metric-value'), 10);
7652 if (isNaN(n)) return;
7653 var big = el.querySelector('.metric-big');
7654 var exact = el.querySelector('.metric-exact');
7655 if (big) big.textContent = fmtBig(n);
7656 if (exact) exact.textContent = n >= 1e4 ? fmtExact(n) : '';
7657 });
7658 var densityCard = document.querySelector('[data-metric-density]');
7659 if (densityCard) {
7660 var phys = 0, code = 0;
7661 document.querySelectorAll('[data-metric-value]').forEach(function (el) {
7662 var lbl = el.querySelector('.metric-label');
7663 if (!lbl) return;
7664 var t = lbl.textContent.trim().toLowerCase();
7665 var v = parseInt(el.getAttribute('data-metric-value'), 10) || 0;
7666 if (t === 'physical lines') phys = v;
7667 if (t === 'code') code = v;
7668 });
7669 var pct = phys > 0 ? (code / phys * 100) : 0;
7670 var big = densityCard.querySelector('.metric-big');
7671 var exact = densityCard.querySelector('.metric-exact');
7672 if (big) big.textContent = pct.toFixed(1) + '%';
7673 if (exact) exact.textContent = '';
7674 }
7675 (function(){
7676 var g=document.querySelector('.summary-grid');if(!g)return;
7677 var pad=g.querySelector('.metric-pad');
7678 var real=Array.prototype.slice.call(g.querySelectorAll('.metric')).filter(function(el){return el!==pad;});
7679 if(!real.length)return;
7680 function upd(){
7681 // Pad the strip to an EVEN card count so a true CSS grid lays it out as
7682 // exactly two full rows with every column aligned and every card the
7683 // same size. When the real-card count is odd, reveal the reserve
7684 // "Assertions" pad card; otherwise keep it hidden.
7685 var n=real.length;
7686 if(pad){ if(n%2===1){pad.style.display='';n++;} else {pad.style.display='none';} }
7687 var perRow=window.innerWidth<=640?2:Math.ceil(n/2);
7688 g.style.gridTemplateColumns='repeat('+perRow+',minmax(0,1fr))';
7689 }
7690 upd();window.addEventListener('resize',upd);
7691 })();
7692 (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);}})();
7693 })();
7694 // ── Info chip interactivity ───────────────────────────────────────────────
7695 (function() {
7696 document.querySelectorAll('.run-id-chip[data-copy]').forEach(function(chip) {
7697 chip.addEventListener('click', function() {
7698 var val = chip.getAttribute('data-copy');
7699 var tt = chip.querySelector('.chip-tooltip');
7700 var orig = tt ? tt.textContent : '';
7701 if (!navigator.clipboard) return;
7702 navigator.clipboard.writeText(val).then(function() {
7703 chip.classList.add('chip-copied-flash');
7704 if (tt) tt.textContent = 'Copied!';
7705 setTimeout(function() {
7706 chip.classList.remove('chip-copied-flash');
7707 if (tt) tt.textContent = orig;
7708 }, 1100);
7709 });
7710 });
7711 });
7712 document.querySelectorAll('.run-id-chip[data-author]').forEach(function(chip) {
7713 var author = chip.getAttribute('data-author');
7714 var el = chip.querySelector('.author-handle');
7715 if (el) el.textContent = '/' + author.replace(/\s+/g, '');
7716 });
7717 })();
7718 // ── Export helpers ────────────────────────────────────────────────────────
7719 function _slocUnh(s){var e=document.createElement('div');e.innerHTML=s;return e.textContent;}
7720 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 }}"};
7721 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
7722 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
7723 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);}
7724 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;');}
7725 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');}
7726 function slocXlsMulti(fname,sheets){
7727 var enc=new TextEncoder();
7728 var CT=[];
7729 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;}
7730 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;}
7731 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
7732 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
7733 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
7734 var ss=[],si={};
7735 function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
7736 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;}
7737 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
7738 // 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(@)
7739 var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
7740 +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
7741 +'<fonts count="3">'
7742 +'<font><sz val="11"/><name val="Calibri"/></font>'
7743 +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
7744 +'<font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font>'
7745 +'</fonts>'
7746 +'<fills count="4">'
7747 +'<fill><patternFill patternType="none"/></fill>'
7748 +'<fill><patternFill patternType="gray125"/></fill>'
7749 +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
7750 +'<fill><patternFill patternType="solid"><fgColor rgb="FFFAF0E6"/><bgColor indexed="64"/></patternFill></fill>'
7751 +'</fills>'
7752 +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
7753 +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
7754 +'<cellXfs count="7">'
7755 +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
7756 +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
7757 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
7758 +'<xf numFmtId="0" fontId="2" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
7759 +'<xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
7760 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="left"/></xf>'
7761 +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
7762 +'</cellXfs>'
7763 +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
7764 +'</styleSheet>';
7765 var wsXmls=[],tableCounter=0,tableXmls={},wsRelsXmls={};
7766 function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
7767 sheets.forEach(function(sh,sheetIdx){
7768 var rx='<row r="1">';
7769 sh.hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
7770 rx+='</row>';
7771 var rn=2;
7772 sh.rows.forEach(function(row){
7773 if(!row||row.length===0){rx+='<row r="'+rn+'"/>';rn++;return;}
7774 if(row.length===1&&row[0]&&typeof row[0]==='object'&&row[0]._sec){
7775 rx+='<row r="'+rn+'">';
7776 rx+='<c r="'+colRef(0,rn)+'" t="s" s="3"><v>'+S(row[0].v)+'</v></c>';
7777 for(var ec=1;ec<sh.hdrs.length;ec++){rx+='<c r="'+colRef(ec,rn)+'" s="3"/>';}
7778 rx+='</row>';rn++;return;
7779 }
7780 rx+='<row r="'+rn+'">';
7781 row.forEach(function(cell,c){
7782 var ref=colRef(c,rn);
7783 if(cell===null||cell===undefined||cell===''){rx+='<c r="'+ref+'"/>';return;}
7784 if(typeof cell==='object'&&cell!==null){
7785 var cv=cell.v,cs=cell.s!=null?cell.s:0;
7786 if(typeof cv==='number'){rx+='<c r="'+ref+'" s="'+cs+'"><v>'+xe(cv)+'</v></c>';}
7787 else{rx+='<c r="'+ref+'" t="s" s="'+cs+'"><v>'+S(cv)+'</v></c>';}
7788 return;
7789 }
7790 if(typeof cell==='number'){rx+='<c r="'+ref+'" s="2"><v>'+xe(cell)+'</v></c>';return;}
7791 rx+='<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';
7792 });
7793 rx+='</row>';rn++;
7794 });
7795 var cw='';
7796 if(sh.colWidths&&sh.colWidths.length>0){
7797 cw='<cols>';
7798 sh.colWidths.forEach(function(w,i){cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';});
7799 cw+='</cols>';
7800 }
7801 var tblParts='';
7802 if(!sh.isKv&&sh.hdrs.length>0&&sh.rows.length>0){
7803 tableCounter++;
7804 var tc=tableCounter,colCount=sh.hdrs.length,rowCount=sh.rows.length+1;
7805 var tRef='A1:'+colNm(colCount)+rowCount;
7806 tableXmls['xl/tables/table'+tc+'.xml']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
7807 +'<table xmlns="'+sns+'" id="'+tc+'" name="Table'+tc+'" displayName="Table'+tc+'" ref="'+tRef+'" totalsRowShown="0">'
7808 +'<autoFilter ref="'+tRef+'"/>'
7809 +'<tableColumns count="'+colCount+'">'
7810 +sh.hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
7811 +'</tableColumns>'
7812 +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
7813 +'</table>';
7814 wsRelsXmls['xl/worksheets/_rels/sheet'+(sheetIdx+1)+'.xml.rels']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
7815 +'<Relationships xmlns="'+pns+'relationships">'
7816 +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table'+tc+'.xml"/>'
7817 +'</Relationships>';
7818 tblParts='<tableParts count="1"><tablePart r:id="rId1"/></tableParts>';
7819 }
7820 wsXmls.push('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
7821 +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
7822 +'<sheetFormatPr defaultRowHeight="15"/>'+cw+'<sheetData>'+rx+'</sheetData>'+tblParts+'</worksheet>');
7823 });
7824 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>';
7825 var ctOver=sheets.map(function(_,i){return'<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}).join('');
7826 var ctTable=Object.keys(tableXmls).map(function(k){return'<Override PartName="/'+k+'" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';}).join('');
7827 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>';
7828 var wbSh=sheets.map(function(sh,i){return'<sheet name="'+xe(sh.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}).join('');
7829 var wbXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><sheets>'+wbSh+'</sheets></workbook>';
7830 var wbR=sheets.map(function(_,i){return'<Relationship Id="rId'+(i+1)+'" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}).join('');
7831 wbR+='<Relationship Id="rId'+(sheets.length+1)+'" Type="'+ons+'relationships/styles" Target="styles.xml"/>'
7832 +'<Relationship Id="rId'+(sheets.length+2)+'" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/>';
7833 var wbRXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships">'+wbR+'</Relationships>';
7834 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};
7835 var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml'];
7836 sheets.forEach(function(_,i){var k='xl/worksheets/sheet'+(i+1)+'.xml';F[k]=wsXmls[i];order.push(k);});
7837 Object.keys(wsRelsXmls).forEach(function(k){F[k]=wsRelsXmls[k];order.push(k);});
7838 Object.keys(tableXmls).forEach(function(k){F[k]=tableXmls[k];order.push(k);});
7839 var zparts=[],zcds=[],zoff=0,znf=0;
7840 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++;});
7841 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
7842 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]);
7843 var tot=zoff+cdSz+ea.length,zout=new Uint8Array(tot),zpos=0;
7844 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
7845 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
7846 zout.set(new Uint8Array(ea),zpos);
7847 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
7848 }
7849 window.resetPerFileTable = function() {
7850 var tbl = document.getElementById('per-file-table');
7851 if (!tbl) return;
7852 var shell = tbl.closest('.table-shell');
7853 if (shell) shell.scrollLeft = 0;
7854 Array.prototype.slice.call(tbl.querySelectorAll('th')).forEach(function(th) { th.style.width = ''; });
7855 Array.prototype.slice.call(tbl.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
7856 if (window._pfPaginationReset) window._pfPaginationReset();
7857 var si = document.getElementById('per-file-search');
7858 if (si) si.value = '';
7859 };
7860 var _rh=['File','Language','Physical Lines','Code Lines','Comments','Blank','Mixed Separate','Functions','Classes','Variables','Imports'];
7861 var _titleSlug="{{ title }}".replace(/[^a-zA-Z0-9\-]/g,'_').replace(/_+/g,'_').replace(/^_+|_+$/g,'');
7862 var _commitSlug="{% if let Some(c) = run.git_commit_short %}{{ c }}{% endif %}";
7863 var _exportSlug='per-file_'+_titleSlug+(_commitSlug?'_'+_commitSlug:'');
7864 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;}
7865 window.exportReportCsv=function(){slocCsv(_exportSlug+'.csv',_rh,getReportExportRows());};
7866 window.exportReportXls=function(){
7867 var fname='report_'+_titleSlug+(_commitSlug?'_'+_commitSlug:'')+'.xlsx';
7868 function sec(v){return[{_sec:true,v:v}];}
7869 function B(v){return{v:v,s:4};}
7870 function N(v){return{v:typeof v==='number'?v:Number(v),s:5};}
7871 // Table cells render with thousands separators (1,656,153) via the |commas
7872 // filter; Number() on that string is NaN, which would store the value as text
7873 // (green-triangle warning, left-aligned). Strip separators so numeric cells
7874 // become real numbers and align correctly. Non-numeric text is left untouched.
7875 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;}
7876 function pnum(v){var t=String(v==null?'':v).replace(/,/g,'').trim();return /^-?\d+(\.\d+)?$/.test(t)?Number(t):0;}
7877 var dens=_SLOC_META.physicalLines>0?(_SLOC_META.codeLines/_SLOC_META.physicalLines*100).toFixed(1)+'%':'0%';
7878 var sumRows=[
7879 sec('RUN INFORMATION'),
7880 [B('Run ID'),_SLOC_META.runId,''],
7881 [B('Git Commit'),_SLOC_META.gitCommit,''],
7882 [B('Branch'),_SLOC_META.branch,''],
7883 [B('Last Commit By'),_SLOC_META.lastCommitBy,''],
7884 [B('Scan By'),_SLOC_META.scanBy,''],
7885 [B('Scanned'),_SLOC_META.scanned,''],
7886 [B('OS'),_SLOC_META.os,''],
7887 [B('Files Analyzed'),N(_SLOC_META.filesAnalyzed),'Total source files included in this analysis'],
7888 [B('Files Skipped'),N(_SLOC_META.filesSkipped),'Files excluded (binary, unsupported, or policy-filtered)'],
7889 [],
7890 sec('CODE METRICS'),
7891 [B('Physical Lines'),N(_SLOC_META.physicalLines),'Total lines including code, comments, and blanks'],
7892 [B('Code Lines'),N(_SLOC_META.codeLines),'Lines containing executable source code'],
7893 [B('Comments'),N(_SLOC_META.commentLines),'Lines consisting entirely of comments or documentation'],
7894 [B('Blank Lines'),N(_SLOC_META.blankLines),'Empty or whitespace-only lines'],
7895 [B('Mixed Separate'),N(_SLOC_META.mixedSeparate),'Lines with both code and trailing comment, counted separately'],
7896 [B('Functions'),N(_SLOC_META.functions),'Best-effort count of function/method definitions'],
7897 [B('Classes / Types'),N(_SLOC_META.classes),'Best-effort count of class, struct, interface definitions'],
7898 [B('Variables'),N(_SLOC_META.variables),'Best-effort count of variable and constant declarations'],
7899 [B('Imports'),N(_SLOC_META.imports),'Best-effort count of import, include, module-use statements'],
7900 [B('Tests'),N(_SLOC_META.tests),'Best-effort count of test cases (GTest, PyTest, JUnit, etc.)'],
7901 [B('Code Density'),{v:dens,s:6},'Percentage of physical lines that contain executable source code'],
7902 [B('Tool Version'),'oxide-sloc '+_SLOC_META.toolVersion,''],
7903 ];
7904 var langHdrs=['Language','Files','Physical Lines','Code Lines','Comments','Blank Lines','Mixed','Functions','Classes','Variables','Imports','Tests','Assertions','Suites'];
7905 var langRows=[];
7906 document.querySelectorAll('#lang-breakdown-table tbody tr').forEach(function(tr){
7907 var tds=tr.querySelectorAll('td');
7908 var row=[];
7909 Array.prototype.forEach.call(tds,function(td,i){var v=td.textContent.trim();row.push(i>0?numify(v):v);});
7910 langRows.push(row);
7911 });
7912 var pfHdrs=['File','Language','Physical Lines','Code Lines','Comments','Blank','Mixed','Functions','Classes','Variables','Imports','Tests','Assertions','Suites'];
7913 var pfRows=[];
7914 document.querySelectorAll('#per-file-table tbody tr').forEach(function(tr){
7915 var tds=tr.querySelectorAll('td');
7916 if(tds.length<11)return;
7917 var row=[];
7918 Array.prototype.forEach.call(tds,function(td,i){var v=td.textContent.trim();row.push(i>=2?numify(v):v);});
7919 pfRows.push(row);
7920 });
7921 var skHdrs=['File','Status','Warnings'];
7922 var skRows=[];
7923 document.querySelectorAll('#skipped-table tbody tr').forEach(function(tr){
7924 var tds=tr.querySelectorAll('td');
7925 if(tds.length<3)return;
7926 skRows.push([tds[0].textContent.trim(),tds[1].textContent.trim(),tds[2].textContent.trim()]);
7927 });
7928 var covHdrs=['Language','Files','Physical Lines','Code Lines','Code Density','Functions','Classes','Variables','Imports','Tests','Assertions','Test Suites'];
7929 var covRows=[];
7930 document.querySelectorAll('#lang-breakdown-table tbody tr').forEach(function(tr){
7931 var tds=tr.querySelectorAll('td');
7932 if(tds.length<4)return;
7933 var phys=pnum(tds[2].textContent);
7934 var code=pnum(tds[3].textContent);
7935 var densStr=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
7936 var row=[tds[0].textContent.trim(),pnum(tds[1].textContent),phys,code,{v:densStr,s:6}];
7937 for(var i=7;i<Math.min(tds.length,14);i++){row.push(numify(tds[i].textContent.trim()));}
7938 covRows.push(row);
7939 });
7940 slocXlsMulti(fname,[
7941 {name:'Summary',hdrs:['Field / Metric','Value','Description'],rows:sumRows,colWidths:[22,45,55],isKv:true},
7942 {name:'Language Breakdown',hdrs:langHdrs,rows:langRows,colWidths:[16,8,14,12,12,12,8,10,10,10,10,8,10,8]},
7943 {name:'Per-File Detail',hdrs:pfHdrs,rows:pfRows,colWidths:[50,12,12,12,12,10,8,10,10,10,10,8,10,8]},
7944 {name:'Code Coverage',hdrs:covHdrs,rows:covRows,colWidths:[18,7,14,12,13,11,10,10,10,8,11,12]},
7945 {name:'Skipped Files',hdrs:skHdrs,rows:skRows,colWidths:[60,25,50]}
7946 ]);
7947 };
7948 Array.prototype.slice.call(document.querySelectorAll('[data-export-csv]')).forEach(function(btn){btn.addEventListener('click',function(){slocCsv(_exportSlug+'.csv',_rh,getReportExportRows());});});
7949 Array.prototype.slice.call(document.querySelectorAll('[data-export-xls]')).forEach(function(btn){btn.addEventListener('click',window.exportReportXls);});
7950 Array.prototype.slice.call(document.querySelectorAll('[data-reset-table]')).forEach(function(btn){btn.addEventListener('click',window.resetPerFileTable);});
7951 var _skippedRh=['File','Status','Warnings'];
7952 var _skippedSlug='skipped_'+_titleSlug+(_commitSlug?'_'+_commitSlug:'');
7953 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;}
7954 (function(){var b=document.getElementById('skipped-export-csv');if(b)b.addEventListener('click',function(){slocCsv(_skippedSlug+'.csv',_skippedRh,getSkippedExportRows());});})();
7955 (function(){var b=document.getElementById('skipped-export-xls');if(b)b.addEventListener('click',function(){slocXls(_skippedSlug+'.xlsx','Skipped Files',_skippedRh,getSkippedExportRows());});})();
7956 // ── Chart.js initialization ───────────────────────────────────────────────
7957 // Deferred so the browser can repaint (dismiss the loading overlay) before
7958 // the canvas/SVG chart work blocks the main thread.
7959 requestAnimationFrame(function() {
7960 try {
7961 (function() {
7962 var D = {{ lang_chart_json|safe }};
7963 var SUB_D = {{ submodule_chart_json|safe }};
7964 var SCAT_D = {{ scatter_chart_json|safe }};
7965 var SEM_D = {{ semantic_chart_json|safe }};
7966 var HIST_D = {{ file_size_histogram_json|safe }};
7967 if (!D || !D.length) return;
7968
7969 var PALETTE = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030',
7970 '#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082',
7971 '#D0743C','#5BA8A0','#8B3A8B','#3D7A3D','#AA5500','#005599'];
7972 var OX = '#C45C10', GN = '#2A6846', GY = '#BBBBBB';
7973 var ALL_CHARTS = [];
7974 function hexAlpha(hex, a) {
7975 var r=parseInt(hex.slice(1,3),16),g=parseInt(hex.slice(3,5),16),b=parseInt(hex.slice(5,7),16);
7976 return 'rgba('+r+','+g+','+b+','+a+')';
7977 }
7978
7979 function fmt(n) {
7980 var v = Number(n), a = Math.abs(v);
7981 if (a >= 1e6) return (v/1e6).toFixed(1).replace(/\.0$/,'') + 'M';
7982 if (a >= 1e4) return Math.round(v/1e3) + 'K';
7983 return v.toLocaleString();
7984 }
7985 function isDark() { return document.body.classList.contains('dark-theme'); }
7986 function clr() {
7987 return isDark()
7988 ? { text: '#d4c5b8', grid: 'rgba(255,255,255,0.10)' }
7989 : { text: '#43342d', grid: '#e6d0bf' };
7990 }
7991 // Legend-highlight alpha for a dataset's drawn label / marker. `chart.$hiDs` is
7992 // set by legend hover (see attachScatterLegend); when it is null every dataset
7993 // draws at full strength. Once a language is hovered, the others fade so the
7994 // hovered one's marker, name and number stay readable through overlapping
7995 // neighbours. Applied globally to every value-labelled Chart.js plot.
7996 function hiAlpha(chart, di) {
7997 var h = chart.$hiDs;
7998 if (h == null) return 1;
7999 return di === h ? 1 : 0.1;
8000 }
8001 // Inline Chart.js plugin: draws a permanent value label on each bar / bubble.
8002 // fmtFn(rawValue, datasetIndex, pointIndex) → string | null
8003 // anchor: 'top' = above vertical bar, 'end' = right of horizontal bar, 'bubble' = above bubble
8004 function makeDlPlugin(fmtFn, anchor) {
8005 return {
8006 afterDatasetsDraw: function(chart) {
8007 var ctx = chart.ctx;
8008 var tc = clr().text;
8009 chart.data.datasets.forEach(function(ds, di) {
8010 var meta = chart.getDatasetMeta(di);
8011 meta.data.forEach(function(el, idx) {
8012 var label = fmtFn(ds.data[idx], di, idx);
8013 if (label == null || label === '') return;
8014 ctx.save();
8015 ctx.globalAlpha = hiAlpha(chart, di);
8016 ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
8017 ctx.fillStyle = tc;
8018 if (anchor === 'top') {
8019 ctx.textAlign = 'center';
8020 ctx.textBaseline = 'bottom';
8021 ctx.fillText(String(label), el.x, el.y - 3);
8022 } else if (anchor === 'end') {
8023 ctx.textAlign = 'left';
8024 ctx.textBaseline = 'middle';
8025 ctx.fillText(String(label), el.x + 5, el.y);
8026 } else {
8027 ctx.textAlign = 'center';
8028 ctx.textBaseline = 'bottom';
8029 var r = (el.options && el.options.radius) ? el.options.radius : 10;
8030 ctx.fillText(String(label), el.x, el.y - r - 3);
8031 }
8032 ctx.restore();
8033 });
8034 });
8035 }
8036 };
8037 }
8038 // Bubble-chart value labels (language name + code lines above each bubble).
8039 // Shared by the dashboard card and the Full View modal. Honours the legend
8040 // highlight: non-hovered languages' labels fade and the hovered one is drawn
8041 // last so its name + number sit on top of any overlapping neighbours — the
8042 // clustered bubbles at the origin are otherwise an unreadable pile of text.
8043 function scatterLabelPlugin() {
8044 return { afterDatasetsDraw: function(chart) {
8045 var ctx = chart.ctx, tc = clr().text, hi = chart.$hiDs;
8046 function drawOne(di) {
8047 var d = SCAT_D[di]; if (!d) return;
8048 var meta = chart.getDatasetMeta(di), a = hiAlpha(chart, di);
8049 meta.data.forEach(function(el) {
8050 var r = (el.options && el.options.radius) ? el.options.radius : 10;
8051 var ty2 = Math.max(14, el.y - r - 3), ty1 = Math.max(1, ty2 - 14);
8052 ctx.save();
8053 ctx.globalAlpha = a; ctx.fillStyle = tc;
8054 ctx.textBaseline = 'bottom'; ctx.textAlign = 'center';
8055 ctx.font = '800 11px Inter,ui-sans-serif,sans-serif';
8056 ctx.fillText(d.lang, el.x, ty1);
8057 ctx.font = '700 10px Inter,ui-sans-serif,sans-serif';
8058 ctx.fillText(fmt(d.code), el.x, ty2);
8059 ctx.restore();
8060 });
8061 }
8062 chart.data.datasets.forEach(function(_, di) { if (hi == null || di !== hi) drawOne(di); });
8063 if (hi != null && hi >= 0) drawOne(hi);
8064 } };
8065 }
8066 function makeStackedEndPlugin(fmtFn) {
8067 return {
8068 afterDatasetsDraw: function(chart) {
8069 var ctx = chart.ctx;
8070 var tc = clr().text;
8071 var nDs = chart.data.datasets.length;
8072 if (nDs === 0) return;
8073 var lastMeta = chart.getDatasetMeta(nDs - 1);
8074 lastMeta.data.forEach(function(el, idx) {
8075 var total = 0;
8076 chart.data.datasets.forEach(function(ds) { total += ds.data[idx] || 0; });
8077 var label = fmtFn(total, idx);
8078 if (label == null || label === '') return;
8079 ctx.save();
8080 ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
8081 ctx.fillStyle = tc;
8082 ctx.textAlign = 'left';
8083 ctx.textBaseline = 'middle';
8084 ctx.fillText(String(label), el.x + 5, el.y);
8085 ctx.restore();
8086 });
8087 }
8088 };
8089 }
8090
8091 function wireDonutLegend(svg) {
8092 if(!svg) return;
8093 // Every donut element carries data-lang: slices (path/circle), leader lines,
8094 // outside labels + % labels (text) and legend rows (g). Hovering any one of
8095 // them emphasises that language across all of them and fades the rest, so the
8096 // slice, its leader line, its label and its legend row move as one picture.
8097 var items=svg.querySelectorAll('[data-lang]');
8098 function emph(el,st){ // st: 1 = highlight, -1 = fade, 0 = reset
8099 var tag=el.tagName.toLowerCase();
8100 if(tag==='path'||tag==='circle'){
8101 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)';}
8102 else if(st===-1){el.style.opacity='0.24';el.style.filter='none';el.style.transform='none';}
8103 else{el.style.opacity='';el.style.filter='';el.style.transform='';}
8104 }else if(tag==='line'){
8105 if(st===1){el.style.opacity='1';el.style.strokeWidth='1.8';}
8106 else if(st===-1){el.style.opacity='0.1';el.style.strokeWidth='';}
8107 else{el.style.opacity='';el.style.strokeWidth='';}
8108 }else if(tag==='text'){
8109 if(st===1){el.style.opacity='1';el.style.fontWeight='800';}
8110 else if(st===-1){el.style.opacity='0.18';el.style.fontWeight='';}
8111 else{el.style.opacity='';el.style.fontWeight='';}
8112 }else{ // legend group
8113 if(st===1){el.style.opacity='1';}
8114 else if(st===-1){el.style.opacity='0.4';}
8115 else{el.style.opacity='';}
8116 }
8117 }
8118 function hl(lang){for(var i=0;i<items.length;i++){emph(items[i],items[i].getAttribute('data-lang')===lang?1:-1);}}
8119 function rst(){for(var i=0;i<items.length;i++){emph(items[i],0);}}
8120 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();});
8121 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();});
8122 svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
8123 }
8124 function wireMixLegend(svg) {
8125 if(!svg) return;
8126 var legGs=svg.querySelectorAll('g[data-kind]');
8127 var allRects=svg.querySelectorAll('rect[data-kind]');
8128 if(!legGs.length) return;
8129 function hlKind(kind) {
8130 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';}}
8131 for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-kind')===kind?'1':'0.45';}
8132 }
8133 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='';}}
8134 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]);}
8135 }
8136
8137 // ── Language overview: SVG donut + horizontal stacked bars ───────────────
8138 (function() {
8139 var el = document.getElementById('report-lang-overview');
8140 if (!el || !D || !D.length) return;
8141 var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
8142 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
8143 function px(n){return Math.round(n);}
8144 function tt(label,val){return ' class="rchit" data-ttl="'+String(label).replace(/&/g,'&').replace(/"/g,'"')+'" data-ttv="'+String(val).replace(/&/g,'&').replace(/"/g,'"')+'"';}
8145 var tot = D.reduce(function(a,d){return a+d.code;},0)||1;
8146 // Donut — height matches the stacked-bar chart so both panels align
8147 var rHb_d=28;
8148 var DH=Math.max(220,D.length*rHb_d+32);
8149 var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48,legX=208,DW=395;
8150 var legCount=D.length;
8151 var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
8152 var legYStart=Math.round((DH-legCount*legSpacing)/2);
8153 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">';
8154 // One shared transition on every donut element so slices, leader lines,
8155 // outside labels, % labels and the legend all animate together as a single
8156 // picture when a language is hovered. Slices scale from the donut centre.
8157 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>';
8158 if(D.length===1){
8159 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
8160 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+'"/>';
8161 } else {
8162 var smalls=[];
8163 var ang=-Math.PI/2;
8164 D.forEach(function(d,i){
8165 var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
8166 var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
8167 var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
8168 var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
8169 var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
8170 var pct=Math.round(d.code/tot*100);
8171 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"/>';
8172 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]});}
8173 ang+=sw;
8174 });
8175 // Small slices (<5%) get outside labels positioned near each slice's own
8176 // angular position (a slice on the left gets its label/leader on the left),
8177 // then nudged apart horizontally so text never overlaps. Leader lines point
8178 // from each slice to its label. Horizontal text keeps long names legible;
8179 // the whole SVG scales up in Full View so these stay readable there too.
8180 if(smalls.length){
8181 smalls.sort(function(a,b){return a.mAng-b.mAng;});
8182 var sPad=6,sRowY=11;
8183 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)));});
8184 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;}
8185 var sLast=smalls[smalls.length-1],sOver=sLast.x+sLast.w/2-(DW-sPad);
8186 if(sOver>0)smalls.forEach(function(sm){sm.x-=sOver;});
8187 smalls.forEach(function(sm){
8188 var axx=cx+Ro*Math.cos(sm.mAng),ayy=cy+Ro*Math.sin(sm.mAng);
8189 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;"/>';
8190 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>';
8191 });
8192 }
8193 }
8194 ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
8195 ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
8196 D.forEach(function(d,i){
8197 var ly=legYStart+i*legSpacing;
8198 var pctL=Math.round(d.code/tot*100);
8199 var ttL=String(d.lang).replace(/&/g,'&').replace(/"/g,'"');
8200 var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&').replace(/"/g,'"');
8201 ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
8202 ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
8203 ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(PALETTE[i%PALETTE.length])+'"/>';
8204 ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
8205 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>';
8206 ds+='</g>';
8207 });
8208 ds+='</svg>';
8209 // Horizontal stacked-bar chart
8210 var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
8211 var LW=108,BW=260,svgW=LW+BW+68;
8212 var barRhb=Math.min(48,Math.max(28,Math.floor((DH-32)/D.length)));
8213 var barBH=Math.min(32,Math.round(barRhb*0.7));
8214 var SH=DH;
8215 var barTopPad=Math.max(6,Math.round((SH-D.length*barRhb-18)/2));
8216 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">';
8217 // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if
8218 // it cannot fit legibly even at the 6.5 floor (labels shrink to fit instead
8219 // of disappearing; the SVG scales up in Full View so small fonts stay legible).
8220 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;}
8221 D.forEach(function(d,i){
8222 var y=barTopPad+i*barRhb,x=LW;
8223 var phys=d.physical||d.code+d.comments+d.blanks;
8224 var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
8225 var lmid=y+barBH/2+4;
8226 var ttv='Code: '+fmt(d.code)+'\nComments: '+fmt(d.comments)+'\nBlank: '+fmt(d.blanks)+'\nTotal: '+fmt(phys);
8227 bs+='<g class="lang-bar-row">';
8228 // Hit area ends just past the total label so empty space to the right of the
8229 // bar does not trigger the tooltip — only the name, bar and total are hot.
8230 var hitW=px(LW+phys/maxT*BW+8+(String(fmt(phys)).length*6.8)+6);
8231 bs+='<rect'+tt(d.lang,ttv)+' x="0" y="'+y+'" width="'+hitW+'" height="'+barBH+'" fill="transparent" style="cursor:pointer;"/>';
8232 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>';
8233 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;}
8234 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;}
8235 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>';}
8236 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>';
8237 bs+='</g>';
8238 });
8239 var ly=SH-14;
8240 var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
8241 var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
8242 var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
8243 var totAll=totC+totCm+totBl||1;
8244 function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
8245 var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
8246 var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
8247 var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
8248 var legSt=LW+Math.max(0,Math.round((BW-194)/2));
8249 bs+='<g data-kind="code" style="cursor:pointer;">'
8250 +'<rect x="'+legSt+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC+'/>'
8251 +'<rect x="'+legSt+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
8252 +'<text x="'+(legSt+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
8253 +'</g>';
8254 bs+='<g data-kind="comment" style="cursor:pointer;">'
8255 +'<rect x="'+(legSt+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm+'/>'
8256 +'<rect x="'+(legSt+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
8257 +'<text x="'+(legSt+71)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
8258 +'</g>';
8259 bs+='<g data-kind="blank" style="cursor:pointer;">'
8260 +'<rect x="'+(legSt+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
8261 +'<rect x="'+(legSt+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
8262 +'<text x="'+(legSt+158)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
8263 +'</g>';
8264 bs+='</svg>';
8265 el.innerHTML='<div class="r-lang-overview">'+
8266 '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
8267 '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
8268 '</div>';
8269 wireDonutLegend(el.querySelector('svg'));
8270 wireMixLegend(el.querySelectorAll('svg')[1]);
8271 })();
8272
8273 // Shared cursor helper: pointer over data elements, default elsewhere.
8274 // Added to options.onHover on every Chart.js instance.
8275 // Legend items handled separately via legend.onHover / legend.onLeave.
8276 function chartCursor(e, els) {
8277 var t = e.native && e.native.target;
8278 if (t) t.style.cursor = els.length ? 'pointer' : 'default';
8279 }
8280 function legendCursorOn(e) { var t=e.native&&e.native.target; if(t)t.style.cursor='pointer'; }
8281 function legendCursorOff(e){ var t=e.native&&e.native.target; if(t)t.style.cursor='default'; }
8282 // Pushes a right-positioned legend away from the plot by `gap` px. Chart.js
8283 // (v4) places a right legend flush against the plot area: fit() reserves the
8284 // legend box width and _draw() lays items out from `this.left + padding`, so
8285 // the column hugs the bubbles. We reserve `gap` extra width in fit() (which
8286 // shrinks the plot by `gap`), then translate the canvas right by `gap` while
8287 // the legend draws so the column lands in that reserved space — clear of the
8288 // plot. The legendHitBoxes (used only for hover hit-testing, not drawing) are
8289 // shifted by the same `gap` so hover targets stay aligned with what's drawn.
8290 function legendGapPlugin(gap) {
8291 return {
8292 id: 'legendGap',
8293 beforeInit: function(chart) {
8294 var lg = chart.legend; if (!lg) return;
8295 var origFit = lg.fit, origDraw = lg.draw;
8296 lg.fit = function() { origFit.call(this); this.width += gap; this._needGap = true; };
8297 lg.draw = function() {
8298 if (this._needGap && this.legendHitBoxes) {
8299 this.legendHitBoxes.forEach(function(h){ h.left += gap; });
8300 this._needGap = false;
8301 }
8302 var ctx = this.ctx;
8303 ctx.save();
8304 ctx.translate(gap, 0);
8305 origDraw.call(this);
8306 ctx.restore();
8307 };
8308 }
8309 };
8310 }
8311
8312 // ── Project Overview bar ─────────────────────────────────────────────────
8313 var projChart = null;
8314 (function() {
8315 var ySel = document.getElementById('overview-y-axis');
8316 var xSel = document.getElementById('overview-x-mode');
8317 var el = document.getElementById('overview-chart');
8318 var lockedEl = document.getElementById('overview-chart-locked');
8319 var wrap = document.getElementById('canvas-proj-wrap');
8320 var canvas = document.getElementById('canvas-proj');
8321 if (!canvas || !ySel || !xSel) return;
8322 var Y_LABELS = { code:'Code Lines', comments:'Comment Lines', blanks:'Blank Lines',
8323 physical:'Physical Lines', files:'Files', comment:'Comment Lines', blank:'Blank Lines' };
8324 function getData() {
8325 var yKey = ySel.value, mode = xSel.value;
8326 var src = mode === 'submodules' ? SUB_D : D;
8327 var lKey = mode === 'submodules' ? 'name' : 'lang';
8328 var sorted = src.slice().sort(function(a,b){ return (b[yKey]||0)-(a[yKey]||0); });
8329 return { sorted: sorted, lKey: lKey, yKey: yKey, yLabel: Y_LABELS[yKey]||yKey };
8330 }
8331 function renderOverview() {
8332 var mode = xSel.value, isHist = mode.indexOf('history') === 0;
8333 if (el) el.style.display = isHist ? 'none' : 'block';
8334 if (lockedEl) lockedEl.style.display = isHist ? 'block' : 'none';
8335 if (isHist) return;
8336 var r = getData();
8337 var c = clr();
8338 if (wrap) wrap.style.height = Math.max(200, Math.min(432, r.sorted.length * 29 + 60)) + 'px';
8339 if (projChart) {
8340 projChart.data.labels = r.sorted.map(function(d){return d[r.lKey];});
8341 projChart.data.datasets[0].data = r.sorted.map(function(d){return d[r.yKey]||0;});
8342 projChart.data.datasets[0].backgroundColor = r.sorted.map(function(_,i){return PALETTE[i%PALETTE.length];});
8343 projChart.data.datasets[0].label = r.yLabel;
8344 projChart.options.scales.x.title.text = r.yLabel;
8345 projChart.update('none'); return;
8346 }
8347 projChart = new Chart(canvas, {
8348 type: 'bar',
8349 data: {
8350 labels: r.sorted.map(function(d){return d[r.lKey];}),
8351 datasets: [{ label: r.yLabel,
8352 data: r.sorted.map(function(d){return d[r.yKey]||0;}),
8353 backgroundColor: r.sorted.map(function(_,i){return PALETTE[i%PALETTE.length];}),
8354 borderRadius: 3 }]
8355 },
8356 options: {
8357 indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8358 onHover: chartCursor,
8359 animation: { duration: 500, easing: 'easeOutQuart' },
8360 layout: { padding: { right: 64 } },
8361 scales: {
8362 x: { grid: { color: c.grid }, ticks: { color: c.text, callback: function(v){return fmt(v);} },
8363 title: { display: true, text: r.yLabel, color: c.text } },
8364 y: { grid: { display: false }, ticks: { color: c.text } }
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 return ' ' + ctx.dataset.label + ': ' + Number(ctx.parsed.x).toLocaleString();
8373 }
8374 }
8375 }
8376 }
8377 },
8378 plugins: [makeDlPlugin(function(v){ return fmt(v||0); }, 'end')]
8379 });
8380 ALL_CHARTS.push(projChart);
8381 }
8382 ySel.addEventListener('change', renderOverview);
8383 xSel.addEventListener('change', renderOverview);
8384 renderOverview();
8385
8386 var overviewExpandBtn = document.getElementById('overview-expand-btn');
8387 if (overviewExpandBtn) {
8388 overviewExpandBtn.addEventListener('click', function() {
8389 var r = getData();
8390 var n = r.sorted.length || 1;
8391 var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
8392 var modalH = Math.min(Math.max(480, n * 29 + 96), maxH);
8393 var overlay = document.createElement('div');
8394 overlay.className = 'chart-modal-overlay';
8395 overlay.innerHTML = '<div class="chart-modal" style="max-width:1320px;">'
8396 + '<button class="chart-modal-close" aria-label="Close">×</button>'
8397 + '<div class="chart-modal-header">'
8398 + '<span class="chart-modal-title">Project Overview \u2014 Full View</span>'
8399 + '<label style="font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:6px;flex-shrink:0;">Y Axis:'
8400 + '<select id="ov-modal-y" class="chart-select">'
8401 + '<option value="code">Code Lines</option>'
8402 + '<option value="comments">Comment Lines</option>'
8403 + '<option value="blanks">Blank Lines</option>'
8404 + '<option value="physical">Total Physical Lines</option>'
8405 + '<option value="files">File Count</option>'
8406 + '</select></label>'
8407 + (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:'
8408 + '<select id="ov-modal-x" class="chart-select">'
8409 + '<option value="languages">Languages</option>'
8410 + '<option value="submodules">Submodules</option>'
8411 + '</select></label>' : '')
8412 + '</div>'
8413 + '<div style="position:relative;height:' + modalH + 'px;width:100%;"><canvas id="canvas-proj-modal"></canvas></div></div>';
8414 document.body.appendChild(overlay);
8415 overlay.querySelector('.chart-modal-close').addEventListener('click', function() { document.body.removeChild(overlay); });
8416 overlay.addEventListener('click', function(e) { if (e.target === overlay) document.body.removeChild(overlay); });
8417 var Y_LABELS = { code:'Code Lines', comments:'Comment Lines', blanks:'Blank Lines', physical:'Physical Lines', files:'Files' };
8418 var modalYSel = document.getElementById('ov-modal-y');
8419 var modalXSel = document.getElementById('ov-modal-x');
8420 if (modalYSel) modalYSel.value = ySel ? ySel.value : 'code';
8421 if (modalXSel && xSel) modalXSel.value = (xSel.value === 'languages' || xSel.value === 'submodules') ? xSel.value : 'languages';
8422 var modalCanvas = document.getElementById('canvas-proj-modal');
8423 if (!modalCanvas) return;
8424 var c = clr();
8425 function getModalData() {
8426 var yKey = modalYSel ? modalYSel.value : 'code';
8427 var mode = modalXSel ? modalXSel.value : 'languages';
8428 var src = mode === 'submodules' ? SUB_D : D;
8429 var lKey = mode === 'submodules' ? 'name' : 'lang';
8430 var sorted = src.slice().sort(function(a,b){ return (b[yKey]||0)-(a[yKey]||0); });
8431 return { sorted: sorted, lKey: lKey, yKey: yKey, yLabel: Y_LABELS[yKey]||yKey };
8432 }
8433 var ovModalChart = null;
8434 function renderOverviewModal() {
8435 var r2 = getModalData();
8436 if (ovModalChart) {
8437 ovModalChart.data.labels = r2.sorted.map(function(d){return d[r2.lKey];});
8438 ovModalChart.data.datasets[0].data = r2.sorted.map(function(d){return d[r2.yKey]||0;});
8439 ovModalChart.data.datasets[0].backgroundColor = r2.sorted.map(function(_,i){return PALETTE[i%PALETTE.length];});
8440 ovModalChart.data.datasets[0].label = r2.yLabel;
8441 ovModalChart.options.scales.x.title.text = r2.yLabel;
8442 ovModalChart.update('none'); return;
8443 }
8444 ovModalChart = new Chart(modalCanvas, {
8445 type: 'bar',
8446 data: {
8447 labels: r2.sorted.map(function(d){return d[r2.lKey];}),
8448 datasets: [{ label: r2.yLabel,
8449 data: r2.sorted.map(function(d){return d[r2.yKey]||0;}),
8450 backgroundColor: r2.sorted.map(function(_,i){return PALETTE[i%PALETTE.length];}),
8451 borderRadius: 3 }]
8452 },
8453 options: {
8454 indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8455 onHover: chartCursor,
8456 animation: { duration: 500, easing: 'easeOutQuart' },
8457 layout: { padding: { right: 64 } },
8458 scales: {
8459 x: { grid:{color:c.grid}, ticks:{color:c.text, callback:function(v){return fmt(v);}},
8460 title:{display:true, text:r2.yLabel, color:c.text} },
8461 y: { grid:{display:false}, ticks:{color:c.text} }
8462 },
8463 plugins: {
8464 legend:{display:false},
8465 tooltip:{callbacks:{
8466 title:function(items){return items.length?items[0].label:'';},
8467 label:function(ctx){return ' '+ctx.dataset.label+': '+Number(ctx.parsed.x).toLocaleString();}
8468 }}
8469 }
8470 },
8471 plugins: [makeDlPlugin(function(v){ return fmt(v||0); }, 'end')]
8472 });
8473 }
8474 renderOverviewModal();
8475 if (modalYSel) modalYSel.addEventListener('change', renderOverviewModal);
8476 if (modalXSel) modalXSel.addEventListener('change', renderOverviewModal);
8477 });
8478 }
8479 })();
8480
8481 // ── Language Composition (SVG — matches /runs/result behaviour) ──────────
8482 (function() {
8483 var el = document.getElementById('comp-svg-container');
8484 if (!el || !D || !D.length) return;
8485 var cData = D.slice(0, 15);
8486 var cMode = 'absolute';
8487 var CX = OX, CG = GN, CB = '#BBBBBB';
8488 var CFONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
8489 function cEsc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
8490 function cPx(n){return Math.round(n);}
8491 function cTT(l,v){return ' class="rchit" data-ttl="'+String(l).replace(/&/g,'&').replace(/"/g,'"')+'" data-ttv="'+String(v).replace(/&/g,'&').replace(/"/g,'"')+'"';}
8492 // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if it
8493 // cannot fit legibly even at the 6.5 floor (labels shrink to fit rather than
8494 // disappear; the SVG scales up in Full View so small fonts stay legible).
8495 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;}
8496 function cLT(l,v){return ' data-ttl="'+l+'" data-ttv="'+v.replace(/"/g,'"')+'"';}
8497 function renderCompSVG() {
8498 var isPct = cMode === 'pct';
8499 var totC=cData.reduce(function(a,d){return a+(d.code||0);},0);
8500 var totCm=cData.reduce(function(a,d){return a+(d.comments||0);},0);
8501 var totBl=cData.reduce(function(a,d){return a+(d.blanks||0);},0);
8502 var totAll=totC+totCm+totBl||1;
8503 var svgW=Math.max(320,el.offsetWidth||540);
8504 var LW=108,legendH=24,topPad=4;
8505 var MIN_SVG_H=220;
8506 var rHb=Math.min(80,Math.max(26,Math.floor((MIN_SVG_H-legendH-topPad-10)/cData.length)));
8507 var bH=Math.min(38,Math.round(rHb*0.68));
8508 var BW=Math.max(120,svgW-LW-84);
8509 var SH=Math.max(MIN_SVG_H,cData.length*rHb+legendH+topPad+10);
8510 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">';
8511 if(isPct){
8512 cData.forEach(function(d,i){
8513 var t2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
8514 var cW=(d.code||0)/t2*BW,cmW=(d.comments||0)/t2*BW,blW=(d.blanks||0)/t2*BW;
8515 var y=topPad+i*rHb+Math.floor((rHb-bH)/2),x=LW;
8516 var lmid=y+Math.floor(bH/2)+4;
8517 var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||t2);
8518 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>';
8519 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;}
8520 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;}
8521 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>';}
8522 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>';
8523 });
8524 } else {
8525 var maxT=Math.max.apply(null,cData.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);})) || 1;
8526 cData.forEach(function(d,i){
8527 var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
8528 var y=topPad+i*rHb+Math.floor((rHb-bH)/2),x=LW;
8529 var lmid=y+Math.floor(bH/2)+4;
8530 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));
8531 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>';
8532 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;}
8533 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;}
8534 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>';}
8535 var phys=d.physical||(d.code||0)+(d.comments||0)+(d.blanks||0);
8536 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>';
8537 });
8538 }
8539 var ly=SH-legendH+4;
8540 var ttC=cLT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
8541 var ttCm=cLT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
8542 var ttBl=cLT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
8543 var legSt=LW+Math.max(0,Math.round((BW-194)/2));
8544 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>';
8545 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>';
8546 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>';
8547 s+='</svg>';
8548 el.innerHTML=s;
8549 wireMixLegend(el.querySelector('svg'));
8550 }
8551 document.querySelectorAll('[data-comp-tab]').forEach(function(btn){
8552 btn.addEventListener('click', function(){
8553 document.querySelectorAll('[data-comp-tab]').forEach(function(b){b.classList.remove('active');});
8554 btn.classList.add('active');
8555 cMode=btn.getAttribute('data-comp-tab');
8556 renderCompSVG();
8557 });
8558 });
8559 renderCompSVG();
8560 window.addEventListener('resize', renderCompSVG);
8561 })();
8562
8563 // Custom HTML legend for the bubble chart: balanced columns (~15 rows max
8564 // per column, evenly distributed) placed to the right of the canvas. Chart.js'
8565 // native right-legend fills one column to full height then dumps the rest into
8566 // a tiny second column (and clips when space is tight) — this gives even columns
8567 // and never hides languages. Hover mirrors the old dim/highlight behaviour.
8568 //
8569 // Must run BEFORE `new Chart(canvas, …)` so Chart.js' resize observer binds to
8570 // the inner wrapper (not the full-width host). Returns a holder whose `.chart`
8571 // field the caller assigns once the chart exists, so hover can drive it.
8572 // expandBtnId: when set (compact card), the legend is capped to what fits in
8573 // 2 columns at the available height and a trailing "+N more" row links to Full
8574 // View. When null (Full View itself) every language is shown across (up to) 2
8575 // tall columns. Languages are ordered by code lines so the compact view keeps
8576 // the biggest ones; colours/hover still key off each language's original index.
8577 function attachScatterLegend(canvas, expandBtnId) {
8578 var holder = { chart: null };
8579 var host = canvas && canvas.parentNode;
8580 if (!host) return holder;
8581 host.style.display = 'flex';
8582 host.style.alignItems = 'center';
8583 host.style.gap = '12px';
8584 var cwrap = document.createElement('div');
8585 cwrap.style.cssText = 'position:relative;flex:1 1 auto;min-width:0;height:100%;';
8586 host.insertBefore(cwrap, canvas);
8587 cwrap.appendChild(canvas);
8588
8589 var n = SCAT_D.length;
8590 var availH = Math.max(120, host.clientHeight || 224);
8591 var rowsFit = Math.max(2, Math.floor(availH / 18)); // readable pitch
8592 // Never more than 2 columns; compact view truncates to fit, Full View shows all.
8593 var truncated = expandBtnId ? (n > 2 * rowsFit) : false;
8594 var realShown = truncated ? (2 * rowsFit - 1) : n;
8595 var totalItems = truncated ? (2 * rowsFit) : n;
8596 // Split into 2 equal columns once a single column would exceed ~18 rows, even
8597 // when the (tall) Full-View modal could fit them all in one column.
8598 var cols = totalItems > Math.min(rowsFit, 18) ? 2 : 1;
8599 var perCol = Math.ceil(totalItems / cols);
8600 var rowH = Math.max(14, Math.min(30, Math.floor(availH / perCol)));
8601
8602 // Order by code lines desc so the compact view keeps the biggest languages.
8603 var order = SCAT_D.map(function(_, i){ return i; })
8604 .sort(function(a, b){ return (SCAT_D[b].code || 0) - (SCAT_D[a].code || 0); });
8605
8606 var leg = document.createElement('div');
8607 leg.style.cssText = 'flex:0 0 auto;display:grid;grid-auto-flow:column;'
8608 + 'grid-template-rows:repeat(' + perCol + ',' + rowH + 'px);column-gap:18px;'
8609 + 'align-content:center;font-size:12px;line-height:1;';
8610 function setHi(idx) {
8611 var chart = holder.chart; if (!chart) return;
8612 chart.$hiDs = idx; // read by scatterLabelPlugin so labels fade in step
8613 chart.data.datasets.forEach(function(ds, i) {
8614 var b = PALETTE[i % PALETTE.length];
8615 ds.backgroundColor = i === idx ? b + 'b8' : b + '20';
8616 ds.borderColor = i === idx ? b : b + '30';
8617 });
8618 chart.setActiveElements([{ datasetIndex: idx, index: 0 }]);
8619 chart.update();
8620 }
8621 function clearHi() {
8622 var chart = holder.chart; if (!chart) return;
8623 chart.$hiDs = null;
8624 chart.data.datasets.forEach(function(ds, i) {
8625 var b = PALETTE[i % PALETTE.length];
8626 ds.backgroundColor = b + 'b8';
8627 ds.borderColor = b;
8628 });
8629 chart.setActiveElements([]);
8630 chart.update('none');
8631 }
8632 function addItem(swColor, label, idx, isMore) {
8633 var it = document.createElement('div');
8634 it.style.cssText = 'display:flex;align-items:center;gap:7px;white-space:nowrap;'
8635 + ((idx != null || isMore) ? 'cursor:pointer;' : '');
8636 var sw = document.createElement('span');
8637 sw.style.cssText = 'width:22px;height:12px;border-radius:2px;flex:0 0 auto;background:'
8638 + swColor + ';' + (isMore ? 'opacity:0.45;' : '');
8639 var tx = document.createElement('span');
8640 tx.textContent = label;
8641 if (isMore) { tx.style.fontStyle = 'italic'; tx.style.opacity = '0.8'; }
8642 it.appendChild(sw); it.appendChild(tx);
8643 if (idx != null) {
8644 it.addEventListener('mouseenter', function(){ setHi(idx); });
8645 it.addEventListener('mouseleave', clearHi);
8646 }
8647 if (isMore) {
8648 it.addEventListener('click', function(){
8649 var b = document.getElementById(expandBtnId); if (b) b.click();
8650 });
8651 }
8652 leg.appendChild(it);
8653 }
8654 for (var k = 0; k < realShown; k++) {
8655 var oi = order[k];
8656 addItem(PALETTE[oi % PALETTE.length], SCAT_D[oi].lang, oi, false);
8657 }
8658 if (truncated) addItem('#9a8c82', '+' + (n - realShown) + ' more — Full View', null, true);
8659 host.appendChild(leg);
8660 return holder;
8661 }
8662
8663 // ── Scatter / Bubble chart ────────────────────────────────────────────────
8664 (function() {
8665 var canvas = document.getElementById('canvas-scatter');
8666 if (!canvas || !SCAT_D || !SCAT_D.length) return;
8667 var maxP = Math.max.apply(null, SCAT_D.map(function(d){return d.physical;})) || 1;
8668 var maxFx = Math.max.apply(null, SCAT_D.map(function(d){return d.files;})) || 1;
8669 var c = clr();
8670 var legHolder = attachScatterLegend(canvas, 'scatter-expand-btn');
8671 var chart = new Chart(canvas, {
8672 type: 'bubble',
8673 data: {
8674 datasets: SCAT_D.map(function(d, i) {
8675 return {
8676 label: d.lang,
8677 data: [{ x: d.files, y: d.code, r: Math.max(5, Math.round(Math.sqrt(d.physical/maxP)*20)) }],
8678 backgroundColor: PALETTE[i % PALETTE.length] + 'b8',
8679 borderColor: PALETTE[i % PALETTE.length], borderWidth: 1,
8680 hoverBorderWidth: 2
8681 };
8682 })
8683 },
8684 options: {
8685 responsive: true, maintainAspectRatio: false,
8686 onHover: chartCursor,
8687 animation: { duration: 500, easing: 'easeOutQuart' },
8688 layout: { padding: { top: 44, right: 12 } },
8689 scales: {
8690 x: { type: 'logarithmic', min: 0.8, max: maxFx * 2.6,
8691 grid: { color: c.grid },
8692 ticks: { color: c.text, font: { size: 11 }, maxTicksLimit: 6, callback: function(v){ return fmt(v); } },
8693 title: { display: true, text: 'Files Analyzed', color: c.text, font: { size: 11 } } },
8694 y: { grid: { color: c.grid }, ticks: { color: c.text, font: { size: 11 }, callback: function(v){return fmt(v);} },
8695 title: { display: true, text: 'Code Lines', color: c.text, font: { size: 11 } } }
8696 },
8697 plugins: {
8698 legend: { display: false },
8699 tooltip: {
8700 callbacks: {
8701 title: function(items) { return items.length ? items[0].dataset.label : ''; },
8702 label: function(ctx){
8703 var d = SCAT_D[ctx.datasetIndex];
8704 return [
8705 ' Files analyzed: ' + fmt(d.files),
8706 ' Code lines: ' + Number(d.code).toLocaleString(),
8707 ' Physical lines: ' + Number(d.physical).toLocaleString()
8708 ];
8709 }
8710 }
8711 }
8712 }
8713 },
8714 plugins: [scatterLabelPlugin()]
8715 });
8716 ALL_CHARTS.push(chart);
8717 legHolder.chart = chart;
8718 })();
8719
8720 // ── Submodule breakdown ──────────────────────────────────────────────────
8721 // No-op plugins: hover row-dimming was removed because the flashing row
8722 // background looked out of place vs. every other chart. Kept as empty stubs
8723 // so the (inline + Full View) chart configs that reference them stay valid.
8724 var rowDimPlugin = {};
8725 var barJumpPlugin = {};
8726 var subChart = null;
8727 (function() {
8728 if (!SUB_D || !SUB_D.length) return;
8729 var subYSel = document.getElementById('sub-y-axis');
8730 var subSortSel = document.getElementById('sub-sort');
8731 var wrap = document.getElementById('canvas-sub-wrap');
8732 var canvas = document.getElementById('canvas-sub');
8733 if (!canvas) return;
8734 var Y_LABELS = { code:'Code Lines', comment:'Comment Lines', blank:'Blank Lines',
8735 physical:'Physical Lines', files:'Files' };
8736 var SUB_COLS = { code:OX, comment:GN, blank:GY, physical:'#4472C4', files:'#805099' };
8737 function renderSubmodule() {
8738 var yKey = subYSel ? subYSel.value : 'code';
8739 var sortMode = subSortSel ? subSortSel.value : 'desc';
8740 var data = SUB_D.slice();
8741 if (sortMode==='desc') data.sort(function(a,b){return (b[yKey]||0)-(a[yKey]||0);});
8742 else if (sortMode==='asc') data.sort(function(a,b){return (a[yKey]||0)-(b[yKey]||0);});
8743 else data.sort(function(a,b){return a.name.localeCompare(b.name);});
8744 data = data.slice(0, 30);
8745 var c = clr();
8746 var col = SUB_COLS[yKey] || OX;
8747 if (wrap) wrap.style.height = Math.max(200, Math.min(540, data.length * 28 + 60)) + 'px';
8748 if (subChart) {
8749 subChart.data.labels = data.map(function(d){return d.name;});
8750 subChart.data.datasets[0].data = data.map(function(d){return d[yKey]||0;});
8751 subChart.data.datasets[0].backgroundColor = col;
8752 subChart.data.datasets[0].label = Y_LABELS[yKey]||yKey;
8753 subChart.options.scales.x.title.text = Y_LABELS[yKey]||yKey;
8754 subChart.update('none'); return;
8755 }
8756 subChart = new Chart(canvas, {
8757 type: 'bar',
8758 data: {
8759 labels: data.map(function(d){return d.name;}),
8760 datasets: [{ label: Y_LABELS[yKey]||yKey,
8761 data: data.map(function(d){return d[yKey]||0;}),
8762 backgroundColor: col, hoverBackgroundColor: col === OX ? '#d97020' : col,
8763 borderRadius: 3 }]
8764 },
8765 options: {
8766 indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8767 onHover: chartCursor,
8768 animation: { duration: 500, easing: 'easeOutQuart' },
8769 transitions: { active: { animation: { duration: 180, easing: 'easeOutQuart' } } },
8770 layout: { padding: { right: 64 } },
8771 scales: {
8772 x: { grid: { color: c.grid }, ticks: { color: c.text, callback: function(v){return fmt(v);} },
8773 title: { display: true, text: Y_LABELS[yKey]||yKey, color: c.text } },
8774 y: { grid: { display: false }, ticks: { color: c.text } }
8775 },
8776 plugins: {
8777 legend: { display: false },
8778 tooltip: {
8779 callbacks: {
8780 title: function(items) { return items.length ? items[0].label : ''; },
8781 label: function(ctx){
8782 var d = data[ctx.dataIndex] || {};
8783 return [
8784 ' Code: ' + Number(d.code||0).toLocaleString(),
8785 ' Comments: ' + Number(d.comment||0).toLocaleString(),
8786 ' Blanks: ' + Number(d.blank||0).toLocaleString(),
8787 ' Physical: ' + Number(d.physical||0).toLocaleString(),
8788 ' Files: ' + fmt(d.files||0)
8789 ];
8790 }
8791 }
8792 }
8793 }
8794 },
8795 plugins: [makeDlPlugin(function(v){ return fmt(v||0); }, 'end'), barJumpPlugin]
8796 });
8797 ALL_CHARTS.push(subChart);
8798 }
8799 if (subYSel) subYSel.addEventListener('change', renderSubmodule);
8800 if (subSortSel) subSortSel.addEventListener('change', renderSubmodule);
8801 renderSubmodule();
8802 })();
8803
8804 // ── Submodule composition: stacked horizontal bar (Chart.js) ─────────────
8805 var subCompChart = null;
8806 // Plugin: draw value label inside each visible segment of a stacked horizontal bar.
8807 var segLabelPlugin = {
8808 afterDatasetsDraw: function(chart) {
8809 var ctx = chart.ctx, nDs = chart.data.datasets.length;
8810 var tc = clr().text;
8811 for (var di = 0; di < nDs; di++) {
8812 var meta = chart.getDatasetMeta(di);
8813 if (meta.hidden) continue;
8814 meta.data.forEach(function(el, idx) {
8815 var v = chart.data.datasets[di].data[idx] || 0;
8816 if (!v) return;
8817 var w = Math.abs(el.x - el.base);
8818 if (w < 28) return; // too narrow to show label
8819 ctx.save();
8820 ctx.font = '600 10px Inter,ui-sans-serif,sans-serif';
8821 ctx.fillStyle = di === 0 ? '#fff' : (di === 1 ? '#fff' : '#555');
8822 ctx.textAlign = 'center';
8823 ctx.textBaseline = 'middle';
8824 ctx.fillText(fmt(v), el.base + w / 2, el.y);
8825 ctx.restore();
8826 });
8827 }
8828 }
8829 };
8830 (function() {
8831 var el = document.getElementById('submodule-donut');
8832 if (!el || !SUB_D || !SUB_D.length) return;
8833 var data = SUB_D.slice().sort(function(a,b){
8834 return ((b.code||0)+(b.comment||0)+(b.blank||0))-((a.code||0)+(a.comment||0)+(a.blank||0));
8835 }).slice(0, 15);
8836 var h = Math.max(150, Math.min(540, data.length * 40 + 90));
8837 el.style.height = h + 'px';
8838 el.style.position = 'relative';
8839 var cv = document.createElement('canvas');
8840 cv.id = 'canvas-sub-comp';
8841 el.innerHTML = '';
8842 el.appendChild(cv);
8843 var c = clr();
8844 subCompChart = new Chart(cv, {
8845 type: 'bar',
8846 data: {
8847 labels: data.map(function(d){ return d.name; }),
8848 datasets: [
8849 { label: 'Code', data: data.map(function(d){ return d.code||0; }), backgroundColor: OX, hoverBackgroundColor: '#d97020', borderRadius: 0, borderSkipped: false },
8850 { label: 'Comments', data: data.map(function(d){ return d.comment||0; }), backgroundColor: GN, hoverBackgroundColor: '#3a8a5e', borderRadius: 0, borderSkipped: false },
8851 { label: 'Blank', data: data.map(function(d){ return d.blank||0; }), backgroundColor: GY, hoverBackgroundColor: '#999', borderRadius: 0, borderSkipped: false }
8852 ]
8853 },
8854 options: {
8855 indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8856 onHover: chartCursor,
8857 animation: { duration: 500, easing: 'easeOutQuart' },
8858 transitions: { active: { animation: { duration: 180, easing: 'easeOutQuart' } } },
8859 layout: { padding: { right: 56 } },
8860 scales: {
8861 x: { stacked: true, grid: { color: c.grid }, ticks: { color: c.text, callback: function(v){ return fmt(v); } } },
8862 y: { stacked: true, grid: { display: false }, ticks: { color: c.text } }
8863 },
8864 plugins: {
8865 legend: {
8866 position: 'bottom',
8867 labels: { color: c.text, usePointStyle: true, pointStyle: 'rect', font: { size: 11, weight: '700' }, padding: 16 },
8868 onHover: function(e, item, leg) {
8869 legendCursorOn(e);
8870 var ch = leg.chart, di = item.datasetIndex;
8871 var orig = [OX, GN, GY], hov = ['#d97020','#3a8a5e','#999'];
8872 ch.data.datasets.forEach(function(ds, i) {
8873 ds.backgroundColor = i===di ? orig[i] : hexAlpha(orig[i], 0.15);
8874 ds.hoverBackgroundColor = i===di ? hov[i] : hexAlpha(orig[i], 0.15);
8875 });
8876 // show tooltip on first bar row with all datasets (index mode)
8877 var n = ch.data.datasets.length, ae = [];
8878 for (var ii = 0; ii < n; ii++) { ae.push({ datasetIndex: ii, index: 0 }); }
8879 var fp = ch.getDatasetMeta(di).data[0];
8880 ch.setActiveElements([{ datasetIndex: di, index: 0 }]);
8881 ch.tooltip.setActiveElements(ae, fp ? { x: fp.x, y: fp.y } : { x: 0, y: 0 });
8882 ch.update();
8883 },
8884 onLeave: function(e, item, leg) {
8885 legendCursorOff(e);
8886 var ch = leg.chart;
8887 var orig = [OX, GN, GY], hov = ['#d97020','#3a8a5e','#999'];
8888 ch.data.datasets.forEach(function(ds, i) { ds.backgroundColor = orig[i]; ds.hoverBackgroundColor = hov[i]; });
8889 ch.setActiveElements([]);
8890 ch.tooltip.setActiveElements([], {});
8891 ch.update('none');
8892 }
8893 },
8894 tooltip: {
8895 mode: 'index',
8896 callbacks: {
8897 title: function(items){ return items.length ? items[0].label : ''; },
8898 label: function(ctx){
8899 var v = ctx.parsed.x || 0;
8900 return ' ' + ctx.dataset.label + ': ' + Number(v).toLocaleString();
8901 },
8902 footer: function(items){
8903 var tot = items.reduce(function(s,i){ return s + (i.parsed.x||0); }, 0);
8904 return 'Total: ' + Number(tot).toLocaleString();
8905 }
8906 }
8907 }
8908 }
8909 },
8910 plugins: [makeStackedEndPlugin(function(v){ return fmt(v); }), segLabelPlugin, rowDimPlugin]
8911 });
8912 ALL_CHARTS.push(subCompChart);
8913 })();
8914
8915 // ── Semantic Metrics ─────────────────────────────────────────────────────
8916 (function() {
8917 if (!SEM_D || !SEM_D.length) return;
8918 var semSel = document.getElementById('semantic-metric');
8919 var canvas = document.getElementById('canvas-semantic');
8920 if (!canvas) return;
8921 var SEM_LABELS = { functions:'Functions', classes:'Classes / Types', variables:'Variables',
8922 imports:'Imports', tests:'Tests' };
8923 var SEM_COLS = { functions:OX, classes:'#4472C4', variables:GN, imports:'#805099', tests:'#B23030' };
8924 var SEM_HCOLS = { functions:'#d97020', classes:'#5a8ad8', variables:'#3a8a5e', imports:'#9a68b3', tests:'#cc4545' };
8925 var semChart = null;
8926 function renderSemantic() {
8927 var mKey = semSel ? semSel.value : 'functions';
8928 var data = SEM_D.slice().sort(function(a,b){return (b[mKey]||0)-(a[mKey]||0);}).slice(0,15);
8929 var c = clr();
8930 var col = SEM_COLS[mKey] || OX;
8931 var hCol = SEM_HCOLS[mKey] || '#d97020';
8932 if (semChart) {
8933 semChart.data.labels = data.map(function(d){return d.lang;});
8934 semChart.data.datasets[0].data = data.map(function(d){return d[mKey]||0;});
8935 semChart.data.datasets[0].backgroundColor = col;
8936 semChart.data.datasets[0].hoverBackgroundColor = hCol;
8937 semChart.data.datasets[0].label = SEM_LABELS[mKey]||mKey;
8938 semChart.update('none'); return;
8939 }
8940 semChart = new Chart(canvas, {
8941 type: 'bar',
8942 data: {
8943 labels: data.map(function(d){return d.lang;}),
8944 datasets: [{ label: SEM_LABELS[mKey]||mKey,
8945 data: data.map(function(d){return d[mKey]||0;}),
8946 backgroundColor: col, hoverBackgroundColor: hCol,
8947 borderRadius: 4, borderWidth: 0, hoverBorderWidth: 0 }]
8948 },
8949 options: {
8950 responsive: true, maintainAspectRatio: false,
8951 onHover: chartCursor,
8952 animation: { duration: 500, easing: 'easeOutQuart' },
8953 transitions: { active: { animation: { duration: 200, easing: 'easeOutQuart' } } },
8954 layout: { padding: { top: 18 } },
8955 scales: {
8956 x: { grid: { display: false }, ticks: { color: c.text } },
8957 y: { grid: { color: c.grid }, ticks: { color: c.text, callback: function(v){return fmt(v);} } }
8958 },
8959 plugins: {
8960 legend: { display: false },
8961 tooltip: {
8962 callbacks: {
8963 title: function(items) { return items.length ? items[0].label : ''; },
8964 label: function(ctx) {
8965 var d = data[ctx.dataIndex] || {};
8966 var lines = [' ' + (SEM_LABELS[mKey]||mKey) + ': ' + Number(ctx.parsed.y).toLocaleString()];
8967 var others = Object.keys(SEM_LABELS).filter(function(k){ return k !== mKey && (d[k]||0) > 0; });
8968 others.forEach(function(k) {
8969 lines.push(' ' + SEM_LABELS[k] + ': ' + Number(d[k]||0).toLocaleString());
8970 });
8971 return lines;
8972 }
8973 }
8974 }
8975 }
8976 },
8977 plugins: [makeDlPlugin(function(v) { return fmt(v || 0); }, 'top')]
8978 });
8979 ALL_CHARTS.push(semChart);
8980 }
8981 if (semSel) semSel.addEventListener('change', renderSemantic);
8982 renderSemantic();
8983
8984 var semExpandBtn = document.getElementById('semantic-expand-btn');
8985 if (semExpandBtn) {
8986 semExpandBtn.addEventListener('click', function() {
8987 var mKey = semSel ? semSel.value : 'functions';
8988 var n = SEM_D.length || 1;
8989 var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
8990 var modalH = Math.min(Math.max(400, n * 46 + 96), maxH);
8991 var overlay = document.createElement('div');
8992 overlay.className = 'chart-modal-overlay';
8993 var semOptHtml = '<option value="functions">Functions</option>'
8994 + '<option value="classes">Classes / Types</option>'
8995 + '<option value="variables">Variables</option>'
8996 + '<option value="imports">Imports</option>'
8997 + '<option value="tests">Tests</option>';
8998 var hdr = '<div class="chart-modal-header"><span class="chart-modal-title">Semantic Metrics \u2014 Full View</span>'
8999 + '<select class="chart-select" id="sem-modal-metric">' + semOptHtml + '</select></div>';
9000 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>';
9001 document.body.appendChild(overlay);
9002 overlay.querySelector('.chart-modal-close').addEventListener('click', function() { document.body.removeChild(overlay); });
9003 overlay.addEventListener('click', function(e) { if (e.target === overlay) document.body.removeChild(overlay); });
9004 var modalSel = document.getElementById('sem-modal-metric');
9005 if (modalSel) modalSel.value = mKey;
9006 var modalCanvas = document.getElementById('canvas-semantic-modal');
9007 var semModalChart = null;
9008 function renderSemModal(key) {
9009 if (semModalChart) { semModalChart.destroy(); semModalChart = null; }
9010 if (!modalCanvas) return;
9011 var data = SEM_D.slice().sort(function(a,b){return (b[key]||0)-(a[key]||0);});
9012 var c = clr();
9013 var col = SEM_COLS[key] || OX;
9014 var hcol = SEM_HCOLS[key] || '#d97020';
9015 semModalChart = new Chart(modalCanvas, {
9016 type: 'bar',
9017 data: {
9018 labels: data.map(function(d){return d.lang;}),
9019 datasets: [{ label: SEM_LABELS[key]||key, data: data.map(function(d){return d[key]||0;}),
9020 backgroundColor: col, hoverBackgroundColor: hcol,
9021 borderRadius: 4, borderWidth: 0, hoverBorderWidth: 0 }]
9022 },
9023 options: {
9024 responsive: true, maintainAspectRatio: false,
9025 onHover: chartCursor,
9026 animation: { duration: 500, easing: 'easeOutQuart' },
9027 transitions: { active: { animation: { duration: 200, easing: 'easeOutQuart' } } },
9028 layout: { padding: { top: 18 } },
9029 scales: {
9030 x: { grid: { display: false }, ticks: { color: c.text } },
9031 y: { grid: { color: c.grid }, ticks: { color: c.text, callback: function(v){return fmt(v);} } }
9032 },
9033 plugins: { legend: { display: false }, tooltip: { callbacks: {
9034 title: function(items){return items.length?items[0].label:'';},
9035 label: function(ctx){
9036 var d = data[ctx.dataIndex] || {};
9037 var lines = [' '+(SEM_LABELS[key]||key)+': '+Number(ctx.parsed.y).toLocaleString()];
9038 var others = Object.keys(SEM_LABELS).filter(function(k){ return k !== key && (d[k]||0) > 0; });
9039 others.forEach(function(k){ lines.push(' '+SEM_LABELS[k]+': '+Number(d[k]||0).toLocaleString()); });
9040 return lines;
9041 }
9042 }}}
9043 },
9044 plugins: [makeDlPlugin(function(v) { return fmt(v || 0); }, 'top')]
9045 });
9046 }
9047 renderSemModal(mKey);
9048 if (modalSel) modalSel.addEventListener('change', function() { renderSemModal(this.value); });
9049 });
9050 }
9051 })();
9052
9053 // ── Comment Density: comments / (code + comments) per language ──────────
9054 (function() {
9055 var canvas = document.getElementById('canvas-density');
9056 if (!canvas || !D || !D.length) return;
9057 var data = D.slice().sort(function(a,b){
9058 var da=(a.comments||0)/Math.max((a.code||0)+(a.comments||0),1);
9059 var db=(b.comments||0)/Math.max((b.code||0)+(b.comments||0),1);
9060 return db-da;
9061 });
9062 var labels = data.map(function(d){return d.lang;});
9063 var densities = data.map(function(d){
9064 var sig=(d.code||0)+(d.comments||0);
9065 return sig>0?Math.round((d.comments||0)/sig*1000)/10:0;
9066 });
9067 var wrap = canvas.parentElement;
9068 if (wrap) wrap.style.height = Math.max(150, Math.min(500, data.length*29+36))+'px';
9069 var c = clr();
9070 var densChart = new Chart(canvas, {
9071 type: 'bar',
9072 data: {
9073 labels: labels,
9074 datasets: [{ label: 'Comment %',
9075 data: densities,
9076 backgroundColor: data.map(function(_,i){return PALETTE[i%PALETTE.length];}),
9077 borderRadius: 4
9078 }]
9079 },
9080 options: {
9081 indexAxis: 'y', responsive: true, maintainAspectRatio: false,
9082 onHover: chartCursor,
9083 animation: { duration: 500, easing: 'easeOutQuart' },
9084 layout: { padding: { right: 42 } },
9085 scales: {
9086 x: { min: 0, max: 100,
9087 grid: { color: c.grid },
9088 ticks: { color: c.text, callback: function(v){return v+'%';} },
9089 title: { display: true, text: 'Comment %', color: c.text } },
9090 y: { grid: { display: false }, ticks: { color: c.text } }
9091 },
9092 plugins: {
9093 legend: { display: false },
9094 tooltip: { callbacks: {
9095 title: function(items){return items.length?items[0].label:'';},
9096 label: function(ctx){
9097 var d=data[ctx.dataIndex]||{};
9098 var sig=(d.code||0)+(d.comments||0);
9099 return [' Comment ratio: '+ctx.parsed.x+'%',
9100 ' Comments: '+Number(d.comments||0).toLocaleString(),
9101 ' Significant lines: '+Number(sig).toLocaleString()];
9102 }
9103 }}
9104 }
9105 },
9106 plugins: [makeDlPlugin(function(v) { return (v || 0) + '%'; }, 'end')]
9107 });
9108 ALL_CHARTS.push(densChart);
9109 })();
9110
9111 // ── File Size Distribution histogram ──────────────────────────────────────
9112 (function() {
9113 var canvas = document.getElementById('canvas-filesize');
9114 if (!canvas || !HIST_D || !HIST_D.length) return;
9115 var labels = HIST_D.map(function(d){return d.label;});
9116 var counts = HIST_D.map(function(d){return d.count||0;});
9117 var total = counts.reduce(function(a,b){return a+b;},0);
9118 var c = clr();
9119 var fsBg = ['#2A6846','#4472C4','#C45C10','#D4A017','#B23030'];
9120 var fsHv = ['#3a8a5e','#5a8ad8','#d97020','#e8b520','#cc4545'];
9121 var fsChart = new Chart(canvas, {
9122 type: 'bar',
9123 data: {
9124 labels: labels,
9125 datasets: [{ label: 'Files',
9126 data: counts,
9127 backgroundColor: fsBg,
9128 hoverBackgroundColor: fsHv,
9129 borderRadius: 6,
9130 borderWidth: 0,
9131 hoverBorderWidth: 0
9132 }]
9133 },
9134 options: {
9135 responsive: true, maintainAspectRatio: false,
9136 onHover: chartCursor,
9137 animation: { duration: 500, easing: 'easeOutQuart' },
9138 transitions: { active: { animation: { duration: 200, easing: 'easeOutQuart' } } },
9139 layout: { padding: { top: 18 } },
9140 scales: {
9141 x: { grid: { display: false }, ticks: { color: c.text, font: { size: 11 } } },
9142 y: { beginAtZero: true,
9143 grid: { color: c.grid },
9144 ticks: { color: c.text, precision: 0 },
9145 title: { display: true, text: 'File Count', color: c.text } }
9146 },
9147 plugins: {
9148 legend: { display: false },
9149 tooltip: { callbacks: {
9150 label: function(ctx) {
9151 var n = ctx.parsed.y;
9152 var pct = total > 0 ? Math.round(n/total*1000)/10 : 0;
9153 return [' Files: '+n, ' Share: '+pct+'%'];
9154 }
9155 }}
9156 }
9157 },
9158 plugins: [makeDlPlugin(function(v) { return fmt(v || 0); }, 'top')]
9159 });
9160 ALL_CHARTS.push(fsChart);
9161 })();
9162
9163 // ── Expand button handlers ────────────────────────────────────────────────
9164 (function() {
9165 function makeOverlay(title, h, subtitle, ctrlHtml) {
9166 var overlay = document.createElement('div');
9167 overlay.className = 'chart-modal-overlay';
9168 var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
9169 var hAttr = 'height:' + Math.min(h || 696, maxH) + 'px;';
9170 var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
9171 var hdr = '<div class="chart-modal-header"><span class="chart-modal-title">' + title + '</span>' + (ctrlHtml || '') + '</div>';
9172 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>';
9173 document.body.appendChild(overlay);
9174 overlay.querySelector('.chart-modal-close').addEventListener('click', function(){ document.body.removeChild(overlay); });
9175 overlay.addEventListener('click', function(e){ if(e.target === overlay) document.body.removeChild(overlay); });
9176 return document.getElementById('modal-expand-canvas');
9177 }
9178
9179 // Language Composition
9180 (function(){
9181 var btn = document.getElementById('comp-expand-btn');
9182 if(!btn) return;
9183 btn.addEventListener('click', function(){
9184 var activeTab = document.querySelector('[data-comp-tab].active');
9185 var compMode = activeTab ? activeTab.getAttribute('data-comp-tab') : 'absolute';
9186 var ctrlHtml = '<select class="chart-select" id="comp-modal-mode">'
9187 + '<option value="absolute">Absolute Lines</option>'
9188 + '<option value="pct">100% Normalized</option>'
9189 + '</select>';
9190 var canvas = makeOverlay('Language Composition \u2014 Full View', undefined, null, ctrlHtml);
9191 if(!canvas) return;
9192 var modalMode = document.getElementById('comp-modal-mode');
9193 if(modalMode) modalMode.value = compMode;
9194 var compModalChart = null;
9195 function renderCompModal(mode) {
9196 if(compModalChart) { compModalChart.destroy(); compModalChart = null; }
9197 var data = D.slice(0, 15);
9198 var c = clr(), isPct = mode === 'pct';
9199 var tot = function(d){ return (d.code||0)+(d.comments||0)+(d.blanks||0)||1; };
9200 var codeD = data.map(function(d){ return isPct ? (d.code||0)/tot(d)*100 : d.code||0; });
9201 var cmD = data.map(function(d){ return isPct ? (d.comments||0)/tot(d)*100 : d.comments||0; });
9202 var blD = data.map(function(d){ return isPct ? (d.blanks||0)/tot(d)*100 : d.blanks||0; });
9203 var tickCb = isPct ? function(v){return v.toFixed(0)+'%';} : function(v){return fmt(v);};
9204 compModalChart = new Chart(canvas, {
9205 type: 'bar',
9206 data: {
9207 labels: data.map(function(d){ return d.lang; }),
9208 datasets: [
9209 { label:'Code', data: codeD, backgroundColor: OX, borderRadius: 3 },
9210 { label:'Comments', data: cmD, backgroundColor: GN, borderRadius: 3 },
9211 { label:'Blanks', data: blD, backgroundColor: GY, borderRadius: 3 }
9212 ]
9213 },
9214 options: {
9215 indexAxis: 'y', responsive: true, maintainAspectRatio: false,
9216 layout: { padding: { right: 64 } },
9217 scales: {
9218 x: { stacked: true, grid: { color: c.grid }, ticks: { color: c.text, callback: tickCb } },
9219 y: { stacked: true, grid: { display: false }, ticks: { color: c.text } }
9220 },
9221 plugins: { legend: { position: 'bottom', labels: { color: c.text } } }
9222 },
9223 plugins: [makeStackedEndPlugin(function(total, idx) {
9224 if (isPct) return '';
9225 var d = data[idx]; return fmt(Math.round(d && d.physical ? d.physical : total));
9226 })]
9227 });
9228 }
9229 renderCompModal(compMode);
9230 if(modalMode) modalMode.addEventListener('change', function(){ renderCompModal(this.value); });
9231 });
9232 })();
9233
9234 // Files vs Code Lines (Scatter)
9235 (function(){
9236 var btn = document.getElementById('scatter-expand-btn');
9237 if(!btn || !SCAT_D || !SCAT_D.length) return;
9238 btn.addEventListener('click', function(){
9239 var canvas = makeOverlay('Files vs Code Lines \u2014 Full View', undefined, 'File count vs SLOC per language');
9240 if(!canvas) return;
9241 var maxP = Math.max.apply(null, SCAT_D.map(function(d){return d.physical;})) || 1;
9242 var maxFx = Math.max.apply(null, SCAT_D.map(function(d){return d.files;})) || 1;
9243 var c = clr();
9244 var scLegHolder = attachScatterLegend(canvas);
9245 var scExpand = new Chart(canvas, {
9246 type: 'bubble',
9247 data: {
9248 datasets: SCAT_D.map(function(d, i) {
9249 return {
9250 label: d.lang,
9251 data: [{ x: d.files, y: d.code, r: Math.max(5, Math.round(Math.sqrt(d.physical/maxP)*20)) }],
9252 backgroundColor: PALETTE[i % PALETTE.length] + 'b8',
9253 borderColor: PALETTE[i % PALETTE.length], borderWidth: 1,
9254 hoverBorderWidth: 2
9255 };
9256 })
9257 },
9258 options: {
9259 responsive: true, maintainAspectRatio: false,
9260 onHover: chartCursor,
9261 animation: { duration: 500, easing: 'easeOutQuart' },
9262 layout: { padding: { top: 44, right: 12 } },
9263 scales: {
9264 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 } } },
9265 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 } } }
9266 },
9267 plugins: {
9268 legend: { display: false },
9269 tooltip: { callbacks: {
9270 title: function(items){ return items.length ? items[0].dataset.label : ''; },
9271 label: function(ctx){
9272 var d = SCAT_D[ctx.datasetIndex];
9273 return [' Files analyzed: '+fmt(d.files), ' Code lines: '+Number(d.code).toLocaleString(), ' Physical lines: '+Number(d.physical).toLocaleString()];
9274 }
9275 }}
9276 }
9277 },
9278 plugins: [scatterLabelPlugin()]
9279 });
9280 scLegHolder.chart = scExpand;
9281 });
9282 })();
9283
9284 // Comment Density
9285 (function(){
9286 var btn = document.getElementById('density-expand-btn');
9287 if(!btn) return;
9288 btn.addEventListener('click', function(){
9289 var data = D.slice().sort(function(a,b){
9290 var da=(a.comments||0)/Math.max((a.code||0)+(a.comments||0),1);
9291 var db=(b.comments||0)/Math.max((b.code||0)+(b.comments||0),1);
9292 return db-da;
9293 });
9294 var h = Math.min(Math.max(672, data.length * 46 + 96), Math.max(400, Math.floor(window.innerHeight * 0.82) - 130));
9295 var canvas = makeOverlay('Comment Density \u2014 Full View', h, 'Comment ratio per language');
9296 if(!canvas) return;
9297 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; });
9298 var c = clr();
9299 new Chart(canvas, {
9300 type: 'bar',
9301 data: {
9302 labels: data.map(function(d){return d.lang;}),
9303 datasets: [{ label: 'Comment %', data: densities,
9304 backgroundColor: data.map(function(_,i){return PALETTE[i%PALETTE.length];}), borderRadius: 4 }]
9305 },
9306 options: {
9307 indexAxis: 'y', responsive: true, maintainAspectRatio: false,
9308 animation: { duration: 500, easing: 'easeOutQuart' },
9309 layout: { padding: { right: 42 } },
9310 scales: {
9311 x: { min:0, max:100, grid:{color:c.grid}, ticks:{color:c.text, callback:function(v){return v+'%';}} },
9312 y: { grid:{display:false}, ticks:{color:c.text} }
9313 },
9314 plugins: { legend:{display:false}, tooltip:{callbacks:{
9315 title:function(items){return items.length?items[0].label:'';},
9316 label:function(ctx){
9317 var d=data[ctx.dataIndex]||{};
9318 var sig=(d.code||0)+(d.comments||0);
9319 return [' Comment ratio: '+ctx.parsed.x.toFixed(1)+'%',
9320 ' Comments: '+Number(d.comments||0).toLocaleString(),
9321 ' Significant lines: '+Number(sig).toLocaleString()];
9322 }
9323 }}}
9324 },
9325 plugins: [makeDlPlugin(function(v) { return (v || 0) + '%'; }, 'end')]
9326 });
9327 });
9328 })();
9329
9330 // File Size Distribution
9331 (function(){
9332 var btn = document.getElementById('filesize-expand-btn');
9333 if(!btn || !HIST_D || !HIST_D.length) return;
9334 btn.addEventListener('click', function(){
9335 var canvas = makeOverlay('File Size Distribution \u2014 Full View', undefined, 'File count per SLOC bucket');
9336 if(!canvas) return;
9337 var labels = HIST_D.map(function(d){return d.label;});
9338 var counts = HIST_D.map(function(d){return d.count||0;});
9339 var total = counts.reduce(function(a,b){return a+b;},0);
9340 var fsBg = ['#2A6846','#4472C4','#C45C10','#D4A017','#B23030'];
9341 var fsHv = ['#3a8a5e','#5a8ad8','#d97020','#e8b520','#cc4545'];
9342 var c = clr();
9343 new Chart(canvas, {
9344 type: 'bar',
9345 data: {
9346 labels: labels,
9347 datasets: [{ label: 'Files', data: counts,
9348 backgroundColor: fsBg, hoverBackgroundColor: fsHv,
9349 borderRadius: 6, borderWidth: 0, hoverBorderWidth: 0 }]
9350 },
9351 options: {
9352 responsive: true, maintainAspectRatio: false,
9353 animation: { duration: 500, easing: 'easeOutQuart' },
9354 transitions: { active: { animation: { duration: 200, easing: 'easeOutQuart' } } },
9355 layout: { padding: { top: 18 } },
9356 scales: {
9357 x: { grid:{display:false}, ticks:{color:c.text} },
9358 y: { beginAtZero:true, grid:{color:c.grid}, ticks:{color:c.text, precision:0}, title:{display:true, text:'File Count', color:c.text} }
9359 },
9360 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+'%']; }}} }
9361 },
9362 plugins: [makeDlPlugin(function(v) { return fmt(v || 0); }, 'top')]
9363 });
9364 });
9365 })();
9366
9367 // Submodule Breakdown — Full View with live Y Axis + Sort controls
9368 (function(){
9369 var btn = document.getElementById('sub-expand-btn');
9370 if(!btn || !SUB_D || !SUB_D.length) return;
9371 btn.addEventListener('click', function(){
9372 var subYSel = document.getElementById('sub-y-axis');
9373 var subSortSel = document.getElementById('sub-sort');
9374 var initY = subYSel ? subYSel.value : 'code';
9375 var initSort = subSortSel ? subSortSel.value : 'desc';
9376 var Y_LABELS = { code:'Code Lines', comment:'Comment Lines', blank:'Blank Lines', physical:'Physical Lines', files:'Files' };
9377 var SUB_COLS = { code:OX, comment:GN, blank:GY, physical:'#4472C4', files:'#805099' };
9378 var SUB_HCOLS = { code:'#d97020', comment:'#3a8a5e', blank:'#999', physical:'#5a8ad8', files:'#9a68b3' };
9379 var n = Math.min(SUB_D.length, 30);
9380 var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
9381 var modalH = Math.min(Math.max(480, n * 36 + 96), maxH);
9382 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:'
9383 + '<select class="chart-select" id="sub-modal-y">'
9384 + '<option value="code">Code Lines</option>'
9385 + '<option value="comment">Comment Lines</option>'
9386 + '<option value="blank">Blank Lines</option>'
9387 + '<option value="physical">Physical Lines</option>'
9388 + '<option value="files">File Count</option>'
9389 + '</select></label>'
9390 + '<label style="font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:6px;flex-shrink:0;">Sort:'
9391 + '<select class="chart-select" id="sub-modal-sort">'
9392 + '<option value="desc">Value \u2193</option>'
9393 + '<option value="asc">Value \u2191</option>'
9394 + '<option value="name">Name A\u2192Z</option>'
9395 + '</select></label>';
9396 var canvas = makeOverlay('Submodule Breakdown \u2014 Full View', modalH, null, ctrlHtml);
9397 if(!canvas) return;
9398 var modalY = document.getElementById('sub-modal-y');
9399 var modalSort = document.getElementById('sub-modal-sort');
9400 if(modalY) modalY.value = initY;
9401 if(modalSort) modalSort.value = initSort;
9402 var subModalChart = null;
9403 function renderSubModal(yKey, sortMode) {
9404 if(subModalChart) { subModalChart.destroy(); subModalChart = null; }
9405 var data = SUB_D.slice();
9406 if(sortMode==='desc') data.sort(function(a,b){return (b[yKey]||0)-(a[yKey]||0);});
9407 else if(sortMode==='asc') data.sort(function(a,b){return (a[yKey]||0)-(b[yKey]||0);});
9408 else data.sort(function(a,b){return a.name.localeCompare(b.name);});
9409 data = data.slice(0, 30);
9410 var c = clr(), col = SUB_COLS[yKey]||OX, hcol = SUB_HCOLS[yKey]||'#d97020';
9411 subModalChart = new Chart(canvas, {
9412 type: 'bar',
9413 data: {
9414 labels: data.map(function(d){return d.name;}),
9415 datasets: [{ label: Y_LABELS[yKey]||yKey,
9416 data: data.map(function(d){return d[yKey]||0;}),
9417 backgroundColor: col, hoverBackgroundColor: hcol, borderRadius: 3 }]
9418 },
9419 options: {
9420 indexAxis: 'y', responsive: true, maintainAspectRatio: false,
9421 onHover: chartCursor,
9422 animation: { duration: 500, easing: 'easeOutQuart' },
9423 transitions: { active: { animation: { duration: 180, easing: 'easeOutQuart' } } },
9424 layout: { padding: { right: 72 } },
9425 scales: {
9426 x: { grid:{color:c.grid}, ticks:{color:c.text, callback:function(v){return fmt(v);}},
9427 title:{display:true, text:Y_LABELS[yKey]||yKey, color:c.text} },
9428 y: { grid:{display:false}, ticks:{color:c.text} }
9429 },
9430 plugins: {
9431 legend:{display:false},
9432 tooltip: { callbacks: {
9433 title: function(items){return items.length?items[0].label:'';},
9434 label: function(ctx){
9435 var d = data[ctx.dataIndex]||{};
9436 return [' Code: '+Number(d.code||0).toLocaleString(),
9437 ' Comments: '+Number(d.comment||0).toLocaleString(),
9438 ' Blanks: '+Number(d.blank||0).toLocaleString(),
9439 ' Physical: '+Number(d.physical||0).toLocaleString(),
9440 ' Files: '+fmt(d.files||0)];
9441 }
9442 }}
9443 }
9444 },
9445 plugins: [makeDlPlugin(function(v){return fmt(v||0);}, 'end'), barJumpPlugin]
9446 });
9447 }
9448 renderSubModal(initY, initSort);
9449 if(modalY) modalY.addEventListener('change', function(){ renderSubModal(this.value, modalSort ? modalSort.value : 'desc'); });
9450 if(modalSort) modalSort.addEventListener('change', function(){ renderSubModal(modalY ? modalY.value : 'code', this.value); });
9451 });
9452 })();
9453
9454 // Submodule Composition — Full View (Chart.js with sort control)
9455 (function(){
9456 var btn = document.getElementById('sub-comp-expand-btn');
9457 if(!btn || !SUB_D || !SUB_D.length) return;
9458 btn.addEventListener('click', function(){
9459 var n = Math.min(SUB_D.length, 20);
9460 var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
9461 var modalH = Math.min(Math.max(400, n * 40 + 90), maxH);
9462 var ctrlHtml = '<label style="font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:6px;flex-shrink:0;">Sort:'
9463 + '<select class="chart-select" id="sub-comp-modal-sort">'
9464 + '<option value="desc">Total Lines \u2193</option>'
9465 + '<option value="asc">Total Lines \u2191</option>'
9466 + '<option value="name">Name A\u2192Z</option>'
9467 + '</select></label>';
9468 var canvas = makeOverlay('Submodule Composition \u2014 Full View', modalH, null, ctrlHtml);
9469 if(!canvas) return;
9470 var modalSort = document.getElementById('sub-comp-modal-sort');
9471 var scModalChart = null;
9472 function renderSCModal(sortMode) {
9473 if(scModalChart) { scModalChart.destroy(); scModalChart = null; }
9474 var data = SUB_D.slice();
9475 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));});
9476 else if(sortMode==='name') data.sort(function(a,b){return a.name.localeCompare(b.name);});
9477 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));});
9478 data = data.slice(0, 20);
9479 var c = clr();
9480 scModalChart = new Chart(canvas, {
9481 type: 'bar',
9482 data: {
9483 labels: data.map(function(d){ return d.name; }),
9484 datasets: [
9485 { label:'Code', data:data.map(function(d){return d.code||0;}), backgroundColor:OX, hoverBackgroundColor:'#d97020', borderRadius:0, borderSkipped:false },
9486 { label:'Comments', data:data.map(function(d){return d.comment||0;}), backgroundColor:GN, hoverBackgroundColor:'#3a8a5e', borderRadius:0, borderSkipped:false },
9487 { label:'Blank', data:data.map(function(d){return d.blank||0;}), backgroundColor:GY, hoverBackgroundColor:'#999', borderRadius:0, borderSkipped:false }
9488 ]
9489 },
9490 options: {
9491 indexAxis:'y', responsive:true, maintainAspectRatio:false,
9492 onHover: chartCursor,
9493 animation: { duration: 500, easing: 'easeOutQuart' },
9494 transitions: { active: { animation: { duration: 180, easing: 'easeOutQuart' } } },
9495 layout:{ padding:{ right:72 } },
9496 scales: {
9497 x:{ stacked:true, grid:{color:c.grid}, ticks:{color:c.text, callback:function(v){return fmt(v);}} },
9498 y:{ stacked:true, grid:{display:false}, ticks:{color:c.text} }
9499 },
9500 plugins: {
9501 legend:{ position:'bottom', labels:{color:c.text, usePointStyle:true, pointStyle:'rect', font:{size:11,weight:'700'}, padding:16},
9502 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(); },
9503 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'); }
9504 },
9505 tooltip:{ mode:'index', callbacks:{
9506 title:function(items){return items.length?items[0].label:'';},
9507 label:function(ctx){return ' '+ctx.dataset.label+': '+Number(ctx.parsed.x||0).toLocaleString();},
9508 footer:function(items){var t=items.reduce(function(s,i){return s+(i.parsed.x||0);},0);return 'Total: '+Number(t).toLocaleString();}
9509 }}
9510 }
9511 },
9512 plugins: [makeStackedEndPlugin(function(v){return fmt(v);}), segLabelPlugin, rowDimPlugin]
9513 });
9514 }
9515 renderSCModal('desc');
9516 if(modalSort) modalSort.addEventListener('change', function(){ renderSCModal(this.value); });
9517 });
9518 })();
9519
9520 // Language overview (donut + line-mix) — clone both SVGs side-by-side
9521 (function(){
9522 var btn = document.getElementById('lang-overview-expand-btn');
9523 if(!btn) return;
9524 btn.addEventListener('click', function(){
9525 var src = document.getElementById('report-lang-overview');
9526 if(!src) return;
9527 var overlay = document.createElement('div');
9528 overlay.className = 'chart-modal-overlay';
9529 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>';
9530 document.body.appendChild(overlay);
9531 overlay.querySelector('.chart-modal-close').addEventListener('click', function(){ document.body.removeChild(overlay); });
9532 overlay.addEventListener('click', function(e){ if(e.target===overlay) document.body.removeChild(overlay); });
9533 var wrap = document.getElementById('lang-overview-modal-wrap');
9534 if(wrap) {
9535 wrap.innerHTML = src.innerHTML;
9536 var svgs = wrap.querySelectorAll('svg');
9537 for(var i=0;i<svgs.length;i++){
9538 svgs[i].removeAttribute('width');
9539 svgs[i].removeAttribute('height');
9540 svgs[i].style.cssText='display:block;width:100%;height:auto;';
9541 }
9542 var ov = wrap.querySelector('.r-lang-overview');
9543 if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
9544 var cells = wrap.querySelectorAll('.r-lang-overview-cell');
9545 if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
9546 if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
9547 wireDonutLegend(wrap.querySelector('svg'));
9548 wireMixLegend(wrap.querySelectorAll('svg')[1]);
9549 requestAnimationFrame(function(){
9550 var ss=wrap.querySelectorAll('svg');
9551 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%;';}}
9552 });
9553 }
9554 });
9555 })();
9556 })();
9557
9558 // ── Dark mode sync ────────────────────────────────────────────────────────
9559 document.querySelectorAll('[data-theme-toggle]').forEach(function(btn) {
9560 btn.addEventListener('click', function() {
9561 setTimeout(function() {
9562 var c = clr();
9563 ALL_CHARTS.forEach(function(chart) {
9564 if (chart.options.scales) {
9565 Object.keys(chart.options.scales).forEach(function(k) {
9566 var ax = chart.options.scales[k];
9567 if (ax.grid) ax.grid.color = c.grid;
9568 if (ax.ticks) ax.ticks.color = c.text;
9569 if (ax.title) ax.title.color = c.text;
9570 });
9571 }
9572 if (chart.options.plugins && chart.options.plugins.legend && chart.options.plugins.legend.labels)
9573 chart.options.plugins.legend.labels.color = c.text;
9574 chart.update('none');
9575 });
9576 }, 60);
9577 });
9578 });
9579
9580 // ── Pre-render all chart variants for PDF export ──────────────────────────
9581 (function() {
9582 var root = document.getElementById('pdf-variants');
9583 if (!root) return;
9584
9585 // Plugin: fill a light background behind every off-screen chart so the PNG
9586 // is opaque — without this Chart.js canvases are transparent and render as
9587 // blank white boxes in print.
9588 var PDF_BG = {
9589 id: 'pdfBg',
9590 beforeDraw: function(ch) {
9591 var ctx = ch.canvas.getContext('2d');
9592 ctx.save();
9593 ctx.globalCompositeOperation = 'destination-over';
9594 ctx.fillStyle = '#faf6f0';
9595 ctx.fillRect(0, 0, ch.canvas.width, ch.canvas.height);
9596 ctx.restore();
9597 }
9598 };
9599
9600 // Off-screen Chart.js render → PNG data-URL → destroy chart
9601 function snap(type, data, opts, w, h) {
9602 var c = document.createElement('canvas');
9603 c.width = w || 900; c.height = h || 280;
9604 var ch = new Chart(c, {
9605 type: type, data: data,
9606 options: Object.assign({}, opts, {
9607 animation: false, responsive: false, devicePixelRatio: 1,
9608 // Breathing room so labels never clip at the canvas edge
9609 layout: { padding: { top: 10, right: 18, bottom: 10, left: 10 } }
9610 }),
9611 plugins: [PDF_BG]
9612 });
9613 var png = c.toDataURL('image/png');
9614 ch.destroy();
9615 return png;
9616 }
9617
9618 function mkPanel(label, imgSrc) {
9619 var d = document.createElement('div'); d.className = 'pdf-variant-panel';
9620 if (label) {
9621 var lbl = document.createElement('div');
9622 lbl.className = 'pdf-variant-label'; lbl.textContent = label;
9623 d.appendChild(lbl);
9624 }
9625 if (imgSrc) {
9626 var img = document.createElement('img');
9627 img.className = 'pdf-variant-img'; img.src = imgSrc;
9628 d.appendChild(img);
9629 }
9630 return d;
9631 }
9632
9633 function mkGroup(title) {
9634 var g = document.createElement('div'); g.className = 'pdf-variant-group';
9635 var h = document.createElement('h2'); h.className = 'pdf-variant-group-title'; h.textContent = title;
9636 g.appendChild(h);
9637 var grid = document.createElement('div'); grid.className = 'pdf-variant-grid';
9638 g.appendChild(grid);
9639 return { group: g, grid: grid };
9640 }
9641
9642 var tc = '#43342d', gc = 'rgba(0,0,0,0.07)';
9643
9644 // ── Project Overview — 4 Y-axis variants ─────────────────────────────────
9645 var pgProj = mkGroup('Project Overview');
9646 var projVariants = [
9647 { label:'Code Lines', fn:function(d){return d.code||0;} },
9648 { label:'Comment Lines', fn:function(d){return d.comments||0;} },
9649 { label:'Physical Lines', fn:function(d){return (d.code||0)+(d.comments||0)+(d.blanks||0);} },
9650 { label:'File Count', fn:function(d){return d.files||0;} }
9651 ];
9652 projVariants.forEach(function(y) {
9653 var sorted = D.slice().sort(function(a,b){return y.fn(b)-y.fn(a);});
9654 var h = Math.max(110, Math.min(360, sorted.length*18+40));
9655 var png = snap('bar', {
9656 labels: sorted.map(function(d){return d.lang;}),
9657 datasets:[{ label:y.label, data:sorted.map(y.fn),
9658 backgroundColor:sorted.map(function(_,i){return PALETTE[i%PALETTE.length];}), borderRadius:3 }]
9659 }, {
9660 indexAxis:'y',
9661 scales:{
9662 x:{grid:{color:gc},ticks:{color:tc,callback:function(v){return fmt(v);}},title:{display:true,text:y.label,color:tc}},
9663 y:{grid:{display:false},ticks:{color:tc}}
9664 },
9665 plugins:{legend:{display:false}}
9666 }, 900, h);
9667 pgProj.grid.appendChild(mkPanel(y.label, png));
9668 });
9669 root.appendChild(pgProj.group);
9670
9671 // ── Language Composition — Absolute Lines + Composition % ────────────────
9672 var pgComp = mkGroup('Language Composition');
9673 var cData = D.slice(0,15);
9674 var totFn = function(d){return (d.code||0)+(d.comments||0)+(d.blanks||0)||1;};
9675 var compH = Math.max(110, Math.min(340, cData.length*18+50));
9676 [{id:'absolute',label:'Absolute Lines',isPct:false},{id:'pct',label:'Composition %',isPct:true}]
9677 .forEach(function(m) {
9678 var pct = m.isPct;
9679 var png = snap('bar', {
9680 labels: cData.map(function(d){return d.lang;}),
9681 datasets:[
9682 {label:'Code', data:cData.map(function(d){return pct?(d.code||0)/totFn(d)*100:d.code||0;}), backgroundColor:OX,borderRadius:3},
9683 {label:'Comments', data:cData.map(function(d){return pct?(d.comments||0)/totFn(d)*100:d.comments||0;}),backgroundColor:GN,borderRadius:3},
9684 {label:'Blanks', data:cData.map(function(d){return pct?(d.blanks||0)/totFn(d)*100:d.blanks||0;}), backgroundColor:GY,borderRadius:3}
9685 ]
9686 }, {
9687 indexAxis:'y',
9688 scales:{
9689 x:{stacked:true,grid:{color:gc},ticks:{color:tc,callback:pct?function(v){return v.toFixed(0)+'%';}:function(v){return fmt(v);}}},
9690 y:{stacked:true,grid:{display:false},ticks:{color:tc}}
9691 },
9692 plugins:{legend:{position:'bottom',labels:{color:tc}}}
9693 }, 900, compH);
9694 pgComp.grid.appendChild(mkPanel(m.label, png));
9695 });
9696 root.appendChild(pgComp.group);
9697
9698 // ── Files vs Code Lines — render off-screen (bubble chart, single-col centred) ─
9699 if (SCAT_D && SCAT_D.length) {
9700 var pgScat = mkGroup('Files vs Code Lines');
9701 pgScat.grid.classList.add('single-col'); // CSS class drives centering in print
9702 var maxP = Math.max.apply(null, SCAT_D.map(function(d){return d.physical||0;})) || 1;
9703 var scatPng = snap('bubble', {
9704 datasets: SCAT_D.map(function(d, i) {
9705 return {
9706 label: d.lang,
9707 data: [{ x: d.files, y: d.code, r: Math.max(5, Math.round(Math.sqrt((d.physical||0)/maxP)*20)) }],
9708 backgroundColor: PALETTE[i % PALETTE.length] + 'b8',
9709 borderColor: PALETTE[i % PALETTE.length], borderWidth: 1
9710 };
9711 })
9712 }, {
9713 scales: {
9714 x: { grid:{color:gc}, ticks:{color:tc}, title:{display:true, text:'Files Analyzed', color:tc} },
9715 y: { grid:{color:gc}, ticks:{color:tc, callback:function(v){return fmt(v);}}, title:{display:true, text:'Code Lines', color:tc} }
9716 },
9717 plugins: { legend:{position:'right', labels:{color:tc, boxWidth:12}} }
9718 }, 900, 260);
9719 pgScat.grid.appendChild(mkPanel('Files \u00d7 Code Lines (bubble size \u221d physical lines)', scatPng));
9720 root.appendChild(pgScat.group);
9721 }
9722
9723 // ── Semantic Metrics — up to 5 metrics, skip empty ones ─────────────────
9724 if (SEM_D && SEM_D.length) {
9725 var pgSem = mkGroup('Semantic Metrics');
9726 var SL={functions:'Functions',classes:'Classes / Types',variables:'Variables',imports:'Imports',tests:'Tests'};
9727 var SC={functions:OX,classes:'#4472C4',variables:GN,imports:'#805099',tests:'#B23030'};
9728 Object.keys(SL).forEach(function(mKey) {
9729 var data = SEM_D.slice().sort(function(a,b){return (b[mKey]||0)-(a[mKey]||0);}).slice(0,15);
9730 if (!data.some(function(d){return (d[mKey]||0)>0;})) return;
9731 var semH = 210; // vertical bar — fixed height; width drives layout, not row count
9732 var png = snap('bar', {
9733 labels: data.map(function(d){return d.lang;}),
9734 datasets:[{label:SL[mKey],data:data.map(function(d){return d[mKey]||0;}),backgroundColor:SC[mKey],borderRadius:4}]
9735 }, {
9736 scales:{
9737 x:{grid:{display:false},ticks:{color:tc}},
9738 y:{grid:{color:gc},ticks:{color:tc,callback:function(v){return fmt(v);}}}
9739 },
9740 plugins:{legend:{display:false}}
9741 }, 900, semH);
9742 pgSem.grid.appendChild(mkPanel(SL[mKey], png));
9743 });
9744 root.appendChild(pgSem.group);
9745 }
9746
9747 // ── Submodule Breakdown — 3 Y-axis variants + donut SVG clone ────────────
9748 if (SUB_D && SUB_D.length) {
9749 var pgSub = mkGroup('Submodule Breakdown');
9750 [{key:'code',label:'Code Lines',col:OX},{key:'comment',label:'Comment Lines',col:GN},{key:'files',label:'File Count',col:'#805099'}]
9751 .forEach(function(y) {
9752 var data = SUB_D.slice().sort(function(a,b){return (b[y.key]||0)-(a[y.key]||0);}).slice(0,30);
9753 if (!data.length) return;
9754 var subH = Math.max(100, Math.min(420, data.length*16+30));
9755 var png = snap('bar', {
9756 labels: data.map(function(d){return d.name;}),
9757 datasets:[{label:y.label,data:data.map(function(d){return d[y.key]||0;}),backgroundColor:y.col,borderRadius:3}]
9758 }, {
9759 indexAxis:'y',
9760 scales:{
9761 x:{grid:{color:gc},ticks:{color:tc,callback:function(v){return fmt(v);}},title:{display:true,text:y.label,color:tc}},
9762 y:{grid:{display:false},ticks:{color:tc}}
9763 },
9764 plugins:{legend:{display:false}}
9765 }, 900, subH);
9766 pgSub.grid.appendChild(mkPanel(y.label, png));
9767 });
9768 var donutEl = document.getElementById('submodule-donut');
9769 if (donutEl && donutEl.innerHTML.trim()) {
9770 var dp = document.createElement('div'); dp.className = 'pdf-variant-panel';
9771 var dl = document.createElement('div'); dl.className = 'pdf-variant-label'; dl.textContent = 'Distribution';
9772 dp.appendChild(dl);
9773 var dw = document.createElement('div'); dw.style.cssText = 'display:flex;justify-content:center;';
9774 dw.innerHTML = donutEl.innerHTML; dp.appendChild(dw);
9775 pgSub.grid.appendChild(dp);
9776 }
9777 root.appendChild(pgSub.group);
9778 }
9779 })();
9780 })();
9781 window.oxSlocChartsReady = true;
9782 } catch(e) { window.oxSlocChartError = String(e); window.oxSlocChartsReady = true; }
9783 }); // end requestAnimationFrame
9784 // Safety net: if rAF never fires (headless browsers throttle it), mark ready
9785 // unconditionally so the PDF capture does not wait the full 15 s.
9786 setTimeout(function() { if (!window.oxSlocChartsReady) window.oxSlocChartsReady = true; }, 3000);
9787 // ── SVG tooltip delegation ───────────────────────────────────────────────
9788 (function(){
9789 var tt = document.getElementById('r-tt');
9790 if (!tt) return;
9791 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
9792 function show(e, html) { tt.innerHTML=html; tt.style.display='block'; move(e); }
9793 function hide() { tt.style.display='none'; }
9794 function move(e) {
9795 var x=e.clientX+16, y=e.clientY-12;
9796 var r=tt.getBoundingClientRect();
9797 if (x+r.width>window.innerWidth-8) x=e.clientX-r.width-8;
9798 if (y+r.height>window.innerHeight-8) y=e.clientY-r.height-8;
9799 tt.style.left=x+'px'; tt.style.top=y+'px';
9800 }
9801 document.addEventListener('mouseover', function(e) {
9802 var t=e.target;
9803 while(t&&t.getAttribute){
9804 var l=t.getAttribute('data-ttl');
9805 if(l!==null){ show(e,'<strong>'+escH(l)+'</strong><br>'+escH(t.getAttribute('data-ttv')||'').replace(/\n/g,'<br>')); return; }
9806 t=t.parentNode;
9807 }
9808 });
9809 document.addEventListener('mouseout', function(e) {
9810 var t=e.target;
9811 while(t&&t.getAttribute){
9812 if(t.getAttribute('data-ttl')!==null){ hide(); return; }
9813 t=t.parentNode;
9814 }
9815 });
9816 document.addEventListener('mousemove', function(e) {
9817 if(tt.style.display!=='none') move(e);
9818 });
9819 window.addEventListener('blur', function() { hide(); });
9820 document.addEventListener('visibilitychange', function() { if(document.hidden) hide(); });
9821 })();
9822 // Auto-populate title on any td that is visually truncated but has no explicit title
9823 requestAnimationFrame(function() {
9824 document.querySelectorAll('td').forEach(function(td) {
9825 if (!td.title && td.scrollWidth > td.clientWidth) {
9826 td.title = td.textContent.trim();
9827 }
9828 });
9829 });
9830
9831 </script>
9832 <script nonce="{{ nonce }}">
9833 (function(){
9834 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'}];
9835 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);});}
9836 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
9837 function init(){
9838 var btn=document.getElementById('settings-btn');if(!btn)return;
9839 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
9840 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>';
9841 document.body.appendChild(m);
9842 var g=document.getElementById('scheme-grid');
9843 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);});
9844 var cl=document.getElementById('settings-close');
9845 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);
9846 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');});
9847 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
9848 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
9849 }
9850 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
9851 }());
9852 </script>
9853 <script nonce="{{ nonce }}">
9854 (function(){
9855 // Format delta card unmodified-lines value with comma separators
9856 Array.prototype.slice.call(document.querySelectorAll('.delta-card-inline[data-raw] .delta-card-val')).forEach(function(el){
9857 var raw=parseInt(el.parentNode.getAttribute('data-raw'),10);
9858 if(!isNaN(raw))el.textContent=raw.toLocaleString();
9859 });
9860 // Format code-before / code-now numbers in the prev-scan summary line
9861 Array.prototype.slice.call(document.querySelectorAll('.prev-scan-summary [data-raw]')).forEach(function(el){
9862 var raw=parseInt(el.getAttribute('data-raw'),10);
9863 if(!isNaN(raw))el.textContent=raw.toLocaleString();
9864 });
9865 }());
9866 </script>
9867 {% if has_style_data %}
9868 <script nonce="{{ nonce }}">
9869 (function(){
9870 var CHART_DATA = {{ style_chart_json|safe }};
9871 var FILE_DATA = {{ style_file_json|safe }};
9872 var SCORE_THRESHOLD = {{ style_score_threshold }};
9873 var activeLang = CHART_DATA.length ? CHART_DATA[0].family : '';
9874 var sftSortKey = '';
9875 var sftSortDir = 1;
9876 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
9877 // Official style guide URLs — covers every guide produced by the language analysers
9878 var GUIDE_URLS = {
9879 'PEP 8':'https://peps.python.org/pep-0008/',
9880 'PEP 8 (99-col)':'https://peps.python.org/pep-0008/',
9881 'Black':'https://black.readthedocs.io/en/stable/the_black_code_style/current_style.html',
9882 'Google Python':'https://google.github.io/styleguide/pyguide.html',
9883 'Effective Go':'https://go.dev/doc/effective_go',
9884 'Uber Go':'https://github.com/uber-go/guide/blob/master/style.md',
9885 'Google Go':'https://google.github.io/styleguide/go/',
9886 'LLVM':'https://llvm.org/docs/CodingStandards.html',
9887 'Google':'https://google.github.io/styleguide/cppguide.html',
9888 'Mozilla':'https://firefox-source-docs.mozilla.org/code-quality/coding-style/',
9889 'Microsoft':'https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions',
9890 'WebKit':'https://webkit.org/code-style-guidelines/',
9891 'rustfmt defaults':'https://doc.rust-lang.org/rustfmt/',
9892 'Mozilla Rust':'https://firefox-source-docs.mozilla.org/code-quality/coding-style/coding-style-rust.html',
9893 'Rust API Guidelines':'https://rust-lang.github.io/api-guidelines/',
9894 'Relaxed (120-col)':'https://doc.rust-lang.org/rustfmt/',
9895 'Airbnb':'https://airbnb.io/javascript/',
9896 'Google JS':'https://google.github.io/styleguide/jsguide.html',
9897 'Standard.js':'https://standardjs.com/',
9898 'Prettier':'https://prettier.io/docs/en/options.html',
9899 'Airbnb TS':'https://airbnb.io/javascript/',
9900 'Google TS':'https://google.github.io/styleguide/tsguide.html',
9901 'Angular':'https://angular.dev/style-guide',
9902 'Microsoft TS':'https://github.com/Microsoft/TypeScript/wiki/Coding-guidelines',
9903 'Google Java':'https://google.github.io/styleguide/javaguide.html',
9904 'Oracle/Sun':'https://www.oracle.com/java/technologies/javase/codeconventions-contents.html',
9905 'Spring':'https://github.com/spring-projects/spring-framework/wiki/Code-Style',
9906 'JetBrains':'https://www.jetbrains.com/help/idea/code-style.html',
9907 'Android':'https://source.android.com/docs/setup/contribute/code-style',
9908 'Google Kotlin':'https://developer.android.com/kotlin/style-guide',
9909 'Apache Groovy':'https://groovy-lang.org/style-guide.html',
9910 'Gradle DSL':'https://docs.gradle.org/current/userguide/groovy_build_script_primer.html',
9911 'Scala Style Guide':'https://docs.scala-lang.org/style/',
9912 'Lightbend':'https://docs.scala-lang.org/style/',
9913 'Spark':'https://spark.apache.org/contributing.html',
9914 'RuboCop':'https://docs.rubocop.org/rubocop/',
9915 'Airbnb Ruby':'https://github.com/airbnb/ruby',
9916 'Standard Ruby':'https://github.com/standardrb/standard',
9917 'Microsoft .NET':'https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions',
9918 'Google C#':'https://google.github.io/styleguide/csharp-style.html',
9919 'StyleCop':'https://github.com/DotNetAnalyzers/StyleCopAnalyzers',
9920 'Microsoft F#':'https://learn.microsoft.com/en-us/dotnet/fsharp/style-guide/formatting',
9921 'FSharp.Formatting':'https://fsprojects.github.io/FSharp.Formatting/'
9922 };
9923 // Human-readable descriptions for each guide shown in bar tooltips
9924 var GUIDE_DESC = {
9925 'PEP 8':'4-space | 79-col | Python style standard',
9926 'PEP 8 (99-col)':'4-space | 99-col | relaxed line limit',
9927 'Black':'4-space | 88-col | double quotes enforced',
9928 'Google Python':'4-space | 80-col | double quotes preferred',
9929 'Effective Go':'tabs | ~80-col | gofmt standard',
9930 'Uber Go':'tabs | 120-col max',
9931 'Google Go':'tabs | 80-col',
9932 'LLVM':'2-space | 80-col | C/C++ LLVM project style',
9933 'Google':'2-space | 80-col | Google C++ style',
9934 'Mozilla':'4-space | 80-col | Firefox codebase style',
9935 'Microsoft':'4-space | Allman braces | C++ Win32 style',
9936 'WebKit':'4-space | 80-col | WebKit engine style',
9937 'rustfmt defaults':'4-space | 100-col | official Rust formatter',
9938 'Mozilla Rust':'4-space | 100-col | Firefox Rust style',
9939 'Rust API Guidelines':'4-space | naming + docs conventions',
9940 'Relaxed (120-col)':'4-space | 120-col | relaxed line limit',
9941 'Airbnb':'2-space | single quotes | no semicolons opt',
9942 'Google JS':'2-space | 80-col | single quotes',
9943 'Standard.js':'2-space | no semicolons | single quotes',
9944 'Prettier':'2-space | 80-col | double quotes | semicolons',
9945 'Airbnb TS':'2-space | single quotes | TypeScript variant',
9946 'Google TS':'2-space | 80-col | single quotes | TypeScript',
9947 'Angular':'2-space | Angular team TypeScript conventions',
9948 'Microsoft TS':'4-space | TypeScript compiler team style',
9949 'Google Java':'2-space | 100-col | Google Java guide',
9950 'Oracle/Sun':'4-space | 80-col | original Java conventions',
9951 'Spring':'4-space | Spring Framework code style',
9952 'JetBrains':'4-space | IntelliJ default Java style',
9953 'Android':'4-space | 100-col | AOSP Java style',
9954 'Google Kotlin':'4-space | 100-col | Android Kotlin style',
9955 'Apache Groovy':'4-space | Apache Groovy style',
9956 'Gradle DSL':'4-space | Gradle build script conventions',
9957 'Scala Style Guide':'2-space | 100-col | official Scala style',
9958 'Lightbend':'2-space | Lightbend/Akka Scala style',
9959 'Spark':'2-space | Apache Spark Scala style',
9960 'RuboCop':'2-space | 120-col | community Ruby style',
9961 'Airbnb Ruby':'2-space | 80-col | Airbnb Ruby guide',
9962 'Standard Ruby':'2-space | 80-col | StandardRB formatter',
9963 'Microsoft .NET':'4-space | Allman braces | .NET C# style',
9964 'Google C#':'2-space | Google C# style guide',
9965 'StyleCop':'4-space | StyleCop analyzer rules',
9966 'Microsoft F#':'4-space | official F# formatting guide',
9967 'FSharp.Formatting':'4-space | FSharp.Formatting conventions'
9968 };
9969 function renderBars(family){
9970 var wrap=document.getElementById('style-guide-bars');
9971 if(!wrap)return;
9972 wrap.innerHTML='';
9973 var grp=null;
9974 for(var i=0;i<CHART_DATA.length;i++){if(CHART_DATA[i].family===family){grp=CHART_DATA[i];break;}}
9975 if(!grp||!grp.guides.length)return;
9976 grp.guides.forEach(function(d){
9977 var isTop=(d.guide===grp.dominant);
9978 var row=document.createElement('div');row.className='style-guide-row';
9979 // Hover tooltip showing guide name + score + description
9980 var tip=document.createElement('div');tip.className='style-bar-tip';
9981 var desc=GUIDE_DESC[d.guide]||'';
9982 tip.textContent=d.guide+': '+d.score+'%'+(desc?' \u00b7 '+desc:'');
9983 var lbl=document.createElement('div');lbl.className='style-guide-label';
9984 lbl.textContent=d.guide;
9985 if(isTop)lbl.style.color='var(--oxide)';
9986 var track=document.createElement('div');track.className='style-guide-track';
9987 var fill=document.createElement('div');fill.className='style-guide-fill';
9988 fill.style.width='0%';
9989 var pct=document.createElement('div');pct.className='style-guide-score';
9990 pct.textContent=d.score+'%';
9991 if(isTop)pct.style.color='var(--oxide)';
9992 track.appendChild(fill);
9993 row.appendChild(tip);
9994 row.appendChild(lbl);row.appendChild(track);row.appendChild(pct);
9995 wrap.appendChild(row);
9996 setTimeout(function(f,s){return function(){f.style.width=s+'%';};}(fill,d.score),60);
9997 });
9998 }
9999 function initTabs(){
10000 var tabsWrap=document.getElementById('style-lang-tabs');
10001 if(!tabsWrap||!CHART_DATA.length)return;
10002 CHART_DATA.forEach(function(grp){
10003 var btn=document.createElement('button');
10004 btn.className='style-lang-tab'+(grp.family===activeLang?' active':'');
10005 btn.textContent=grp.family+' ('+grp.files+')';
10006 btn.onclick=function(){
10007 activeLang=grp.family;
10008 var tabs=tabsWrap.querySelectorAll('.style-lang-tab');
10009 for(var i=0;i<tabs.length;i++)tabs[i].className='style-lang-tab';
10010 btn.className='style-lang-tab active';
10011 renderBars(activeLang);
10012 };
10013 tabsWrap.appendChild(btn);
10014 });
10015 renderBars(activeLang);
10016 }
10017 function buildGuideHtml(guide){
10018 if(!guide||guide==='\u2014'||guide==='Unknown')return'<span style="color:var(--muted);">\u2014</span>';
10019 var url=GUIDE_URLS[guide];
10020 var desc=GUIDE_DESC[guide]||'';
10021 var tipText='Open official '+guide+' documentation'+(desc?' \u00b7 '+desc:'');
10022 if(url){return'<a href="'+escH(url)+'" target="_blank" rel="noopener" class="style-badge">'+escH(guide)+'</a>';}
10023 return'<span class="style-badge">'+escH(guide)+'</span>';
10024 }
10025 function buildSigsHtml(sigs){
10026 if(!sigs||!sigs.length)return'<span style="color:var(--muted);">\u2014</span>';
10027 var html='';
10028 var visible=sigs.slice(0,2);
10029 var rest=sigs.slice(2);
10030 visible.forEach(function(s){html+='<span class="style-sig-chip">'+escH(s.v)+'</span>';});
10031 if(rest.length){html+='<span style="color:var(--muted);font-size:11px;margin-left:2px;">\u22EF</span>';}
10032 return html;
10033 }
10034 var _sigPop=null;
10035 window.showSigPop=function(btn,ev){
10036 ev.stopPropagation();
10037 if(_sigPop){var prev=_sigPop;_sigPop=null;prev.remove();if(btn._ownPop===prev)return;}
10038 var sigs;try{sigs=JSON.parse(btn.getAttribute('data-sigs'));}catch(e){return;}
10039 var pop=document.createElement('div');
10040 pop.className='style-sig-pop';
10041 pop.setAttribute('role','tooltip');
10042 var inner='<div class="style-sig-pop-title">All Signals</div>';
10043 sigs.forEach(function(s){
10044 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>';
10045 });
10046 pop.innerHTML=inner;
10047 document.body.appendChild(pop);
10048 _sigPop=pop;
10049 btn._ownPop=pop;
10050 var r=btn.getBoundingClientRect();
10051 var pw=pop.offsetWidth||220;
10052 var left=r.left;
10053 if(left+pw>window.innerWidth-8)left=window.innerWidth-pw-8;
10054 if(left<8)left=8;
10055 var top=r.bottom+6;
10056 if(top+(pop.offsetHeight||120)>window.innerHeight-8)top=r.top-(pop.offsetHeight||120)-6;
10057 pop.style.left=left+'px';
10058 pop.style.top=top+'px';
10059 function dismiss(e){if(!pop.contains(e.target)){pop.remove();if(_sigPop===pop)_sigPop=null;document.removeEventListener('click',dismiss);document.removeEventListener('keydown',dismissKey);}}
10060 function dismissKey(e){if(e.key==='Escape'){pop.remove();if(_sigPop===pop)_sigPop=null;document.removeEventListener('click',dismiss);document.removeEventListener('keydown',dismissKey);}}
10061 setTimeout(function(){document.addEventListener('click',dismiss);document.addEventListener('keydown',dismissKey);},0);
10062 }
10063 var sftRows=[];
10064 var sftFilteredRows=[];
10065 var sftCurrentPage=1;
10066 function sftGetPageSize(){
10067 var sel=document.getElementById('sft-page-size');
10068 var v=sel?sel.value:'20';
10069 return v==='all'?Infinity:parseInt(v,10);
10070 }
10071 function sftApplyFilter(){
10072 var inp=document.getElementById('sft-search');
10073 var q=inp?inp.value.toLowerCase():'';
10074 var sorted=sftRows.slice();
10075 if(sftSortKey){
10076 sorted.sort(function(a,b){
10077 if(sftSortKey==='score'){var av=a.score||0,bv=b.score||0;return sftSortDir*(av-bv);}
10078 var av=String(a[sftSortKey]||'').toLowerCase(),bv=String(b[sftSortKey]||'').toLowerCase();
10079 return av<bv?-1*sftSortDir:av>bv?1*sftSortDir:0;
10080 });
10081 }
10082 sftFilteredRows=q===''?sorted:sorted.filter(function(f){
10083 return (f.path||'').toLowerCase().indexOf(q)>=0
10084 ||(f.lang||'').toLowerCase().indexOf(q)>=0
10085 ||(f.guide||'').toLowerCase().indexOf(q)>=0
10086 ||(f.indent||'').toLowerCase().indexOf(q)>=0;
10087 });
10088 sftCurrentPage=1;
10089 renderSftTable();
10090 }
10091 function renderSftTable(){
10092 var tbody=document.getElementById('style-file-tbody');
10093 if(!tbody)return;
10094 var ps=sftGetPageSize();
10095 var total=sftFilteredRows.length;
10096 var totalAll=sftRows.length;
10097 var totalPages=ps===Infinity?1:Math.max(1,Math.ceil(total/ps));
10098 if(sftCurrentPage>totalPages)sftCurrentPage=totalPages;
10099 if(sftCurrentPage<1)sftCurrentPage=1;
10100 var start=ps===Infinity?0:(sftCurrentPage-1)*ps;
10101 var end=ps===Infinity?total:Math.min(start+ps,total);
10102 var page=sftFilteredRows.slice(start,end);
10103 var html='';
10104 page.forEach(function(f){
10105 var barW=Math.round(f.score);
10106 var guide=f.guide&&f.guide!=='Unknown'?f.guide:'';
10107 var badge=guide?buildGuideHtml(guide):'<span style="color:var(--muted);">\u2014</span>';
10108 var sigHtml=buildSigsHtml(f.signals);
10109 var rowClass=SCORE_THRESHOLD>0&&f.score<SCORE_THRESHOLD?' class="style-row-warn"':'';
10110 html+='<tr'+rowClass+'>'
10111 +'<td title="'+escH(f.path)+'">'+escH(f.path.replace(/^.*[\/\\]/,''))+'</td>'
10112 +'<td>'+escH(f.lang)+'</td>'
10113 +'<td>'+escH(f.indent)+'</td>'
10114 +'<td class="guide-cell" data-gtip="'+(guide?(escH(guide)+(GUIDE_DESC[guide]?' \u00b7 '+escH(GUIDE_DESC[guide]):'')):'')+'">' +badge+'</td>'
10115 +'<td><span class="style-score-bar"><span class="style-score-fill" style="width:'+barW+'%"></span></span>'+f.score+'%</td>'
10116 +'<td class="sig-cell" data-sigs="'+escH(JSON.stringify(f.signals||[]))+'">'+sigHtml+'</td>'
10117 +'</tr>';
10118 });
10119 tbody.innerHTML=html||'<tr><td colspan="6" style="text-align:center;color:var(--muted);padding:18px;">No style-analysed files</td></tr>';
10120 var pageInfo=document.getElementById('sft-page-info');
10121 var firstBtn=document.getElementById('sft-first');
10122 var prevBtn=document.getElementById('sft-prev');
10123 var nextBtn=document.getElementById('sft-next');
10124 var lastBtn=document.getElementById('sft-last');
10125 var jumpInput=document.getElementById('sft-page-jump');
10126 var pageTotal=document.getElementById('sft-page-total');
10127 var countLabel=document.getElementById('sft-count-label');
10128 if(pageInfo){
10129 if(total===0){pageInfo.textContent='No results';}
10130 else if(ps===Infinity){pageInfo.textContent='All '+total.toLocaleString()+' files';}
10131 else{pageInfo.textContent=(start+1)+'\u2013'+end+' of '+total.toLocaleString()+' files';}
10132 }
10133 if(countLabel){countLabel.textContent=(total<totalAll&&total>0)?'('+total.toLocaleString()+' matching)':'';}
10134 var edgeOff=ps===Infinity;
10135 if(firstBtn)firstBtn.disabled=sftCurrentPage<=1||edgeOff;
10136 if(prevBtn)prevBtn.disabled=sftCurrentPage<=1||edgeOff;
10137 if(nextBtn)nextBtn.disabled=sftCurrentPage>=totalPages||edgeOff;
10138 if(lastBtn)lastBtn.disabled=sftCurrentPage>=totalPages||edgeOff;
10139 if(jumpInput){jumpInput.value=sftCurrentPage;jumpInput.max=totalPages;jumpInput.disabled=edgeOff;}
10140 if(pageTotal)pageTotal.textContent=totalPages.toLocaleString();
10141 }
10142 function initStyleTable(){
10143 if(!FILE_DATA.length){
10144 var tb=document.getElementById('style-file-tbody');
10145 if(tb)tb.innerHTML='<tr><td colspan="6" style="text-align:center;color:var(--muted);padding:18px;">No style-analysed files</td></tr>';
10146 return;
10147 }
10148 sftRows=FILE_DATA.slice();
10149 sftFilteredRows=sftRows.slice();
10150 sftApplyFilter();
10151 // Signal & guide cell tooltip (appears above hovered cell, arrow points down)
10152 var chipTipEl=document.createElement('div');
10153 chipTipEl.className='sig-tip';
10154 document.body.appendChild(chipTipEl);
10155 function _showSigTip(html,cell){
10156 chipTipEl.innerHTML=html;
10157 chipTipEl.style.display='block';
10158 var r=cell.getBoundingClientRect();
10159 var tw=chipTipEl.offsetWidth||220;
10160 var th=chipTipEl.offsetHeight||80;
10161 var cx=r.left+r.width/2;
10162 var left=cx-tw/2;
10163 if(left<8)left=8;
10164 if(left+tw>window.innerWidth-8)left=window.innerWidth-tw-8;
10165 var arrowPct=Math.round((cx-left)/tw*100);
10166 if(arrowPct<10)arrowPct=10;
10167 if(arrowPct>90)arrowPct=90;
10168 chipTipEl.style.setProperty('--sig-tip-ax',arrowPct+'%');
10169 var top=r.top-th-12;
10170 if(top<8)top=r.bottom+8;
10171 chipTipEl.style.left=left+'px';
10172 chipTipEl.style.top=top+'px';
10173 chipTipEl.classList.add('visible');
10174 }
10175 function _hideSigTip(){
10176 chipTipEl.classList.remove('visible');
10177 chipTipEl.style.display='none';
10178 }
10179 function _buildSigHtml(cell){
10180 var sigs;try{sigs=JSON.parse(cell.getAttribute('data-sigs'));}catch(ex){return null;}
10181 if(!sigs||!sigs.length)return null;
10182 var html='<div class="sig-tip-hd">Signals</div>';
10183 sigs.forEach(function(s){
10184 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>';
10185 });
10186 return html;
10187 }
10188 function _buildGuideHtml(cell){
10189 var tip=cell.getAttribute('data-gtip')||'';
10190 if(!tip)return null;
10191 var parts=tip.split(' \u00b7 ',2);
10192 var html='<div class="sig-tip-hd">'+escH(parts[0])+'</div>';
10193 if(parts[1])html+='<div class="sig-tip-v" style="font-size:11px;">'+escH(parts[1])+'</div>';
10194 return html;
10195 }
10196 var sigTbl=document.getElementById('style-file-table');
10197 if(sigTbl){
10198 sigTbl.addEventListener('mouseover',function(e){
10199 var sc=e.target.closest?e.target.closest('.sig-cell'):null;
10200 var gc=e.target.closest?e.target.closest('.guide-cell'):null;
10201 if(sc){var h=_buildSigHtml(sc);if(h)_showSigTip(h,sc);return;}
10202 if(gc){var h=_buildGuideHtml(gc);if(h){var badge=gc.querySelector('.style-badge')||gc;_showSigTip(h,badge);}return;}
10203 _hideSigTip();
10204 });
10205 sigTbl.addEventListener('mouseleave',function(){
10206 _hideSigTip();
10207 });
10208 sigTbl.addEventListener('mouseout',function(e){
10209 if(!e.relatedTarget||!sigTbl.contains(e.relatedTarget))_hideSigTip();
10210 });
10211 }
10212 // Wire up sortable column headers
10213 var ths=document.querySelectorAll('#style-file-table thead th[data-sort-key]');
10214 for(var i=0;i<ths.length;i++){(function(th){
10215 th.style.cursor='pointer';
10216 th.addEventListener('click',function(){
10217 var key=th.getAttribute('data-sort-key');
10218 if(sftSortKey===key){sftSortDir*=-1;}else{sftSortKey=key;sftSortDir=1;}
10219 for(var j=0;j<ths.length;j++){
10220 ths[j].classList.remove('sft-sort-asc','sft-sort-desc');
10221 var ind=ths[j].querySelector('.style-sort-ind');
10222 if(ind)ind.textContent='\u25BE';
10223 }
10224 th.classList.add(sftSortDir===1?'sft-sort-asc':'sft-sort-desc');
10225 var tind=th.querySelector('.style-sort-ind');
10226 if(tind)tind.textContent=sftSortDir===1?'\u25B2':'\u25BC';
10227 sftApplyFilter();
10228 });
10229 })(ths[i]);}
10230 var searchInput=document.getElementById('sft-search');
10231 if(searchInput){
10232 var sftTimer=null;
10233 searchInput.addEventListener('input',function(){clearTimeout(sftTimer);sftTimer=setTimeout(sftApplyFilter,200);});
10234 }
10235 var pageSel=document.getElementById('sft-page-size');
10236 if(pageSel){pageSel.addEventListener('change',function(){sftCurrentPage=1;renderSftTable();});}
10237 var sftFirstBtn=document.getElementById('sft-first');
10238 var sftPrevBtn=document.getElementById('sft-prev');
10239 var sftNextBtn=document.getElementById('sft-next');
10240 var sftLastBtn=document.getElementById('sft-last');
10241 var sftJumpInput=document.getElementById('sft-page-jump');
10242 if(sftFirstBtn){sftFirstBtn.addEventListener('click',function(){sftCurrentPage=1;renderSftTable();});}
10243 if(sftPrevBtn){sftPrevBtn.addEventListener('click',function(){if(sftCurrentPage>1){sftCurrentPage--;renderSftTable();}});}
10244 if(sftNextBtn){sftNextBtn.addEventListener('click',function(){
10245 var ps=sftGetPageSize();
10246 var totalPages=ps===Infinity?1:Math.ceil(sftFilteredRows.length/ps);
10247 if(sftCurrentPage<totalPages){sftCurrentPage++;renderSftTable();}
10248 });}
10249 if(sftLastBtn){sftLastBtn.addEventListener('click',function(){
10250 var ps=sftGetPageSize();
10251 sftCurrentPage=ps===Infinity?1:Math.max(1,Math.ceil(sftFilteredRows.length/ps));
10252 renderSftTable();
10253 });}
10254 if(sftJumpInput){
10255 function sftJump(){
10256 var ps=sftGetPageSize();
10257 var totalPages=ps===Infinity?1:Math.max(1,Math.ceil(sftFilteredRows.length/ps));
10258 var v=parseInt(sftJumpInput.value,10);
10259 if(!isNaN(v)){sftCurrentPage=Math.max(1,Math.min(v,totalPages));renderSftTable();}
10260 }
10261 sftJumpInput.addEventListener('change',sftJump);
10262 sftJumpInput.addEventListener('keydown',function(e){if(e.key==='Enter')sftJump();});
10263 }
10264 }
10265 function initSigInfoBtn(){
10266 var btn=document.getElementById('sig-info-btn');
10267 if(!btn)return;
10268 btn.addEventListener('click',function(){
10269 var overlay=document.createElement('div');
10270 overlay.className='style-sig-info-overlay';
10271 var GLOSSARY=[
10272 ['Quote Style','Dominant string quote character used in the file (single quotes, double quotes, or mixed)'],
10273 ['Indentation','Leading-whitespace style detected: Tabs, 2-Space, 4-Space, 8-Space, or Mixed'],
10274 ['Brace Style','Opening brace placement: K\u0026R / Attach (same line as statement) or Allman (own line)'],
10275 ['Semicolons','Whether statement-ending semicolons are present (JS/TS). \u201cNone detected\u201d means ASI-style.'],
10276 ['Variable Declarations','Preferred declaration keyword: const/let vs var (JS), short := vs var (Go)'],
10277 ['Function Naming','Dominant function naming convention: snake_case or CamelCase'],
10278 ['Type Hints','Whether Python PEP 484 type annotations (:Type, ->Type) are used in the file'],
10279 ['Wildcard Imports','Presence of import * wildcard import statements (Java/Kotlin)'],
10280 ['Pointer Style','Pointer/reference alignment in C/C++: *var (name-attached) or Type* (type-attached)'],
10281 ['Arrow Functions','Count of arrow function => expressions detected in JS/TS files'],
10282 ['Max Line Length','Character length of the longest line found in the file'],
10283 ['Error Handling','Presence of Go-style if err != nil error-checking patterns'],
10284 ['Type Inference','Whether the C# var keyword is used for implicit type inference'],
10285 ['Frozen String Literal','Whether the # frozen_string_literal: true pragma is present (Ruby)'],
10286 ['Space Before Paren','Spacing convention before opening parentheses in control structures (C/C++)'],
10287 ['Include Guard','Whether #pragma once is used as a header include guard (C/C++)']
10288 ];
10289 var rows='';
10290 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>';});
10291 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>';
10292 document.body.appendChild(overlay);
10293 overlay.addEventListener('click',function(e){if(e.target===overlay||e.target.classList.contains('style-sig-info-close')){overlay.remove();}});
10294 });
10295 }
10296 function init(){initTabs();initStyleTable();initSigInfoBtn();}
10297 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
10298 }());
10299 </script>
10300 {% endif %}
10301 <script nonce="{{ nonce }}">
10302 (function(){
10303 var params=new URLSearchParams(location.search);
10304 if(params.get('autoprint')!=='1')return;
10305 var overlay=document.createElement('div');
10306 overlay.id='autoprint-overlay';
10307 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;';
10308 overlay.innerHTML='<div style="font-size:20px;font-weight:800;color:var(--text,#1a1a1a);">Preparing PDF\u2026</div>'
10309 +'<div style="font-size:13px;color:var(--muted,#666);">Use your browser\u2019s print dialog \u2192 <strong>Save as PDF</strong>.</div>'
10310 +'<div style="width:200px;height:4px;border-radius:2px;background:rgba(0,0,0,0.1);overflow:hidden;">'
10311 +'<div id="autoprint-bar" style="height:100%;width:0%;background:#e07b3a;transition:width 1.5s ease;border-radius:2px;"></div></div>';
10312 document.body.appendChild(overlay);
10313 setTimeout(function(){var b=document.getElementById('autoprint-bar');if(b)b.style.width='80%';},50);
10314 var deadline=Date.now()+12000;
10315 function tryPrint(){
10316 if(window.oxSlocChartsReady||Date.now()>deadline){
10317 var b=document.getElementById('autoprint-bar');
10318 if(b)b.style.width='100%';
10319 setTimeout(function(){
10320 overlay.style.display='none';
10321 window.print();
10322 },350);
10323 } else {
10324 setTimeout(tryPrint,150);
10325 }
10326 }
10327 if(document.readyState==='loading'){
10328 document.addEventListener('DOMContentLoaded',function(){setTimeout(tryPrint,250);});
10329 } else {
10330 setTimeout(tryPrint,250);
10331 }
10332 window.addEventListener('afterprint',function(){overlay.remove();});
10333 }());
10334 </script>
10335 <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>
10336 {% if let Some(banner) = report_header_footer %}
10337 <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
10338 {% endif %}
10339</body>
10340</html>"##,
10341 ext = "html"
10342)]
10343#[allow(clippy::struct_excessive_bools, dead_code)]
10346struct ReportTemplate<'a> {
10347 nonce: String,
10348 title: String,
10349 browser_title: String,
10350 scan_performed_by: String,
10351 scan_time_pst: String,
10352 tool_version: String,
10353 is_sub_report: bool,
10354 run: &'a AnalysisRun,
10355 language_rows: Vec<LanguageRow>,
10356 file_rows: Vec<FileRow>,
10357 skipped_rows: Vec<FileRow>,
10358 config_json: String,
10359 lang_chart_json: String,
10360 submodule_chart_json: String,
10361 scatter_chart_json: String,
10362 semantic_chart_json: String,
10363 file_size_histogram_json: String,
10364 has_submodule_data: bool,
10365 has_semantic_data: bool,
10366 has_coverage_data: bool,
10367 has_fn_coverage: bool,
10368 has_branch_coverage: bool,
10369 test_files_count: u64,
10370 test_assertion_count: u64,
10371 test_suite_count: u64,
10372 test_density: String,
10373 most_tested_lang: String,
10374 langs_with_tests: usize,
10375 cov_line_pct: String,
10376 cov_fn_pct: String,
10377 cov_branch_pct: String,
10378 cov_line_class: String,
10379 cov_fn_class: String,
10380 cov_branch_class: String,
10381 has_run_warnings: bool,
10382 warning_count: usize,
10383 warning_summary_rows: Vec<WarningSummaryRow>,
10384 warning_opportunity_rows: Vec<WarningOpportunityRow>,
10385 warning_console_full: String,
10386 logo_text_uri: String,
10387 small_logo_uri: String,
10388 custom_logo_uri: Option<String>,
10390 company_name: Option<String>,
10392 accent_hex: Option<String>,
10394 report_header_footer: Option<String>,
10396 chart_js: &'static str,
10397 run_id_short: String,
10398 standalone_pdf_url: Option<String>,
10402 git_commit_url: Option<String>,
10405 git_branch_url: Option<String>,
10408 has_style_data: bool,
10410 style_lang_count: usize,
10412 style_score_threshold: u8,
10414 style_chart_json: String,
10416 style_file_json: String,
10418 style_summary: Option<StyleSummary>,
10420 has_delta: bool,
10422 delta_code_added: i64,
10423 delta_code_removed: i64,
10424 delta_unmodified_lines: i64,
10425 delta_files_added: usize,
10426 delta_files_removed: usize,
10427 delta_files_modified: usize,
10428 delta_files_unchanged: usize,
10429 delta_files_total: usize,
10430 prev_code_lines: u64,
10431 prev_scan_count: usize,
10432 prev_scan_label: String,
10433 prev_run_id: String,
10434 has_cocomo: bool,
10436 cocomo_effort_str: String,
10438 cocomo_duration_str: String,
10440 cocomo_staff_str: String,
10442 cocomo_ksloc_str: String,
10444 cocomo_mode_label: String,
10446 cocomo_mode_tooltip: String,
10448 uloc: u64,
10450 dryness_pct_str: String,
10452 duplicate_group_count: usize,
10454 has_hotspots: bool,
10456 hotspot_rows: Vec<HotspotRow>,
10458 has_ownership: bool,
10460 ownership_rows: Vec<AuthorReportRow>,
10462 own_contributors: usize,
10464 own_top_name: String,
10465 own_top_pct_str: String,
10466 own_bus_factor: usize,
10467 own_total_code: u64,
10468 own_dev_code: u64,
10469 own_test_code: u64,
10470 own_test_pct_str: String,
10471 own_total_comment: u64,
10472}
10473
10474fn csv_escape(s: &str) -> String {
10479 if s.contains(',') || s.contains('"') || s.contains('\n') {
10480 format!("\"{}\"", s.replace('"', "\"\""))
10481 } else {
10482 s.to_string()
10483 }
10484}
10485
10486pub fn write_csv(run: &AnalysisRun, path: &Path) -> Result<()> {
10492 let mut out = String::new();
10493
10494 out.push_str("# Summary\r\n");
10496 out.push_str("Metric,Value\r\n");
10497 let _ = write!(out, "Run ID,{}\r\n", csv_escape(&run.tool.run_id));
10498 let _ = write!(
10499 out,
10500 "Timestamp,{}\r\n",
10501 csv_escape(
10502 &run.tool
10503 .timestamp_utc
10504 .format("%Y-%m-%d %H:%M:%S UTC")
10505 .to_string()
10506 )
10507 );
10508 let _ = write!(
10509 out,
10510 "Report Title,{}\r\n",
10511 csv_escape(&run.effective_configuration.reporting.report_title)
10512 );
10513 let _ = write!(
10514 out,
10515 "Files Analyzed,{}\r\n",
10516 run.summary_totals.files_analyzed
10517 );
10518 let _ = write!(
10519 out,
10520 "Files Skipped,{}\r\n",
10521 run.summary_totals.files_skipped
10522 );
10523 let _ = write!(
10524 out,
10525 "Physical Lines,{}\r\n",
10526 run.summary_totals.total_physical_lines
10527 );
10528 let _ = write!(out, "Code Lines,{}\r\n", run.summary_totals.code_lines);
10529 let _ = write!(
10530 out,
10531 "Comment Lines,{}\r\n",
10532 run.summary_totals.comment_lines
10533 );
10534 let _ = write!(out, "Blank Lines,{}\r\n", run.summary_totals.blank_lines);
10535 let _ = write!(
10536 out,
10537 "Mixed Lines (separate),{}\r\n",
10538 run.summary_totals.mixed_lines_separate
10539 );
10540
10541 out.push_str("\r\n# By Language\r\n");
10543 out.push_str(
10544 "Language,Files,Physical Lines,Code Lines,Comment Lines,Blank Lines,Mixed Lines\r\n",
10545 );
10546 for lang in &run.totals_by_language {
10547 let _ = write!(
10548 out,
10549 "{},{},{},{},{},{},{}\r\n",
10550 csv_escape(lang.language.display_name()),
10551 lang.files,
10552 lang.total_physical_lines,
10553 lang.code_lines,
10554 lang.comment_lines,
10555 lang.blank_lines,
10556 lang.mixed_lines_separate,
10557 );
10558 }
10559
10560 write_csv_per_file_section(&mut out, run);
10562
10563 write_csv_ownership_section(&mut out, run);
10565
10566 fs::write(path, out).with_context(|| format!("failed to write CSV to {}", path.display()))
10567}
10568
10569fn write_csv_ownership_section(out: &mut String, run: &AnalysisRun) {
10572 let rows = build_author_rows(run);
10573 if rows.is_empty() {
10574 return;
10575 }
10576 out.push_str("\r\n# Code Ownership\r\n");
10577 out.push_str(
10578 "Author,Email,Code Lines,Comment Lines,Blank Lines,Total Lines,Code %,Files Owned\r\n",
10579 );
10580 for a in &rows {
10581 let _ = write!(
10582 out,
10583 "{},{},{},{},{},{},{},{}\r\n",
10584 csv_escape(&a.name),
10585 csv_escape(&a.email),
10586 a.code,
10587 a.comment,
10588 a.blank,
10589 a.total,
10590 a.code_pct_str,
10591 a.files_owned,
10592 );
10593 }
10594}
10595
10596fn write_csv_per_file_section(out: &mut String, run: &AnalysisRun) {
10598 if run.per_file_records.is_empty() {
10599 return;
10600 }
10601 let has_activity = run
10603 .per_file_records
10604 .iter()
10605 .any(|r| r.commit_count.is_some());
10606 out.push_str("\r\n# Per File\r\n");
10607 out.push_str(
10608 "Path,Language,Size (bytes),Code Lines,Comment Lines,Blank Lines,Physical Lines,Generated,Minified,Vendor",
10609 );
10610 if has_activity {
10611 out.push_str(",Commits,Last Changed");
10612 }
10613 out.push_str("\r\n");
10614 for rec in &run.per_file_records {
10615 let _ = write!(
10616 out,
10617 "{},{},{},{},{},{},{},{},{},{}",
10618 csv_escape(&rec.relative_path),
10619 csv_escape(
10620 &rec.language
10621 .map(|l| l.display_name().to_string())
10622 .unwrap_or_default()
10623 ),
10624 rec.size_bytes,
10625 rec.effective_counts.code_lines,
10626 rec.effective_counts.comment_lines,
10627 rec.effective_counts.blank_lines,
10628 rec.raw_line_categories.total_physical_lines,
10629 rec.generated,
10630 rec.minified,
10631 rec.vendor,
10632 );
10633 if has_activity {
10634 let _ = write!(
10635 out,
10636 ",{},{}",
10637 rec.commit_count.map(|c| c.to_string()).unwrap_or_default(),
10638 csv_escape(rec.last_commit_date.as_deref().unwrap_or("")),
10639 );
10640 }
10641 out.push_str("\r\n");
10642 }
10643}
10644
10645pub fn write_diff_csv(cmp: &sloc_core::ScanComparison, path: &Path) -> Result<()> {
10651 let s = &cmp.summary;
10652 let mut out = String::new();
10653
10654 out.push_str("# Diff Summary\r\n");
10655 out.push_str("Metric,Value\r\n");
10656 let _ = write!(out, "Baseline Run,{}\r\n", csv_escape(&s.baseline_run_id));
10657 let _ = write!(out, "Current Run,{}\r\n", csv_escape(&s.current_run_id));
10658 let _ = write!(out, "Files Added,{}\r\n", cmp.files_added);
10659 let _ = write!(out, "Files Removed,{}\r\n", cmp.files_removed);
10660 let _ = write!(out, "Files Modified,{}\r\n", cmp.files_modified);
10661 let _ = write!(out, "Files Unchanged,{}\r\n", cmp.files_unchanged);
10662 let _ = write!(out, "Files Total,{}\r\n", cmp.files_total);
10663 let _ = write!(out, "Code Δ,{}\r\n", s.code_lines_delta);
10664 let _ = write!(out, "Comment Δ,{}\r\n", s.comment_lines_delta);
10665 let _ = write!(out, "Blank Δ,{}\r\n", s.blank_lines_delta);
10666 let _ = write!(out, "Total Δ,{}\r\n", s.total_lines_delta);
10667
10668 out.push_str("\r\n# File Deltas\r\n");
10669 out.push_str("Status,Path,Language,Baseline Code,Current Code,Code Δ,Baseline Comment,Current Comment,Comment Δ,Baseline Blank,Current Blank,Blank Δ,Total Δ\r\n");
10670 for f in &cmp.file_deltas {
10671 let status = match f.status {
10672 sloc_core::FileChangeStatus::Added => "Added",
10673 sloc_core::FileChangeStatus::Removed => "Removed",
10674 sloc_core::FileChangeStatus::Modified => "Modified",
10675 sloc_core::FileChangeStatus::Unchanged => "Unchanged",
10676 };
10677 let _ = write!(
10678 out,
10679 "{},{},{},{},{},{},{},{},{},{},{},{},{}\r\n",
10680 status,
10681 csv_escape(&f.relative_path),
10682 csv_escape(f.language.as_deref().unwrap_or("")),
10683 f.baseline_code,
10684 f.current_code,
10685 f.code_delta,
10686 f.baseline_comment,
10687 f.current_comment,
10688 f.comment_delta,
10689 f.baseline_blank,
10690 f.current_blank,
10691 f.blank_delta,
10692 f.total_delta,
10693 );
10694 }
10695
10696 fs::write(path, out).with_context(|| format!("failed to write diff CSV to {}", path.display()))
10697}
10698
10699fn crc32(data: &[u8]) -> u32 {
10708 let mut crc: u32 = 0xffff_ffff;
10709 for &b in data {
10710 crc ^= u32::from(b);
10711 for _ in 0..8 {
10712 crc = if crc & 1 == 0 {
10713 crc >> 1
10714 } else {
10715 (crc >> 1) ^ 0xedb8_8320
10716 };
10717 }
10718 }
10719 !crc
10720}
10721
10722struct ZipEntry {
10723 name: Vec<u8>,
10724 data: Vec<u8>,
10725 crc: u32,
10726 offset: u32,
10727}
10728
10729#[allow(clippy::cast_possible_truncation)] fn zip_add(entries: &mut Vec<ZipEntry>, buf: &mut Vec<u8>, name: &str, data: Vec<u8>) {
10731 let crc = crc32(&data);
10732 let offset = buf.len() as u32;
10733 let name_bytes = name.as_bytes().to_vec();
10734 let size = data.len() as u32;
10735
10736 buf.extend_from_slice(&0x0403_4b50_u32.to_le_bytes());
10738 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());
10744 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());
10747 buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&name_bytes);
10749 buf.extend_from_slice(&data);
10750
10751 entries.push(ZipEntry {
10752 name: name_bytes,
10753 data,
10754 crc,
10755 offset,
10756 });
10757}
10758
10759#[allow(clippy::cast_possible_truncation)] fn zip_finish(mut buf: Vec<u8>, entries: &[ZipEntry]) -> Vec<u8> {
10761 let central_start = buf.len() as u32;
10762
10763 for e in entries {
10764 let size = e.data.len() as u32;
10765 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());
10773 buf.extend_from_slice(&size.to_le_bytes());
10774 buf.extend_from_slice(&size.to_le_bytes());
10775 buf.extend_from_slice(&(e.name.len() as u16).to_le_bytes());
10776 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());
10782 buf.extend_from_slice(&e.name);
10783 }
10784
10785 let central_size = buf.len() as u32 - central_start;
10786 let n = entries.len() as u16;
10787
10788 buf.extend_from_slice(&0x0605_4b50_u32.to_le_bytes());
10790 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());
10795 buf.extend_from_slice(¢ral_start.to_le_bytes());
10796 buf.extend_from_slice(&0u16.to_le_bytes()); buf
10799}
10800
10801fn xml_escape(s: &str) -> String {
10802 s.replace('&', "&")
10803 .replace('<', "<")
10804 .replace('>', ">")
10805 .replace('"', """)
10806 .replace('\'', "'")
10807}
10808
10809const XLS_HEADER: u32 = 1;
10821const XLS_BODY: u32 = 2;
10822const XLS_BODY_ALT: u32 = 3;
10823const XLS_NUM: u32 = 4;
10824const XLS_NUM_ALT: u32 = 5;
10825const XLS_KV_KEY: u32 = 6;
10826const XLS_KV_VAL: u32 = 7;
10827
10828struct XlSheet<'a> {
10829 name: &'a str,
10830 tab_color: &'a str, headers: &'a [&'a str],
10832 rows: Vec<Vec<String>>,
10833 col_widths: Vec<f64>, is_kv: bool, }
10836
10837#[allow(clippy::cast_possible_truncation)] fn xl_col_name(idx: usize) -> String {
10839 let mut n = idx + 1;
10840 let mut s = String::new();
10841 while n > 0 {
10842 n -= 1;
10843 s.insert(0, char::from(b'A' + (n % 26) as u8));
10844 n /= 26;
10845 }
10846 s
10847}
10848
10849const fn xl_styles() -> &'static str {
10850 "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\
10851<styleSheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">\
10852<numFmts count=\"1\">\
10853<numFmt numFmtId=\"164\" formatCode=\"#,##0\"/>\
10854</numFmts>\
10855<fonts count=\"3\">\
10856<font><sz val=\"11\"/><name val=\"Calibri\"/></font>\
10857<font><b/><sz val=\"11\"/><color rgb=\"FFFFFFFF\"/><name val=\"Calibri\"/></font>\
10858<font><b/><sz val=\"11\"/><color rgb=\"FF283790\"/><name val=\"Calibri\"/></font>\
10859</fonts>\
10860<fills count=\"5\">\
10861<fill><patternFill patternType=\"none\"/></fill>\
10862<fill><patternFill patternType=\"gray125\"/></fill>\
10863<fill><patternFill patternType=\"solid\"><fgColor rgb=\"FF283790\"/><bgColor indexed=\"64\"/></patternFill></fill>\
10864<fill><patternFill patternType=\"solid\"><fgColor rgb=\"FFF5EFE8\"/><bgColor indexed=\"64\"/></patternFill></fill>\
10865<fill><patternFill patternType=\"solid\"><fgColor rgb=\"FFFBF7F2\"/><bgColor indexed=\"64\"/></patternFill></fill>\
10866</fills>\
10867<borders count=\"2\">\
10868<border><left/><right/><top/><bottom/><diagonal/></border>\
10869<border>\
10870<left style=\"thin\"><color rgb=\"FFD0B8A0\"/></left>\
10871<right style=\"thin\"><color rgb=\"FFD0B8A0\"/></right>\
10872<top style=\"thin\"><color rgb=\"FFD0B8A0\"/></top>\
10873<bottom style=\"thin\"><color rgb=\"FFD0B8A0\"/></bottom>\
10874<diagonal/>\
10875</border>\
10876</borders>\
10877<cellStyleXfs count=\"1\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/></cellStyleXfs>\
10878<cellXfs count=\"8\">\
10879<xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\"/>\
10880<xf numFmtId=\"0\" fontId=\"1\" fillId=\"2\" borderId=\"1\" xfId=\"0\" \
10881applyFont=\"1\" applyFill=\"1\" applyBorder=\"1\" applyAlignment=\"1\">\
10882<alignment horizontal=\"center\" vertical=\"center\"/></xf>\
10883<xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"1\" xfId=\"0\" applyBorder=\"1\"/>\
10884<xf numFmtId=\"0\" fontId=\"0\" fillId=\"3\" borderId=\"1\" xfId=\"0\" applyFill=\"1\" applyBorder=\"1\"/>\
10885<xf numFmtId=\"164\" fontId=\"0\" fillId=\"0\" borderId=\"1\" xfId=\"0\" \
10886applyNumberFormat=\"1\" applyBorder=\"1\" applyAlignment=\"1\">\
10887<alignment horizontal=\"right\"/></xf>\
10888<xf numFmtId=\"164\" fontId=\"0\" fillId=\"3\" borderId=\"1\" xfId=\"0\" \
10889applyNumberFormat=\"1\" applyFill=\"1\" applyBorder=\"1\" applyAlignment=\"1\">\
10890<alignment horizontal=\"right\"/></xf>\
10891<xf numFmtId=\"0\" fontId=\"2\" fillId=\"4\" borderId=\"1\" xfId=\"0\" \
10892applyFont=\"1\" applyFill=\"1\" applyBorder=\"1\"/>\
10893<xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"1\" xfId=\"0\" applyBorder=\"1\"/>\
10894</cellXfs>\
10895</styleSheet>"
10896}
10897
10898fn xl_sheet_xml(sheet: &XlSheet<'_>) -> Vec<u8> {
10899 let ncols = sheet.headers.len();
10900 let ndata = sheet.rows.len();
10901 let last_col = xl_col_name(ncols.saturating_sub(1));
10902 let last_row = ndata + 1;
10903 let range = format!("A1:{last_col}{last_row}");
10904
10905 let mut xml = String::with_capacity(4096 + ndata * 256);
10906 let _ = write!(
10907 xml,
10908 "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
10909 <worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">\n\
10910 <sheetPr><tabColor rgb=\"{tc}\"/></sheetPr>\n\
10911 <dimension ref=\"{rng}\"/>\n\
10912 <sheetViews><sheetView workbookViewId=\"0\">\
10913 <pane ySplit=\"1\" topLeftCell=\"A2\" activePane=\"bottomLeft\" state=\"frozen\"/>\
10914 <selection pane=\"bottomLeft\" activeCell=\"A2\" sqref=\"A2\"/>\
10915 </sheetView></sheetViews>\n\
10916 <sheetFormatPr defaultRowHeight=\"15\"/>\n",
10917 tc = sheet.tab_color,
10918 rng = range,
10919 );
10920
10921 xl_write_col_widths(&mut xml, &sheet.col_widths, ncols);
10922 xml.push_str("<sheetData>\n");
10923 xl_write_header_row(&mut xml, sheet.headers);
10924 xl_write_data_rows(&mut xml, &sheet.rows, sheet.is_kv);
10925 xml.push_str("</sheetData>\n");
10926 if !sheet.is_kv && ncols > 0 {
10927 let _ = writeln!(xml, "<autoFilter ref=\"{range}\"/>");
10928 }
10929 xml.push_str("</worksheet>");
10930 xml.into_bytes()
10931}
10932
10933fn xl_write_col_widths(xml: &mut String, col_widths: &[f64], ncols: usize) {
10934 if col_widths.is_empty() {
10935 return;
10936 }
10937 let default_w = *col_widths.last().unwrap_or(&10.0);
10938 xml.push_str("<cols>\n");
10939 for ci in 0..ncols {
10940 let w = col_widths.get(ci).copied().unwrap_or(default_w);
10941 let _ = writeln!(
10942 xml,
10943 " <col min=\"{n}\" max=\"{n}\" width=\"{w:.1}\" customWidth=\"1\"/>",
10944 n = ci + 1
10945 );
10946 }
10947 xml.push_str("</cols>\n");
10948}
10949
10950fn xl_write_header_row(xml: &mut String, headers: &[&str]) {
10951 let _ = write!(xml, "<row r=\"1\" ht=\"18\" customHeight=\"1\">");
10952 for (ci, &h) in headers.iter().enumerate() {
10953 let _ = write!(
10954 xml,
10955 "<c r=\"{}1\" t=\"inlineStr\" s=\"{}\"><is><t>{}</t></is></c>",
10956 xl_col_name(ci),
10957 XLS_HEADER,
10958 xml_escape(h),
10959 );
10960 }
10961 xml.push_str("</row>\n");
10962}
10963
10964const fn xl_cell_style(is_kv: bool, ci: usize, is_num: bool, is_alt: bool) -> u32 {
10965 if is_kv {
10966 if ci == 0 {
10967 XLS_KV_KEY
10968 } else if is_num {
10969 XLS_NUM
10970 } else {
10971 XLS_KV_VAL
10972 }
10973 } else if is_num {
10974 if is_alt { XLS_NUM_ALT } else { XLS_NUM }
10975 } else if is_alt {
10976 XLS_BODY_ALT
10977 } else {
10978 XLS_BODY
10979 }
10980}
10981
10982fn xl_write_data_rows(xml: &mut String, rows: &[Vec<String>], is_kv: bool) {
10983 for (ri, row) in rows.iter().enumerate() {
10984 let row_num = ri + 2;
10985 let is_alt = ri % 2 == 1;
10986 let _ = write!(xml, "<row r=\"{row_num}\">");
10987 for (ci, cell) in row.iter().enumerate() {
10988 let cell_ref = format!("{}{}", xl_col_name(ci), row_num);
10989 let is_num = !cell.is_empty() && cell.parse::<f64>().is_ok();
10990 let s = xl_cell_style(is_kv, ci, is_num, is_alt);
10991 if is_num {
10992 let _ = write!(
10993 xml,
10994 "<c r=\"{cell_ref}\" s=\"{s}\"><v>{}</v></c>",
10995 xml_escape(cell)
10996 );
10997 } else {
10998 let _ = write!(
10999 xml,
11000 "<c r=\"{cell_ref}\" t=\"inlineStr\" s=\"{s}\"><is><t>{}</t></is></c>",
11001 xml_escape(cell),
11002 );
11003 }
11004 }
11005 xml.push_str("</row>\n");
11006 }
11007}
11008
11009fn build_xlsx(sheets: &[XlSheet<'_>]) -> Vec<u8> {
11010 let mut buf: Vec<u8> = Vec::new();
11011 let mut entries: Vec<ZipEntry> = Vec::new();
11012
11013 let mut ct = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
11015 ct.push_str("<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">\n");
11016 ct.push_str(" <Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>\n");
11017 ct.push_str(" <Default Extension=\"xml\" ContentType=\"application/xml\"/>\n");
11018 ct.push_str(" <Override PartName=\"/xl/workbook.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml\"/>\n");
11019 ct.push_str(" <Override PartName=\"/xl/styles.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml\"/>\n");
11020 for (i, _) in sheets.iter().enumerate() {
11021 let _ = writeln!(
11022 ct,
11023 " <Override PartName=\"/xl/worksheets/sheet{}.xml\" \
11024 ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml\"/>",
11025 i + 1
11026 );
11027 }
11028 ct.push_str("</Types>");
11029 zip_add(
11030 &mut entries,
11031 &mut buf,
11032 "[Content_Types].xml",
11033 ct.into_bytes(),
11034 );
11035
11036 let rels = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
11038<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\n\
11039 <Relationship Id=\"rId1\" \
11040 Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" \
11041 Target=\"xl/workbook.xml\"/>\n\
11042</Relationships>";
11043 zip_add(
11044 &mut entries,
11045 &mut buf,
11046 "_rels/.rels",
11047 rels.as_bytes().to_vec(),
11048 );
11049
11050 let mut wb = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
11052 wb.push_str(
11053 "<workbook xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" \
11054 xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\">\n",
11055 );
11056 wb.push_str(" <sheets>\n");
11057 for (i, sheet) in sheets.iter().enumerate() {
11058 let _ = writeln!(
11059 wb,
11060 " <sheet name=\"{}\" sheetId=\"{}\" r:id=\"rId{}\"/>",
11061 xml_escape(sheet.name),
11062 i + 1,
11063 i + 1
11064 );
11065 }
11066 wb.push_str(" </sheets>\n</workbook>");
11067 zip_add(&mut entries, &mut buf, "xl/workbook.xml", wb.into_bytes());
11068
11069 let mut wbr = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
11071 wbr.push_str(
11072 "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\n",
11073 );
11074 for (i, _) in sheets.iter().enumerate() {
11075 let _ = writeln!(
11076 wbr,
11077 " <Relationship Id=\"rId{}\" \
11078 Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet\" \
11079 Target=\"worksheets/sheet{}.xml\"/>",
11080 i + 1,
11081 i + 1
11082 );
11083 }
11084 let _ = writeln!(
11085 wbr,
11086 " <Relationship Id=\"rId{}\" \
11087 Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles\" \
11088 Target=\"styles.xml\"/>",
11089 sheets.len() + 1
11090 );
11091 wbr.push_str("</Relationships>");
11092 zip_add(
11093 &mut entries,
11094 &mut buf,
11095 "xl/_rels/workbook.xml.rels",
11096 wbr.into_bytes(),
11097 );
11098
11099 zip_add(
11101 &mut entries,
11102 &mut buf,
11103 "xl/styles.xml",
11104 xl_styles().as_bytes().to_vec(),
11105 );
11106
11107 for (i, sheet) in sheets.iter().enumerate() {
11109 let sheet_xml = xl_sheet_xml(sheet);
11110 let name = format!("xl/worksheets/sheet{}.xml", i + 1);
11111 zip_add(&mut entries, &mut buf, &name, sheet_xml);
11112 }
11113
11114 zip_finish(buf, &entries)
11115}
11116
11117#[allow(clippy::too_many_lines)]
11123pub fn write_xlsx(run: &AnalysisRun, path: &Path) -> Result<()> {
11124 let summary_rows: Vec<Vec<String>> = vec![
11126 vec!["Run ID".into(), run.tool.run_id.clone()],
11127 vec![
11128 "Timestamp".into(),
11129 run.tool
11130 .timestamp_utc
11131 .format("%Y-%m-%d %H:%M:%S UTC")
11132 .to_string(),
11133 ],
11134 vec![
11135 "Report Title".into(),
11136 run.effective_configuration.reporting.report_title.clone(),
11137 ],
11138 vec![
11139 "Files Analyzed".into(),
11140 run.summary_totals.files_analyzed.to_string(),
11141 ],
11142 vec![
11143 "Files Skipped".into(),
11144 run.summary_totals.files_skipped.to_string(),
11145 ],
11146 vec![
11147 "Physical Lines".into(),
11148 run.summary_totals.total_physical_lines.to_string(),
11149 ],
11150 vec![
11151 "Code Lines".into(),
11152 run.summary_totals.code_lines.to_string(),
11153 ],
11154 vec![
11155 "Comment Lines".into(),
11156 run.summary_totals.comment_lines.to_string(),
11157 ],
11158 vec![
11159 "Blank Lines".into(),
11160 run.summary_totals.blank_lines.to_string(),
11161 ],
11162 vec![
11163 "Mixed Lines (separate)".into(),
11164 run.summary_totals.mixed_lines_separate.to_string(),
11165 ],
11166 ];
11167
11168 let lang_rows: Vec<Vec<String>> = run
11170 .totals_by_language
11171 .iter()
11172 .map(|l| {
11173 vec![
11174 l.language.display_name().to_string(),
11175 l.files.to_string(),
11176 l.total_physical_lines.to_string(),
11177 l.code_lines.to_string(),
11178 l.comment_lines.to_string(),
11179 l.blank_lines.to_string(),
11180 l.mixed_lines_separate.to_string(),
11181 ]
11182 })
11183 .collect();
11184
11185 let file_rows: Vec<Vec<String>> = run
11187 .per_file_records
11188 .iter()
11189 .map(|r| {
11190 vec![
11191 r.relative_path.clone(),
11192 r.language
11193 .map(|l| l.display_name().to_string())
11194 .unwrap_or_default(),
11195 r.size_bytes.to_string(),
11196 r.effective_counts.code_lines.to_string(),
11197 r.effective_counts.comment_lines.to_string(),
11198 r.effective_counts.blank_lines.to_string(),
11199 r.raw_line_categories.total_physical_lines.to_string(),
11200 r.generated.to_string(),
11201 r.minified.to_string(),
11202 r.vendor.to_string(),
11203 ]
11204 })
11205 .collect();
11206
11207 let skipped_rows: Vec<Vec<String>> = run
11209 .skipped_file_records
11210 .iter()
11211 .map(|r| {
11212 vec![
11213 r.relative_path.clone(),
11214 format!("{:?}", r.status),
11215 r.size_bytes.to_string(),
11216 ]
11217 })
11218 .collect();
11219
11220 let summary_hdrs: &[&str] = &["Metric", "Value"];
11221 let lang_hdrs: &[&str] = &[
11222 "Language",
11223 "Files",
11224 "Physical Lines",
11225 "Code Lines",
11226 "Comments",
11227 "Blank",
11228 "Mixed",
11229 ];
11230 let file_hdrs: &[&str] = &[
11231 "Path",
11232 "Language",
11233 "Size (bytes)",
11234 "Code Lines",
11235 "Comments",
11236 "Blank Lines",
11237 "Physical Lines",
11238 "Generated",
11239 "Minified",
11240 "Vendor",
11241 ];
11242 let skipped_hdrs: &[&str] = &["Path", "Status", "Size (bytes)"];
11243
11244 let sheets = vec![
11245 XlSheet {
11246 name: "Summary",
11247 tab_color: "FF283790",
11248 headers: summary_hdrs,
11249 rows: summary_rows,
11250 col_widths: vec![26.0, 44.0],
11251 is_kv: true,
11252 },
11253 XlSheet {
11254 name: "By Language",
11255 tab_color: "FFB85D33",
11256 headers: lang_hdrs,
11257 rows: lang_rows,
11258 col_widths: vec![20.0, 9.0, 15.0, 13.0, 13.0, 11.0, 11.0],
11259 is_kv: false,
11260 },
11261 XlSheet {
11262 name: "Per File",
11263 tab_color: "FF2A6846",
11264 headers: file_hdrs,
11265 rows: file_rows,
11266 col_widths: vec![48.0, 14.0, 13.0, 13.0, 11.0, 11.0, 15.0, 11.0, 11.0, 9.0],
11267 is_kv: false,
11268 },
11269 XlSheet {
11270 name: "Skipped",
11271 tab_color: "FF7B675B",
11272 headers: skipped_hdrs,
11273 rows: skipped_rows,
11274 col_widths: vec![52.0, 24.0, 13.0],
11275 is_kv: false,
11276 },
11277 ];
11278
11279 let bytes = build_xlsx(&sheets);
11280 fs::write(path, bytes).with_context(|| format!("failed to write XLSX to {}", path.display()))
11281}
11282
11283pub fn write_diff_xlsx(cmp: &sloc_core::ScanComparison, path: &Path) -> Result<()> {
11289 let s = &cmp.summary;
11290
11291 let summary_rows: Vec<Vec<String>> = vec![
11292 vec!["Baseline Run".into(), s.baseline_run_id.clone()],
11293 vec!["Current Run".into(), s.current_run_id.clone()],
11294 vec!["Files Added".into(), cmp.files_added.to_string()],
11295 vec!["Files Removed".into(), cmp.files_removed.to_string()],
11296 vec!["Files Modified".into(), cmp.files_modified.to_string()],
11297 vec!["Files Unchanged".into(), cmp.files_unchanged.to_string()],
11298 vec!["Files Total".into(), cmp.files_total.to_string()],
11299 vec!["Code Δ".into(), s.code_lines_delta.to_string()],
11300 vec!["Comment Δ".into(), s.comment_lines_delta.to_string()],
11301 vec!["Blank Δ".into(), s.blank_lines_delta.to_string()],
11302 vec!["Total Δ".into(), s.total_lines_delta.to_string()],
11303 ];
11304
11305 let delta_rows: Vec<Vec<String>> = cmp
11306 .file_deltas
11307 .iter()
11308 .map(|f| {
11309 let status = match f.status {
11310 sloc_core::FileChangeStatus::Added => "Added",
11311 sloc_core::FileChangeStatus::Removed => "Removed",
11312 sloc_core::FileChangeStatus::Modified => "Modified",
11313 sloc_core::FileChangeStatus::Unchanged => "Unchanged",
11314 };
11315 vec![
11316 status.to_string(),
11317 f.relative_path.clone(),
11318 f.language.clone().unwrap_or_default(),
11319 f.baseline_code.to_string(),
11320 f.current_code.to_string(),
11321 f.code_delta.to_string(),
11322 f.baseline_comment.to_string(),
11323 f.current_comment.to_string(),
11324 f.comment_delta.to_string(),
11325 f.total_delta.to_string(),
11326 ]
11327 })
11328 .collect();
11329
11330 let summary_hdrs: &[&str] = &["Metric", "Value"];
11331 let delta_hdrs: &[&str] = &[
11332 "Status",
11333 "Path",
11334 "Language",
11335 "Baseline Code",
11336 "Current Code",
11337 "Code Δ",
11338 "Baseline Comment",
11339 "Current Comment",
11340 "Comment Δ",
11341 "Total Δ",
11342 ];
11343
11344 let sheets = vec![
11345 XlSheet {
11346 name: "Diff Summary",
11347 tab_color: "FF283790",
11348 headers: summary_hdrs,
11349 rows: summary_rows,
11350 col_widths: vec![26.0, 44.0],
11351 is_kv: true,
11352 },
11353 XlSheet {
11354 name: "File Deltas",
11355 tab_color: "FFB85D33",
11356 headers: delta_hdrs,
11357 rows: delta_rows,
11358 col_widths: vec![12.0, 48.0, 16.0, 14.0, 14.0, 11.0, 14.0, 14.0, 11.0, 11.0],
11359 is_kv: false,
11360 },
11361 ];
11362
11363 let bytes = build_xlsx(&sheets);
11364 fs::write(path, bytes)
11365 .with_context(|| format!("failed to write diff XLSX to {}", path.display()))
11366}
11367
11368fn html_esc(s: &str) -> String {
11371 s.replace('&', "&")
11372 .replace('<', "<")
11373 .replace('>', ">")
11374 .replace('"', """)
11375}
11376
11377#[must_use]
11381pub fn render_confluence_storage(run: &AnalysisRun, report_url: Option<&str>) -> String {
11382 let mut out = String::with_capacity(8192);
11383
11384 let project = run.effective_configuration.reporting.report_title.as_str();
11385 let branch = run.git_branch.as_deref().unwrap_or("—");
11386 let commit = run.git_commit_short.as_deref().unwrap_or("—");
11387 let scanned = run
11388 .tool
11389 .timestamp_utc
11390 .format("%Y-%m-%d %H:%M UTC")
11391 .to_string();
11392
11393 out.push_str(
11395 "<ac:structured-macro ac:name=\"info\" ac:schema-version=\"1\">\
11396 <ac:rich-text-body><p>",
11397 );
11398 let _ = write!(
11399 out,
11400 "<strong>Project:</strong> {proj} · \
11401 <strong>Branch:</strong> {branch} · \
11402 <strong>Commit:</strong> {commit} · \
11403 <strong>Scanned:</strong> {scanned}",
11404 proj = html_esc(project),
11405 branch = html_esc(branch),
11406 commit = html_esc(commit),
11407 scanned = html_esc(&scanned),
11408 );
11409 out.push_str("</p></ac:rich-text-body></ac:structured-macro>");
11410
11411 out.push_str("<h2>Summary</h2>");
11413 out.push_str(
11414 "<table><thead><tr>\
11415 <th>Files Analyzed</th><th>Code Lines</th><th>Comment Lines</th>\
11416 <th>Blank Lines</th><th>Languages</th>\
11417 </tr></thead><tbody><tr>",
11418 );
11419 let t = &run.summary_totals;
11420 let _ = write!(
11421 out,
11422 "<td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td>",
11423 t.files_analyzed,
11424 t.code_lines,
11425 t.comment_lines,
11426 t.blank_lines,
11427 run.totals_by_language.len(),
11428 );
11429 out.push_str("</tr></tbody></table>");
11430
11431 if !run.totals_by_language.is_empty() {
11433 out.push_str("<h2>Language Breakdown</h2>");
11434 out.push_str(
11435 "<table><thead><tr>\
11436 <th>Language</th><th>Files</th><th>Code</th><th>Comments</th><th>Blank</th>\
11437 </tr></thead><tbody>",
11438 );
11439 for lang in &run.totals_by_language {
11440 let _ = write!(
11441 out,
11442 "<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
11443 html_esc(lang.language.display_name()),
11444 lang.files,
11445 lang.code_lines,
11446 lang.comment_lines,
11447 lang.blank_lines,
11448 );
11449 }
11450 out.push_str("</tbody></table>");
11451 }
11452
11453 if let Some(url) = report_url {
11455 let _ = write!(
11456 out,
11457 "<p><strong>Full interactive report:</strong> \
11458 <a href=\"{url}\">{url_disp}</a></p>",
11459 url = html_esc(url),
11460 url_disp = html_esc(url),
11461 );
11462 }
11463
11464 out
11465}
11466
11467#[must_use]
11470pub fn render_confluence_wiki_markup(run: &AnalysisRun) -> String {
11471 let mut out = String::with_capacity(4096);
11472
11473 let project = run.effective_configuration.reporting.report_title.as_str();
11474 let branch = run.git_branch.as_deref().unwrap_or("—");
11475 let commit = run.git_commit_short.as_deref().unwrap_or("—");
11476 let scanned = run
11477 .tool
11478 .timestamp_utc
11479 .format("%Y-%m-%d %H:%M UTC")
11480 .to_string();
11481
11482 let _ = writeln!(out, "{{info}}");
11483 let _ = writeln!(
11484 out,
11485 "Project: {project} · Branch: {branch} · Commit: {commit} · Scanned: {scanned}"
11486 );
11487 let _ = writeln!(out, "{{info}}");
11488 out.push('\n');
11489
11490 let t = &run.summary_totals;
11491 let _ = writeln!(out, "h2. Summary");
11492 let _ = writeln!(
11493 out,
11494 "||Files Analyzed||Code Lines||Comment Lines||Blank Lines||Languages||"
11495 );
11496 let _ = writeln!(
11497 out,
11498 "|{}|{}|{}|{}|{}|",
11499 t.files_analyzed,
11500 t.code_lines,
11501 t.comment_lines,
11502 t.blank_lines,
11503 run.totals_by_language.len(),
11504 );
11505 out.push('\n');
11506
11507 if !run.totals_by_language.is_empty() {
11508 let _ = writeln!(out, "h2. Language Breakdown");
11509 let _ = writeln!(out, "||Language||Files||Code||Comments||Blank||");
11510 for lang in &run.totals_by_language {
11511 let _ = writeln!(
11512 out,
11513 "|{}|{}|{}|{}|{}|",
11514 lang.language.display_name(),
11515 lang.files,
11516 lang.code_lines,
11517 lang.comment_lines,
11518 lang.blank_lines,
11519 );
11520 }
11521 out.push('\n');
11522 }
11523
11524 let _ = writeln!(
11525 out,
11526 "*Total:* {} code lines · {} files · {} languages",
11527 t.code_lines,
11528 t.files_analyzed,
11529 run.totals_by_language.len(),
11530 );
11531
11532 out
11533}
11534
11535#[cfg(test)]
11536mod tests {
11537 use super::*;
11538 use tempfile::tempdir;
11539
11540 #[test]
11543 fn base64_encode_empty() {
11544 assert_eq!(base64_encode(b""), "");
11545 }
11546
11547 #[test]
11548 fn base64_encode_one_byte() {
11549 assert_eq!(base64_encode(b"M"), "TQ==");
11550 }
11551
11552 #[test]
11553 fn base64_encode_two_bytes() {
11554 assert_eq!(base64_encode(b"Ma"), "TWE=");
11555 }
11556
11557 #[test]
11558 fn base64_encode_three_bytes_no_padding() {
11559 assert_eq!(base64_encode(b"Man"), "TWFu");
11560 }
11561
11562 #[test]
11563 fn base64_encode_hello() {
11564 assert_eq!(base64_encode(b"Hello"), "SGVsbG8=");
11565 }
11566
11567 #[test]
11568 fn base64_encode_roundtrip_length_multiple_of_3() {
11569 let data = b"abcdef";
11570 let encoded = base64_encode(data);
11571 assert_eq!(encoded.len(), 8);
11572 assert!(!encoded.contains('='));
11573 }
11574
11575 #[test]
11576 fn base64_encode_all_zeros() {
11577 assert_eq!(base64_encode(&[0u8, 0, 0]), "AAAA");
11578 }
11579
11580 #[test]
11581 fn base64_encode_binary_data() {
11582 let data: Vec<u8> = (0u8..=255).collect();
11583 let encoded = base64_encode(&data);
11584 assert!(!encoded.is_empty());
11585 assert!(
11586 encoded
11587 .chars()
11588 .all(|c| c.is_alphanumeric() || c == '+' || c == '/' || c == '=')
11589 );
11590 }
11591
11592 #[test]
11595 fn json_escape_no_special_chars() {
11596 assert_eq!(json_escape("hello world"), "hello world");
11597 }
11598
11599 #[test]
11600 fn json_escape_backslash() {
11601 assert_eq!(json_escape(r"path\to\file"), r"path\\to\\file");
11602 }
11603
11604 #[test]
11605 fn json_escape_double_quote() {
11606 assert_eq!(json_escape(r#"say "hi""#), r#"say \"hi\""#);
11607 }
11608
11609 #[test]
11610 fn json_escape_both_special_chars() {
11611 assert_eq!(json_escape(r#"a\"b"#), r#"a\\\"b"#);
11612 }
11613
11614 #[test]
11615 fn json_escape_empty_string() {
11616 assert_eq!(json_escape(""), "");
11617 }
11618
11619 #[test]
11620 fn json_escape_only_backslashes() {
11621 assert_eq!(json_escape(r"\\"), r"\\\\");
11622 }
11623
11624 #[test]
11627 fn coverage_pct_str_zero_found_returns_empty() {
11628 assert_eq!(coverage_pct_str(0, 0), "");
11629 }
11630
11631 #[test]
11632 fn coverage_pct_str_full_coverage() {
11633 assert_eq!(coverage_pct_str(100, 100), "100.0");
11634 }
11635
11636 #[test]
11637 fn coverage_pct_str_half_coverage() {
11638 assert_eq!(coverage_pct_str(50, 100), "50.0");
11639 }
11640
11641 #[test]
11642 fn coverage_pct_str_one_decimal_precision() {
11643 let s = coverage_pct_str(7, 10);
11644 assert_eq!(s, "70.0");
11645 }
11646
11647 #[test]
11648 fn coverage_pct_str_zero_hit_but_found() {
11649 assert_eq!(coverage_pct_str(0, 10), "0.0");
11650 }
11651
11652 #[test]
11653 fn coverage_pct_str_non_round_percentage() {
11654 let s = coverage_pct_str(1, 3);
11655 assert!(!s.is_empty());
11656 assert!(s.contains('.'), "result must have decimal point");
11657 }
11658
11659 #[test]
11662 fn coverage_class_zero_found_is_muted() {
11663 assert_eq!(coverage_class(0, 0), "muted");
11664 }
11665
11666 #[test]
11667 fn coverage_class_100_pct_is_good() {
11668 assert_eq!(coverage_class(100, 100), "good");
11669 }
11670
11671 #[test]
11672 fn coverage_class_80_pct_is_good() {
11673 assert_eq!(coverage_class(80, 100), "good");
11674 }
11675
11676 #[test]
11677 fn coverage_class_79_pct_is_warn() {
11678 assert_eq!(coverage_class(79, 100), "warn");
11679 }
11680
11681 #[test]
11682 fn coverage_class_60_pct_is_warn() {
11683 assert_eq!(coverage_class(60, 100), "warn");
11684 }
11685
11686 #[test]
11687 fn coverage_class_59_pct_is_danger() {
11688 assert_eq!(coverage_class(59, 100), "danger");
11689 }
11690
11691 #[test]
11692 fn coverage_class_zero_hit_is_danger() {
11693 assert_eq!(coverage_class(0, 100), "danger");
11694 }
11695
11696 #[test]
11699 fn format_test_density_zero_code_returns_zero() {
11700 assert_eq!(format_test_density(0, 5), "0.0");
11701 }
11702
11703 #[test]
11704 fn format_test_density_zero_tests_returns_zero() {
11705 assert_eq!(format_test_density(100, 0), "0.0");
11706 }
11707
11708 #[test]
11709 fn format_test_density_both_zero() {
11710 assert_eq!(format_test_density(0, 0), "0.0");
11711 }
11712
11713 #[test]
11714 fn format_test_density_1_test_per_1000_lines() {
11715 assert_eq!(format_test_density(1000, 1), "1.0");
11716 }
11717
11718 #[test]
11719 fn format_test_density_10_tests_per_100_lines() {
11720 assert_eq!(format_test_density(100, 10), "100.0");
11721 }
11722
11723 #[test]
11724 fn format_test_density_fractional() {
11725 let s = format_test_density(1000, 3);
11726 assert!(!s.is_empty());
11727 assert!(s.contains('.'));
11728 }
11729
11730 #[test]
11733 fn html_esc_no_special_chars() {
11734 assert_eq!(html_esc("hello"), "hello");
11735 }
11736
11737 #[test]
11738 fn html_esc_ampersand() {
11739 assert_eq!(html_esc("a&b"), "a&b");
11740 }
11741
11742 #[test]
11743 fn html_esc_less_than() {
11744 assert_eq!(html_esc("a<b"), "a<b");
11745 }
11746
11747 #[test]
11748 fn html_esc_greater_than() {
11749 assert_eq!(html_esc("a>b"), "a>b");
11750 }
11751
11752 #[test]
11753 fn html_esc_double_quote() {
11754 assert_eq!(html_esc(r#"a"b"#), "a"b");
11755 }
11756
11757 #[test]
11758 fn html_esc_all_special_chars() {
11759 assert_eq!(
11760 html_esc(r#"<a href="x&y">z</a>"#),
11761 "<a href="x&y">z</a>"
11762 );
11763 }
11764
11765 #[test]
11766 fn html_esc_empty_string() {
11767 assert_eq!(html_esc(""), "");
11768 }
11769
11770 #[test]
11773 fn png_data_uri_has_correct_prefix() {
11774 let uri = png_data_uri(b"\x89PNG\r\n\x1a\n");
11775 assert!(uri.starts_with("data:image/png;base64,"));
11776 }
11777
11778 #[test]
11779 fn png_data_uri_non_empty_for_non_empty_input() {
11780 let uri = png_data_uri(b"fake-png-bytes");
11781 assert!(uri.len() > "data:image/png;base64,".len());
11782 }
11783
11784 #[test]
11787 fn load_custom_logo_nonexistent_file_returns_none() {
11788 let result = load_custom_logo(std::path::Path::new("/nonexistent/__sloc_logo__.png"));
11789 assert!(result.is_none());
11790 }
11791
11792 #[test]
11793 fn load_custom_logo_png_file_returns_data_uri() {
11794 let dir = tempdir().unwrap();
11795 let path = dir.path().join("logo.png");
11796 std::fs::write(&path, b"\x89PNG\r\n\x1a\nfake-png-data").unwrap();
11797 let result = load_custom_logo(&path);
11798 assert!(result.is_some());
11799 let uri = result.unwrap();
11800 assert!(uri.starts_with("data:image/png;base64,"));
11801 }
11802
11803 #[test]
11804 fn load_custom_logo_svg_file_uses_svg_mime() {
11805 let dir = tempdir().unwrap();
11806 let path = dir.path().join("logo.svg");
11807 std::fs::write(&path, b"<svg></svg>").unwrap();
11808 let result = load_custom_logo(&path);
11809 assert!(result.is_some());
11810 let uri = result.unwrap();
11811 assert!(uri.starts_with("data:image/svg+xml;base64,"));
11812 }
11813
11814 #[test]
11815 fn load_custom_logo_unknown_extension_treated_as_png() {
11816 let dir = tempdir().unwrap();
11817 let path = dir.path().join("logo.bin");
11818 std::fs::write(&path, b"some-bytes").unwrap();
11819 let result = load_custom_logo(&path);
11820 assert!(result.is_some());
11821 let uri = result.unwrap();
11822 assert!(uri.starts_with("data:image/png;base64,"));
11823 }
11824}
11825
11826#[cfg(test)]
11827mod coverage_boost_report_tests {
11828 use super::*;
11829 use std::path::Path;
11830
11831 #[test]
11834 fn derive_commit_url_github_uses_commit_segment() {
11835 let url = derive_commit_url(
11836 "https://github.com/org/repo.git",
11837 "abc1234abc1234abc1234abc1234abc1234abc1234",
11838 );
11839 assert_eq!(
11840 url.as_deref(),
11841 Some("https://github.com/org/repo/commit/abc1234abc1234abc1234abc1234abc1234abc1234")
11842 );
11843 }
11844
11845 #[test]
11846 fn derive_commit_url_bitbucket_uses_commits_plural() {
11847 let url = derive_commit_url("https://bitbucket.org/org/repo.git", "deadbeef");
11848 assert_eq!(
11849 url.as_deref(),
11850 Some("https://bitbucket.org/org/repo/commits/deadbeef")
11851 );
11852 }
11853
11854 #[test]
11855 fn derive_commit_url_gitlab_uses_dash_commit() {
11856 let url = derive_commit_url("https://gitlab.example.com/org/repo.git", "cafe0000");
11857 assert_eq!(
11858 url.as_deref(),
11859 Some("https://gitlab.example.com/org/repo/-/commit/cafe0000")
11860 );
11861 }
11862
11863 #[test]
11864 fn derive_branch_url_github_uses_tree() {
11865 let url = derive_branch_url("https://github.com/org/repo.git", "main");
11866 assert_eq!(
11867 url.as_deref(),
11868 Some("https://github.com/org/repo/tree/main")
11869 );
11870 }
11871
11872 #[test]
11873 fn derive_branch_url_bitbucket_uses_branch_segment() {
11874 let url = derive_branch_url("https://bitbucket.org/org/repo.git", "develop");
11875 assert_eq!(
11876 url.as_deref(),
11877 Some("https://bitbucket.org/org/repo/branch/develop")
11878 );
11879 }
11880
11881 #[test]
11882 fn derive_branch_url_gitlab_uses_dash_tree() {
11883 let url = derive_branch_url("https://gitlab.mycompany.com/org/repo.git", "feature");
11884 assert_eq!(
11885 url.as_deref(),
11886 Some("https://gitlab.mycompany.com/org/repo/-/tree/feature")
11887 );
11888 }
11889
11890 #[test]
11891 fn derive_commit_url_invalid_url_returns_none() {
11892 let url = derive_commit_url("not-a-url", "abc123");
11893 assert!(url.is_none());
11894 }
11895
11896 #[test]
11897 fn normalize_remote_url_variants() {
11898 assert_eq!(
11899 normalize_remote_url("git@github.com:org/repo.git").as_deref(),
11900 Some("https://github.com/org/repo")
11901 );
11902 assert_eq!(
11903 normalize_remote_url("https://gitlab.com/a/b.git").as_deref(),
11904 Some("https://gitlab.com/a/b")
11905 );
11906 assert_eq!(
11907 normalize_remote_url("http://host/x").as_deref(),
11908 Some("http://host/x")
11909 );
11910 assert_eq!(normalize_remote_url("not a url"), None);
11911 }
11912
11913 #[test]
11914 fn classify_and_bucket_helpers() {
11915 assert_eq!(
11916 classify_unsupported_path("README.md"),
11917 "Documentation / text"
11918 );
11919 assert_eq!(
11920 classify_unsupported_path("pkg.json"),
11921 "JSON manifests and config"
11922 );
11923 assert_eq!(
11924 classify_unsupported_path("Cargo.toml"),
11925 "Project metadata and packaging"
11926 );
11927 assert_eq!(classify_unsupported_path("page.html"), "HTML templates");
11928 assert_eq!(classify_unsupported_path("notes.txt"), "Plain text assets");
11929 assert_eq!(
11930 classify_unsupported_path("data.xyz"),
11931 "Other unsupported text formats"
11932 );
11933 assert_eq!(
11934 classify_unsupported_path("Makefile_noext"),
11935 "Extensionless or custom text files"
11936 );
11937 for label in [
11939 "Documentation / text",
11940 "JSON manifests and config",
11941 "Project metadata and packaging",
11942 "HTML templates",
11943 "Plain text assets",
11944 "Extensionless or custom text files",
11945 "Unknown bucket",
11946 ] {
11947 assert!(!bucket_description(label).is_empty());
11948 assert!(!bucket_recommendation(label).is_empty());
11949 }
11950 }
11951
11952 #[test]
11953 fn summarize_warnings_groups_categories() {
11954 let warnings = vec![
11955 "file 'a.md': unsupported or undetected language".to_string(),
11956 "file 'b.bin': binary file skipped by default".to_string(),
11957 "file 'c.min.js': minified file skipped by policy".to_string(),
11958 "file 'big.txt': file exceeded max_file_size_bytes".to_string(),
11959 ];
11960 let rows = summarize_warnings(&warnings);
11961 assert!(!rows.is_empty(), "warnings should summarize into buckets");
11962 }
11963
11964 #[test]
11965 fn pdf_number_and_string_formatters() {
11966 assert_eq!(pdf_fmt_full(0), "0");
11967 assert!(pdf_fmt_full(1_234_567).contains('1'));
11968 let s = pdf_safe_str("héllo\tworld\u{1F600}");
11970 assert!(!s.is_empty());
11971 }
11972
11973 #[test]
11974 fn file_url_produces_uri() {
11975 let url = file_url(Path::new("/tmp/report.html"));
11976 assert!(url.starts_with("file://") || url.contains("report.html"));
11977 }
11978
11979 #[test]
11980 fn browser_discovery_is_callable_without_panicking() {
11981 unsafe { std::env::remove_var("SLOC_BROWSER") };
11985 unsafe { std::env::remove_var("BROWSER") };
11987 let _ = discover_browser();
11988 let _ = discover_browser_from_env();
11989 #[cfg(windows)]
11990 let _ = windows_browser_candidates();
11991 #[cfg(not(windows))]
11992 let _ = linux_browser_candidates();
11993 unsafe { std::env::set_var("SLOC_BROWSER", "/no/such/browser/path") };
11996 let _ = discover_browser_from_env();
11997 let p = normalize_browser_env_path("\"/quoted/path/chrome\"");
11998 assert!(p.to_string_lossy().contains("chrome"));
11999 unsafe { std::env::remove_var("SLOC_BROWSER") };
12001 }
12002
12003 #[test]
12004 fn which_in_path_returns_none_for_missing() {
12005 assert!(which_in_path("definitely-not-a-real-exe-xyz123").is_none());
12006 }
12007
12008 #[test]
12009 fn write_pdf_from_html_without_browser_errors_gracefully() {
12010 unsafe { std::env::remove_var("SLOC_BROWSER") };
12012 unsafe { std::env::remove_var("BROWSER") };
12014 let dir = std::env::temp_dir().join("sloc_report_pdf_test");
12015 let _ = std::fs::create_dir_all(&dir);
12016 let html = dir.join("in.html");
12017 std::fs::write(&html, "<html><body>hi</body></html>").unwrap();
12018 let out = dir.join("out.pdf");
12019 let res = write_pdf_from_html(&html, &out);
12021 let _ = res;
12023 let _ = std::fs::remove_dir_all(&dir);
12024 }
12025
12026 #[test]
12029 fn helvetica_advance_uppercase_a_differs_by_weight() {
12030 assert_eq!(helvetica_advance('A', true), 722);
12031 assert_eq!(helvetica_advance('A', false), 667);
12032 }
12033
12034 #[test]
12035 fn helvetica_advance_uppercase_w_same_both_weights() {
12036 assert_eq!(helvetica_advance('W', true), 944);
12037 assert_eq!(helvetica_advance('W', false), 944);
12038 }
12039
12040 #[test]
12041 fn helvetica_advance_lowercase_i_differs_by_weight() {
12042 assert_eq!(helvetica_advance('i', true), 278);
12043 assert_eq!(helvetica_advance('i', false), 222);
12044 }
12045
12046 #[test]
12047 fn helvetica_advance_digits_are_556_both_weights() {
12048 for d in '0'..='9' {
12049 assert_eq!(helvetica_advance(d, true), 556, "bold digit {d}");
12050 assert_eq!(helvetica_advance(d, false), 556, "regular digit {d}");
12051 }
12052 }
12053
12054 #[test]
12055 fn helvetica_advance_middle_dot_is_278() {
12056 assert_eq!(helvetica_advance('\u{00B7}', true), 278);
12057 assert_eq!(helvetica_advance('\u{00B7}', false), 278);
12058 }
12059
12060 #[test]
12061 fn helvetica_advance_unknown_char_returns_nonzero_fallback() {
12062 let bold_fb = helvetica_advance('\u{1F600}', true);
12063 let reg_fb = helvetica_advance('\u{1F600}', false);
12064 assert_eq!(bold_fb, 556);
12065 assert_eq!(reg_fb, 500);
12066 }
12067
12068 #[test]
12071 fn helvetica_width_mm_empty_is_zero() {
12072 assert!(helvetica_width_mm("", 10.0, false).abs() < f32::EPSILON);
12073 assert!(helvetica_width_mm("", 10.0, true).abs() < f32::EPSILON);
12074 }
12075
12076 #[test]
12077 fn helvetica_width_mm_scales_linearly_with_pt_size() {
12078 let w6 = helvetica_width_mm("Hello", 6.0, false);
12079 let w12 = helvetica_width_mm("Hello", 12.0, false);
12080 assert!(
12081 2.0_f32.mul_add(-w6, w12).abs() < 1e-4,
12082 "width must be proportional to pt size"
12083 );
12084 }
12085
12086 #[test]
12087 fn helvetica_width_mm_bold_a_wider_than_regular_a() {
12088 let bold = helvetica_width_mm("A", 10.0, true);
12089 let reg = helvetica_width_mm("A", 10.0, false);
12090 assert!(
12091 bold > reg,
12092 "bold 'A' (722) must be wider than regular 'A' (667)"
12093 );
12094 }
12095
12096 #[test]
12097 fn helvetica_width_mm_single_char_matches_manual_calculation() {
12098 let expected = 667.0_f32 * 10.0 * (25.4 / 72.0) / 1000.0;
12100 let got = helvetica_width_mm("A", 10.0, false);
12101 assert!((got - expected).abs() < 1e-4);
12102 }
12103}