codeclone 1.2.0__py3-none-any.whl → 1.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
codeclone/html_report.py CHANGED
@@ -9,32 +9,35 @@ Licensed under the MIT License.
9
9
  from __future__ import annotations
10
10
 
11
11
  import html
12
+ import importlib
12
13
  import itertools
14
+ from collections.abc import Iterable
13
15
  from dataclasses import dataclass
14
- from pathlib import Path
15
- from string import Template
16
- from typing import Any, Optional, Iterable
16
+ from functools import lru_cache
17
+ from typing import Any, NamedTuple, cast
17
18
 
18
19
  from codeclone import __version__
20
+ from codeclone.errors import FileProcessingError
19
21
 
22
+ from .templates import FONT_CSS_URL, REPORT_TEMPLATE
20
23
 
21
- # ============================
24
+ # ============================
22
25
  # Pairwise
23
- # ============================
26
+ # ============================
24
27
 
25
28
 
26
29
  def pairwise(iterable: Iterable[Any]) -> Iterable[tuple[Any, Any]]:
27
30
  a, b = itertools.tee(iterable)
28
31
  next(b, None)
29
- return zip(a, b)
32
+ return zip(a, b, strict=False)
30
33
 
31
34
 
32
- # ============================
35
+ # ============================
33
36
  # Code snippet infrastructure
34
- # ============================
37
+ # ============================
35
38
 
36
39
 
37
- @dataclass
40
+ @dataclass(slots=True)
38
41
  class _Snippet:
39
42
  filepath: str
40
43
  start_line: int
@@ -43,28 +46,79 @@ class _Snippet:
43
46
 
44
47
 
45
48
  class _FileCache:
46
- def __init__(self) -> None:
47
- self._lines: dict[str, list[str]] = {}
49
+ __slots__ = ("_get_lines_impl", "maxsize")
50
+
51
+ def __init__(self, maxsize: int = 128) -> None:
52
+ self.maxsize = maxsize
53
+ # Create a bound method with lru_cache
54
+ # We need to cache on the method to have instance-level caching if we wanted
55
+ # different caches per instance. But lru_cache on method actually caches
56
+ # on the function object (class level) if not careful,
57
+ # or we use a wrapper.
58
+ # However, for this script, we usually have one reporter.
59
+ # To be safe and cleaner, we can use a method that delegates to a cached
60
+ # function, OR just use lru_cache on a method (which requires 'self' to be
61
+ # hashable, which it is by default id).
62
+ # But 'self' changes if we create new instances.
63
+ # Let's use the audit's pattern: cache the implementation.
64
+
65
+ self._get_lines_impl = lru_cache(maxsize=maxsize)(self._read_file_range)
66
+
67
+ def _read_file_range(
68
+ self, filepath: str, start_line: int, end_line: int
69
+ ) -> tuple[str, ...]:
70
+ if start_line < 1:
71
+ start_line = 1
72
+ if end_line < start_line:
73
+ return ()
74
+
75
+ try:
76
+
77
+ def _read_with_errors(errors: str) -> tuple[str, ...]:
78
+ lines: list[str] = []
79
+ with open(filepath, encoding="utf-8", errors=errors) as f:
80
+ for lineno, line in enumerate(f, start=1):
81
+ if lineno < start_line:
82
+ continue
83
+ if lineno > end_line:
84
+ break
85
+ lines.append(line.rstrip("\n"))
86
+ return tuple(lines)
48
87
 
49
- def get_lines(self, filepath: str) -> list[str]:
50
- if filepath not in self._lines:
51
88
  try:
52
- text = Path(filepath).read_text("utf-8")
89
+ return _read_with_errors("strict")
53
90
  except UnicodeDecodeError:
54
- text = Path(filepath).read_text("utf-8", errors="replace")
55
- self._lines[filepath] = text.splitlines()
56
- return self._lines[filepath]
91
+ return _read_with_errors("replace")
92
+ except OSError as e:
93
+ raise FileProcessingError(f"Cannot read {filepath}: {e}") from e
94
+
95
+ def get_lines_range(
96
+ self, filepath: str, start_line: int, end_line: int
97
+ ) -> tuple[str, ...]:
98
+ return self._get_lines_impl(filepath, start_line, end_line)
57
99
 
100
+ class _CacheInfo(NamedTuple):
101
+ hits: int
102
+ misses: int
103
+ maxsize: int | None
104
+ currsize: int
58
105
 
59
- def _try_pygments(code: str) -> Optional[str]:
106
+ def cache_info(self) -> _CacheInfo:
107
+ return cast(_FileCache._CacheInfo, self._get_lines_impl.cache_info())
108
+
109
+
110
+ def _try_pygments(code: str) -> str | None:
60
111
  try:
61
- from pygments import highlight
62
- from pygments.formatters import HtmlFormatter
63
- from pygments.lexers import PythonLexer
112
+ pygments = importlib.import_module("pygments")
113
+ formatters = importlib.import_module("pygments.formatters")
114
+ lexers = importlib.import_module("pygments.lexers")
64
115
  except Exception:
65
116
  return None
66
117
 
67
- result = highlight(code, PythonLexer(), HtmlFormatter(nowrap=True))
118
+ highlight = pygments.highlight
119
+ formatter_cls = formatters.HtmlFormatter
120
+ lexer_cls = lexers.PythonLexer
121
+ result = highlight(code, lexer_cls(), formatter_cls(nowrap=True))
68
122
  return result if isinstance(result, str) else None
69
123
 
70
124
 
@@ -74,21 +128,23 @@ def _pygments_css(style_name: str) -> str:
74
128
  If Pygments is not available or style missing, returns "".
75
129
  """
76
130
  try:
77
- from pygments.formatters import HtmlFormatter
131
+ formatters = importlib.import_module("pygments.formatters")
78
132
  except Exception:
79
133
  return ""
80
134
 
81
135
  try:
82
- fmt = HtmlFormatter(style=style_name)
136
+ formatter_cls = formatters.HtmlFormatter
137
+ fmt = formatter_cls(style=style_name)
83
138
  except Exception:
84
139
  try:
85
- fmt = HtmlFormatter()
140
+ fmt = formatter_cls()
86
141
  except Exception:
87
142
  return ""
88
143
 
89
144
  try:
90
145
  # `.codebox` scope: pygments will emit selectors like `.codebox .k { ... }`
91
- return fmt.get_style_defs(".codebox")
146
+ css = fmt.get_style_defs(".codebox")
147
+ return css if isinstance(css, str) else ""
92
148
  except Exception:
93
149
  return ""
94
150
 
@@ -104,11 +160,7 @@ def _prefix_css(css: str, prefix: str) -> str:
104
160
  if not stripped:
105
161
  out_lines.append(line)
106
162
  continue
107
- if (
108
- stripped.startswith("/*")
109
- or stripped.startswith("*")
110
- or stripped.startswith("*/")
111
- ):
163
+ if stripped.startswith(("/*", "*", "*/")):
112
164
  out_lines.append(line)
113
165
  continue
114
166
  # Selector lines usually end with `{
@@ -134,17 +186,16 @@ def _render_code_block(
134
186
  context: int,
135
187
  max_lines: int,
136
188
  ) -> _Snippet:
137
- lines = file_cache.get_lines(filepath)
138
-
139
189
  s = max(1, start_line - context)
140
- e = min(len(lines), end_line + context)
190
+ e = end_line + context
141
191
 
142
192
  if e - s + 1 > max_lines:
143
193
  e = s + max_lines - 1
144
194
 
195
+ lines = file_cache.get_lines_range(filepath, s, e)
196
+
145
197
  numbered: list[tuple[bool, str]] = []
146
- for lineno in range(s, e + 1):
147
- line = lines[lineno - 1]
198
+ for lineno, line in enumerate(lines, start=s):
148
199
  hit = start_line <= lineno <= end_line
149
200
  numbered.append((hit, f"{lineno:>5} | {line.rstrip()}"))
150
201
 
@@ -164,13 +215,13 @@ def _render_code_block(
164
215
  filepath=filepath,
165
216
  start_line=start_line,
166
217
  end_line=end_line,
167
- code_html=f'<pre class="codebox"><code>{body}</code></pre>',
218
+ code_html=f'<div class="codebox"><pre><code>{body}</code></pre></div>',
168
219
  )
169
220
 
170
221
 
171
- # ============================
222
+ # ============================
172
223
  # HTML report builder
173
- # ============================
224
+ # ============================
174
225
 
175
226
 
176
227
  def _escape(v: Any) -> str:
@@ -184,575 +235,6 @@ def _group_sort_key(items: list[dict[str, Any]]) -> tuple[int, int]:
184
235
  )
185
236
 
186
237
 
187
- REPORT_TEMPLATE = Template(r"""
188
- <!doctype html>
189
- <html lang="en" data-theme="dark">
190
- <head>
191
- <meta charset="utf-8">
192
- <meta name="viewport" content="width=device-width, initial-scale=1">
193
- <title>${title}</title>
194
-
195
- <style>
196
- /* ============================
197
- CodeClone UI/UX
198
- ============================ */
199
-
200
- :root {
201
- --bg: #0d1117;
202
- --panel: #161b22;
203
- --panel2: #21262d;
204
- --text: #c9d1d9;
205
- --muted: #8b949e;
206
- --border: #30363d;
207
- --border2: #6e7681;
208
- --accent: #58a6ff;
209
- --accent2: rgba(56, 139, 253, 0.15);
210
- --good: #3fb950;
211
- --shadow: 0 8px 24px rgba(0,0,0,0.5);
212
- --shadow2: 0 4px 12px rgba(0,0,0,0.2);
213
- --radius: 6px;
214
- --radius2: 8px;
215
- --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
216
- --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
217
- }
218
-
219
- html[data-theme="light"] {
220
- --bg: #ffffff;
221
- --panel: #f6f8fa;
222
- --panel2: #eaeef2;
223
- --text: #24292f;
224
- --muted: #57606a;
225
- --border: #d0d7de;
226
- --border2: #afb8c1;
227
- --accent: #0969da;
228
- --accent2: rgba(84, 174, 255, 0.2);
229
- --good: #1a7f37;
230
- --shadow: 0 8px 24px rgba(140,149,159,0.2);
231
- --shadow2: 0 4px 12px rgba(140,149,159,0.1);
232
- }
233
-
234
- * { box-sizing: border-box; }
235
-
236
- body {
237
- margin: 0;
238
- background: var(--bg);
239
- color: var(--text);
240
- font-family: var(--font);
241
- line-height: 1.5;
242
- }
243
-
244
- .container {
245
- max-width: 1400px;
246
- margin: 0 auto;
247
- padding: 20px 20px 80px;
248
- }
249
-
250
- .topbar {
251
- position: sticky;
252
- top: 0;
253
- z-index: 100;
254
- backdrop-filter: blur(8px);
255
- -webkit-backdrop-filter: blur(8px);
256
- background: var(--bg);
257
- border-bottom: 1px solid var(--border);
258
- opacity: 0.98;
259
- }
260
-
261
- .topbar-inner {
262
- display: flex;
263
- align-items: center;
264
- justify-content: space-between;
265
- height: 60px;
266
- padding: 0 20px;
267
- max-width: 1400px;
268
- margin: 0 auto;
269
- }
270
-
271
- .brand {
272
- display: flex;
273
- align-items: center;
274
- gap: 12px;
275
- }
276
-
277
- .brand h1 {
278
- margin: 0;
279
- font-size: 18px;
280
- font-weight: 600;
281
- }
282
-
283
- .brand .sub {
284
- color: var(--muted);
285
- font-size: 13px;
286
- background: var(--panel2);
287
- padding: 2px 8px;
288
- border-radius: 99px;
289
- font-weight: 500;
290
- }
291
-
292
- .btn {
293
- display: inline-flex;
294
- align-items: center;
295
- justify-content: center;
296
- gap: 6px;
297
- padding: 6px 12px;
298
- border-radius: 6px;
299
- border: 1px solid var(--border);
300
- background: var(--panel);
301
- color: var(--text);
302
- cursor: pointer;
303
- font-size: 13px;
304
- font-weight: 500;
305
- transition: 0.2s;
306
- height: 32px;
307
- }
308
-
309
- .btn:hover {
310
- border-color: var(--border2);
311
- background: var(--panel2);
312
- }
313
-
314
- .btn.ghost {
315
- background: transparent;
316
- border-color: transparent;
317
- padding: 4px;
318
- width: 28px;
319
- height: 28px;
320
- }
321
-
322
- .select {
323
- padding: 0 24px 0 8px;
324
- height: 32px;
325
- border-radius: 6px;
326
- border: 1px solid var(--border);
327
- background: var(--panel);
328
- color: var(--text);
329
- font-size: 13px;
330
- }
331
-
332
- .section {
333
- margin-top: 32px;
334
- }
335
-
336
- .section-head {
337
- display: flex;
338
- flex-direction: column;
339
- gap: 16px;
340
- margin-bottom: 16px;
341
- }
342
-
343
- .section-head h2 {
344
- margin: 0;
345
- font-size: 20px;
346
- font-weight: 600;
347
- display: flex;
348
- align-items: center;
349
- gap: 12px;
350
- }
351
-
352
- .section-toolbar {
353
- display: flex;
354
- justify-content: space-between;
355
- align-items: center;
356
- gap: 16px;
357
- flex-wrap: wrap;
358
- padding: 12px;
359
- background: var(--panel);
360
- border: 1px solid var(--border);
361
- border-radius: 6px;
362
- }
363
-
364
- .search-wrap {
365
- display: flex;
366
- align-items: center;
367
- gap: 8px;
368
- padding: 4px 8px;
369
- border-radius: 6px;
370
- border: 1px solid var(--border);
371
- background: var(--bg);
372
- min-width: 300px;
373
- height: 32px;
374
- }
375
- .search-wrap:focus-within {
376
- border-color: var(--accent);
377
- box-shadow: 0 0 0 2px var(--accent2);
378
- }
379
-
380
- .search-ico {
381
- color: var(--muted);
382
- display: flex;
383
- }
384
-
385
- .search {
386
- width: 100%;
387
- border: none;
388
- outline: none;
389
- background: transparent;
390
- color: var(--text);
391
- font-size: 13px;
392
- }
393
-
394
- .segmented {
395
- display: inline-flex;
396
- background: var(--panel2);
397
- padding: 2px;
398
- border-radius: 6px;
399
- }
400
-
401
- .btn.seg {
402
- border: none;
403
- background: transparent;
404
- height: 28px;
405
- font-size: 12px;
406
- }
407
- .btn.seg:hover {
408
- background: var(--bg);
409
- box-shadow: 0 1px 2px rgba(0,0,0,0.1);
410
- }
411
-
412
- .pager {
413
- display: inline-flex;
414
- align-items: center;
415
- gap: 8px;
416
- font-size: 13px;
417
- }
418
-
419
- .pill {
420
- padding: 2px 10px;
421
- border-radius: 99px;
422
- background: var(--accent2);
423
- border: 1px solid rgba(56, 139, 253, 0.3);
424
- font-size: 12px;
425
- font-weight: 600;
426
- color: var(--accent);
427
- }
428
- .pill.small {
429
- padding: 1px 8px;
430
- font-size: 11px;
431
- }
432
- .pill-func {
433
- color: var(--accent);
434
- background: var(--accent2);
435
- }
436
- .pill-block {
437
- color: var(--good);
438
- background: rgba(63, 185, 80, 0.15);
439
- border-color: rgba(63, 185, 80, 0.3);
440
- }
441
-
442
- .group {
443
- margin-bottom: 16px;
444
- border: 1px solid var(--border);
445
- border-radius: 6px;
446
- background: var(--bg);
447
- box-shadow: var(--shadow2);
448
- }
449
-
450
- .group-head {
451
- display: flex;
452
- justify-content: space-between;
453
- align-items: center;
454
- padding: 12px 16px;
455
- background: var(--panel);
456
- border-bottom: 1px solid var(--border);
457
- cursor: pointer;
458
- }
459
-
460
- .group-left {
461
- display: flex;
462
- align-items: center;
463
- gap: 12px;
464
- }
465
-
466
- .group-title {
467
- font-weight: 600;
468
- font-size: 14px;
469
- }
470
-
471
- .gkey {
472
- font-family: var(--mono);
473
- font-size: 12px;
474
- color: var(--muted);
475
- background: var(--panel2);
476
- padding: 2px 6px;
477
- border-radius: 4px;
478
- }
479
-
480
- .chev {
481
- display: flex;
482
- align-items: center;
483
- justify-content: center;
484
- width: 24px;
485
- height: 24px;
486
- border-radius: 4px;
487
- border: 1px solid var(--border);
488
- background: var(--bg);
489
- color: var(--muted);
490
- padding: 0;
491
- }
492
- .chev:hover {
493
- color: var(--text);
494
- border-color: var(--border2);
495
- }
496
-
497
- .items {
498
- padding: 16px;
499
- }
500
-
501
- .item-pair {
502
- display: grid;
503
- grid-template-columns: 1fr 1fr;
504
- gap: 16px;
505
- margin-bottom: 16px;
506
- }
507
- .item-pair:last-child {
508
- margin-bottom: 0;
509
- }
510
-
511
- @media (max-width: 1000px) {
512
- .item-pair {
513
- grid-template-columns: 1fr;
514
- }
515
- }
516
-
517
- .item {
518
- border: 1px solid var(--border);
519
- border-radius: 6px;
520
- overflow: hidden;
521
- display: flex;
522
- flex-direction: column;
523
- }
524
-
525
- .item-head {
526
- padding: 8px 12px;
527
- background: var(--panel);
528
- border-bottom: 1px solid var(--border);
529
- font-size: 13px;
530
- font-weight: 600;
531
- color: var(--accent);
532
- }
533
-
534
- .item-file {
535
- padding: 6px 12px;
536
- background: var(--panel2);
537
- border-bottom: 1px solid var(--border);
538
- font-family: var(--mono);
539
- font-size: 11px;
540
- color: var(--muted);
541
- }
542
-
543
- .codebox {
544
- margin: 0;
545
- padding: 12px;
546
- font-family: var(--mono);
547
- font-size: 12px;
548
- line-height: 1.5;
549
- overflow: auto;
550
- background: var(--bg);
551
- flex: 1;
552
- }
553
-
554
- .empty {
555
- padding: 60px 0;
556
- display: flex;
557
- justify-content: center;
558
- }
559
- .empty-card {
560
- text-align: center;
561
- padding: 40px;
562
- background: var(--panel);
563
- border: 1px solid var(--border);
564
- border-radius: 12px;
565
- max-width: 500px;
566
- }
567
- .empty-icon {
568
- color: var(--good);
569
- margin-bottom: 16px;
570
- display: flex;
571
- justify-content: center;
572
- }
573
-
574
- .footer {
575
- margin-top: 60px;
576
- text-align: center;
577
- color: var(--muted);
578
- font-size: 12px;
579
- border-top: 1px solid var(--border);
580
- padding-top: 24px;
581
- }
582
-
583
- ${pyg_dark}
584
- ${pyg_light}
585
- </style>
586
- </head>
587
-
588
- <body>
589
- <div class="topbar">
590
- <div class="topbar-inner">
591
- <div class="brand">
592
- <h1>${title}</h1>
593
- <div class="sub">v${version}</div>
594
- </div>
595
- <div class="top-actions">
596
- <button class="btn" type="button" id="theme-toggle" title="Toggle theme">${icon_theme} Theme</button>
597
- </div>
598
- </div>
599
- </div>
600
-
601
- <div class="container">
602
- ${empty_state_html}
603
-
604
- ${func_section}
605
- ${block_section}
606
-
607
- <div class="footer">Generated by CodeClone v${version}</div>
608
- </div>
609
-
610
- <script>
611
- (() => {
612
- const htmlEl = document.documentElement;
613
- const btnTheme = document.getElementById("theme-toggle");
614
-
615
- const stored = localStorage.getItem("codeclone_theme");
616
- if (stored === "light" || stored === "dark") {
617
- htmlEl.setAttribute("data-theme", stored);
618
- }
619
-
620
- btnTheme?.addEventListener("click", () => {
621
- const cur = htmlEl.getAttribute("data-theme") || "dark";
622
- const next = cur === "dark" ? "light" : "dark";
623
- htmlEl.setAttribute("data-theme", next);
624
- localStorage.setItem("codeclone_theme", next);
625
- });
626
-
627
- // Toggle group visibility via header click
628
- document.querySelectorAll(".group-head").forEach((head) => {
629
- head.addEventListener("click", (e) => {
630
- if (e.target.closest("button")) return;
631
- const btn = head.querySelector("[data-toggle-group]");
632
- if (btn) btn.click();
633
- });
634
- });
635
-
636
- document.querySelectorAll("[data-toggle-group]").forEach((btn) => {
637
- btn.addEventListener("click", () => {
638
- const id = btn.getAttribute("data-toggle-group");
639
- const body = document.getElementById("group-body-" + id);
640
- if (!body) return;
641
-
642
- const isHidden = body.style.display === "none";
643
- body.style.display = isHidden ? "" : "none";
644
- btn.style.transform = isHidden ? "rotate(0deg)" : "rotate(-90deg)";
645
- });
646
- });
647
-
648
- function initSection(sectionId) {
649
- const section = document.querySelector(`section[data-section='$${sectionId}']`);
650
- if (!section) return;
651
-
652
- const groups = Array.from(section.querySelectorAll(`.group[data-group='$${sectionId}']`));
653
- const searchInput = document.getElementById(`search-$${sectionId}`);
654
- const btnPrev = section.querySelector(`[data-prev='$${sectionId}']`);
655
- const btnNext = section.querySelector(`[data-next='$${sectionId}']`);
656
- const meta = section.querySelector(`[data-page-meta='$${sectionId}']`);
657
- const selPageSize = section.querySelector(`[data-pagesize='$${sectionId}']`);
658
- const btnClear = section.querySelector(`[data-clear='$${sectionId}']`);
659
- const btnCollapseAll = section.querySelector(`[data-collapse-all='$${sectionId}']`);
660
- const btnExpandAll = section.querySelector(`[data-expand-all='$${sectionId}']`);
661
- const pill = section.querySelector(`[data-count-pill='$${sectionId}']`);
662
-
663
- const state = {
664
- q: "",
665
- page: 1,
666
- pageSize: parseInt(selPageSize?.value || "10", 10),
667
- filtered: groups
668
- };
669
-
670
- function setGroupVisible(el, yes) {
671
- el.style.display = yes ? "" : "none";
672
- }
673
-
674
- function render() {
675
- const total = state.filtered.length;
676
- const pageSize = Math.max(1, state.pageSize);
677
- const pages = Math.max(1, Math.ceil(total / pageSize));
678
- state.page = Math.min(Math.max(1, state.page), pages);
679
-
680
- const start = (state.page - 1) * pageSize;
681
- const end = Math.min(total, start + pageSize);
682
-
683
- groups.forEach(g => setGroupVisible(g, false));
684
- state.filtered.slice(start, end).forEach(g => setGroupVisible(g, true));
685
-
686
- if (meta) meta.textContent = `Page $${state.page} / $${pages} • $${total} groups`;
687
- if (pill) pill.textContent = `$${total} groups`;
688
-
689
- if (btnPrev) btnPrev.disabled = state.page <= 1;
690
- if (btnNext) btnNext.disabled = state.page >= pages;
691
- }
692
-
693
- function applyFilter() {
694
- const q = (state.q || "").trim().toLowerCase();
695
- if (!q) {
696
- state.filtered = groups;
697
- } else {
698
- state.filtered = groups.filter(g => {
699
- const blob = g.getAttribute("data-search") || "";
700
- return blob.indexOf(q) !== -1;
701
- });
702
- }
703
- state.page = 1;
704
- render();
705
- }
706
-
707
- searchInput?.addEventListener("input", (e) => {
708
- state.q = e.target.value || "";
709
- applyFilter();
710
- });
711
-
712
- btnClear?.addEventListener("click", () => {
713
- if (searchInput) searchInput.value = "";
714
- state.q = "";
715
- applyFilter();
716
- });
717
-
718
- selPageSize?.addEventListener("change", () => {
719
- state.pageSize = parseInt(selPageSize.value || "10", 10);
720
- state.page = 1;
721
- render();
722
- });
723
-
724
- btnPrev?.addEventListener("click", () => {
725
- state.page -= 1;
726
- render();
727
- });
728
-
729
- btnNext?.addEventListener("click", () => {
730
- state.page += 1;
731
- render();
732
- });
733
-
734
- btnCollapseAll?.addEventListener("click", () => {
735
- section.querySelectorAll(".items").forEach(b => b.style.display = "none");
736
- section.querySelectorAll("[data-toggle-group]").forEach(c => c.style.transform = "rotate(-90deg)");
737
- });
738
-
739
- btnExpandAll?.addEventListener("click", () => {
740
- section.querySelectorAll(".items").forEach(b => b.style.display = "");
741
- section.querySelectorAll("[data-toggle-group]").forEach(c => c.style.transform = "rotate(0deg)");
742
- });
743
-
744
- render();
745
- }
746
-
747
- initSection("functions");
748
- initSection("blocks");
749
- })();
750
- </script>
751
- </body>
752
- </html>
753
- """)
754
-
755
-
756
238
  def build_html_report(
757
239
  *,
758
240
  func_groups: dict[str, list[dict[str, Any]]],
@@ -780,19 +262,69 @@ def build_html_report(
780
262
  pyg_dark = _prefix_css(pyg_dark_raw, "html[data-theme='dark']")
781
263
  pyg_light = _prefix_css(pyg_light_raw, "html[data-theme='light']")
782
264
 
783
- # ============================
265
+ # ============================
784
266
  # Icons (Inline SVG)
785
- # ============================
786
- ICON_SEARCH = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>'
787
- ICON_X = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>'
788
- ICON_CHEV_DOWN = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>'
789
- # ICON_CHEV_RIGHT = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>'
790
- ICON_THEME = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg>'
791
- ICON_CHECK = '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>'
792
- ICON_PREV = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>'
793
- ICON_NEXT = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>'
794
-
795
- # ----------------------------
267
+ # ============================
268
+ ICON_SEARCH = (
269
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" '
270
+ 'stroke="currentColor" stroke-width="2.5" stroke-linecap="round" '
271
+ 'stroke-linejoin="round">'
272
+ '<circle cx="11" cy="11" r="8"></circle>'
273
+ '<line x1="21" y1="21" x2="16.65" y2="16.65"></line>'
274
+ "</svg>"
275
+ )
276
+ ICON_X = (
277
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" '
278
+ 'stroke="currentColor" stroke-width="2.5" stroke-linecap="round" '
279
+ 'stroke-linejoin="round">'
280
+ '<line x1="18" y1="6" x2="6" y2="18"></line>'
281
+ '<line x1="6" y1="6" x2="18" y2="18"></line>'
282
+ "</svg>"
283
+ )
284
+ ICON_CHEV_DOWN = (
285
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" '
286
+ 'stroke="currentColor" stroke-width="2.5" stroke-linecap="round" '
287
+ 'stroke-linejoin="round">'
288
+ '<polyline points="6 9 12 15 18 9"></polyline>'
289
+ "</svg>"
290
+ )
291
+ # ICON_CHEV_RIGHT = (
292
+ # '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" '
293
+ # 'stroke="currentColor" stroke-width="2.5" stroke-linecap="round" '
294
+ # 'stroke-linejoin="round">'
295
+ # '<polyline points="9 18 15 12 9 6"></polyline>'
296
+ # "</svg>"
297
+ # )
298
+ ICON_THEME = (
299
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" '
300
+ 'stroke="currentColor" stroke-width="2" stroke-linecap="round" '
301
+ 'stroke-linejoin="round">'
302
+ '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>'
303
+ "</svg>"
304
+ )
305
+ ICON_CHECK = (
306
+ '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" '
307
+ 'stroke="currentColor" stroke-width="2" stroke-linecap="round" '
308
+ 'stroke-linejoin="round">'
309
+ '<polyline points="20 6 9 17 4 12"></polyline>'
310
+ "</svg>"
311
+ )
312
+ ICON_PREV = (
313
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" '
314
+ 'stroke="currentColor" stroke-width="2" stroke-linecap="round" '
315
+ 'stroke-linejoin="round">'
316
+ '<polyline points="15 18 9 12 15 6"></polyline>'
317
+ "</svg>"
318
+ )
319
+ ICON_NEXT = (
320
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" '
321
+ 'stroke="currentColor" stroke-width="2" stroke-linecap="round" '
322
+ 'stroke-linejoin="round">'
323
+ '<polyline points="9 18 15 12 9 6"></polyline>'
324
+ "</svg>"
325
+ )
326
+
327
+ # ----------------------------
796
328
  # Section renderer
797
329
  # ----------------------------
798
330
 
@@ -810,26 +342,43 @@ def build_html_report(
810
342
  f'<section id="{section_id}" class="section" data-section="{section_id}">',
811
343
  '<div class="section-head">',
812
344
  f"<h2>{_escape(section_title)} "
813
- f'<span class="pill {pill_cls}" data-count-pill="{section_id}">{len(groups)} groups</span></h2>',
345
+ f'<span class="pill {pill_cls}" data-count-pill="{section_id}">'
346
+ f"{len(groups)} groups</span></h2>",
814
347
  f"""
815
- <div class="section-toolbar" role="toolbar" aria-label="{_escape(section_title)} controls">
348
+ <div class="section-toolbar"
349
+ role="toolbar"
350
+ aria-label="{_escape(section_title)} controls">
816
351
  <div class="toolbar-left">
817
352
  <div class="search-wrap">
818
353
  <span class="search-ico">{ICON_SEARCH}</span>
819
- <input class="search" id="search-{section_id}" placeholder="Search..." autocomplete="off" />
820
- <button class="btn ghost" type="button" data-clear="{section_id}" title="Clear search">{ICON_X}</button>
354
+ <input class="search"
355
+ id="search-{section_id}"
356
+ placeholder="Search..."
357
+ autocomplete="off" />
358
+ <button class="btn ghost"
359
+ type="button"
360
+ data-clear="{section_id}"
361
+ title="Clear search">{ICON_X}</button>
821
362
  </div>
822
363
  <div class="segmented">
823
- <button class="btn seg" type="button" data-collapse-all="{section_id}">Collapse</button>
824
- <button class="btn seg" type="button" data-expand-all="{section_id}">Expand</button>
364
+ <button class="btn seg"
365
+ type="button"
366
+ data-collapse-all="{section_id}">Collapse</button>
367
+ <button class="btn seg"
368
+ type="button"
369
+ data-expand-all="{section_id}">Expand</button>
825
370
  </div>
826
371
  </div>
827
372
 
828
373
  <div class="toolbar-right">
829
374
  <div class="pager">
830
- <button class="btn" type="button" data-prev="{section_id}">{ICON_PREV}</button>
375
+ <button class="btn"
376
+ type="button"
377
+ data-prev="{section_id}">{ICON_PREV}</button>
831
378
  <span class="page-meta" data-page-meta="{section_id}">Page 1</span>
832
- <button class="btn" type="button" data-next="{section_id}">{ICON_NEXT}</button>
379
+ <button class="btn"
380
+ type="button"
381
+ data-next="{section_id}">{ICON_NEXT}</button>
833
382
  </div>
834
383
  <select class="select" data-pagesize="{section_id}" title="Groups per page">
835
384
  <option value="5">5 / page</option>
@@ -853,20 +402,22 @@ def build_html_report(
853
402
  search_blob_escaped = html.escape(search_blob, quote=True)
854
403
 
855
404
  out.append(
856
- f'<div class="group" data-group="{section_id}" data-search="{search_blob_escaped}">'
405
+ f'<div class="group" data-group="{section_id}" '
406
+ f'data-search="{search_blob_escaped}">'
857
407
  )
858
408
 
859
409
  out.append(
860
- f'<div class="group-head">'
861
- f'<div class="group-left">'
862
- f'<button class="chev" type="button" aria-label="Toggle group" data-toggle-group="{section_id}-{idx}">{ICON_CHEV_DOWN}</button>'
410
+ '<div class="group-head">'
411
+ '<div class="group-left">'
412
+ f'<button class="chev" type="button" aria-label="Toggle group" '
413
+ f'data-toggle-group="{section_id}-{idx}">{ICON_CHEV_DOWN}</button>'
863
414
  f'<div class="group-title">Group #{idx}</div>'
864
415
  f'<span class="pill small {pill_cls}">{len(items)} items</span>'
865
- f"</div>"
866
- f'<div class="group-right">'
416
+ "</div>"
417
+ '<div class="group-right">'
867
418
  f'<code class="gkey">{_escape(gkey)}</code>'
868
- f"</div>"
869
- f"</div>"
419
+ "</div>"
420
+ "</div>"
870
421
  )
871
422
 
872
423
  out.append(f'<div class="items" id="group-body-{section_id}-{idx}">')
@@ -904,9 +455,9 @@ def build_html_report(
904
455
  out.append("</section>")
905
456
  return "\n".join(out)
906
457
 
907
- # ============================
458
+ # ============================
908
459
  # HTML Rendering
909
- # ============================
460
+ # ============================
910
461
 
911
462
  empty_state_html = ""
912
463
  if not has_any:
@@ -915,13 +466,17 @@ def build_html_report(
915
466
  <div class="empty-card">
916
467
  <div class="empty-icon">{ICON_CHECK}</div>
917
468
  <h2>No code clones detected</h2>
918
- <p>No structural or block-level duplication was found above configured thresholds.</p>
469
+ <p>
470
+ No structural or block-level duplication was found above configured thresholds.
471
+ </p>
919
472
  <p class="muted">This usually indicates healthy abstraction boundaries.</p>
920
473
  </div>
921
474
  </div>
922
475
  """
923
476
 
924
- func_section = render_section("functions", "Function clones", func_sorted, "pill-func")
477
+ func_section = render_section(
478
+ "functions", "Function clones", func_sorted, "pill-func"
479
+ )
925
480
  block_section = render_section("blocks", "Block clones", block_sorted, "pill-block")
926
481
 
927
482
  return REPORT_TEMPLATE.substitute(
@@ -933,4 +488,5 @@ def build_html_report(
933
488
  func_section=func_section,
934
489
  block_section=block_section,
935
490
  icon_theme=ICON_THEME,
491
+ font_css_url=FONT_CSS_URL,
936
492
  )