codeclone 1.0.0__py3-none-any.whl → 1.2.0__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.
@@ -0,0 +1,936 @@
1
+ """
2
+ CodeClone — AST and CFG-based code clone detector for Python
3
+ focused on architectural duplication.
4
+
5
+ Copyright (c) 2026 Den Rozhnovskiy
6
+ Licensed under the MIT License.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import html
12
+ import itertools
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from string import Template
16
+ from typing import Any, Optional, Iterable
17
+
18
+ from codeclone import __version__
19
+
20
+
21
+ # ============================
22
+ # Pairwise
23
+ # ============================
24
+
25
+
26
+ def pairwise(iterable: Iterable[Any]) -> Iterable[tuple[Any, Any]]:
27
+ a, b = itertools.tee(iterable)
28
+ next(b, None)
29
+ return zip(a, b)
30
+
31
+
32
+ # ============================
33
+ # Code snippet infrastructure
34
+ # ============================
35
+
36
+
37
+ @dataclass
38
+ class _Snippet:
39
+ filepath: str
40
+ start_line: int
41
+ end_line: int
42
+ code_html: str
43
+
44
+
45
+ class _FileCache:
46
+ def __init__(self) -> None:
47
+ self._lines: dict[str, list[str]] = {}
48
+
49
+ def get_lines(self, filepath: str) -> list[str]:
50
+ if filepath not in self._lines:
51
+ try:
52
+ text = Path(filepath).read_text("utf-8")
53
+ except UnicodeDecodeError:
54
+ text = Path(filepath).read_text("utf-8", errors="replace")
55
+ self._lines[filepath] = text.splitlines()
56
+ return self._lines[filepath]
57
+
58
+
59
+ def _try_pygments(code: str) -> Optional[str]:
60
+ try:
61
+ from pygments import highlight
62
+ from pygments.formatters import HtmlFormatter
63
+ from pygments.lexers import PythonLexer
64
+ except Exception:
65
+ return None
66
+
67
+ result = highlight(code, PythonLexer(), HtmlFormatter(nowrap=True))
68
+ return result if isinstance(result, str) else None
69
+
70
+
71
+ def _pygments_css(style_name: str) -> str:
72
+ """
73
+ Returns CSS for pygments tokens. Scoped to `.codebox` to avoid leaking styles.
74
+ If Pygments is not available or style missing, returns "".
75
+ """
76
+ try:
77
+ from pygments.formatters import HtmlFormatter
78
+ except Exception:
79
+ return ""
80
+
81
+ try:
82
+ fmt = HtmlFormatter(style=style_name)
83
+ except Exception:
84
+ try:
85
+ fmt = HtmlFormatter()
86
+ except Exception:
87
+ return ""
88
+
89
+ try:
90
+ # `.codebox` scope: pygments will emit selectors like `.codebox .k { ... }`
91
+ return fmt.get_style_defs(".codebox")
92
+ except Exception:
93
+ return ""
94
+
95
+
96
+ def _prefix_css(css: str, prefix: str) -> str:
97
+ """
98
+ Prefix every selector block with `prefix `.
99
+ Safe enough for pygments CSS which is mostly selector blocks and comments.
100
+ """
101
+ out_lines: list[str] = []
102
+ for line in css.splitlines():
103
+ stripped = line.strip()
104
+ if not stripped:
105
+ out_lines.append(line)
106
+ continue
107
+ if (
108
+ stripped.startswith("/*")
109
+ or stripped.startswith("*")
110
+ or stripped.startswith("*/")
111
+ ):
112
+ out_lines.append(line)
113
+ continue
114
+ # Selector lines usually end with `{
115
+ if "{" in line:
116
+ # naive prefix: split at "{", prefix selector part
117
+ before, after = line.split("{", 1)
118
+ sel = before.strip()
119
+ if sel:
120
+ out_lines.append(f"{prefix} {sel} {{ {after}".rstrip())
121
+ else:
122
+ out_lines.append(line)
123
+ else:
124
+ out_lines.append(line)
125
+ return "\n".join(out_lines)
126
+
127
+
128
+ def _render_code_block(
129
+ *,
130
+ filepath: str,
131
+ start_line: int,
132
+ end_line: int,
133
+ file_cache: _FileCache,
134
+ context: int,
135
+ max_lines: int,
136
+ ) -> _Snippet:
137
+ lines = file_cache.get_lines(filepath)
138
+
139
+ s = max(1, start_line - context)
140
+ e = min(len(lines), end_line + context)
141
+
142
+ if e - s + 1 > max_lines:
143
+ e = s + max_lines - 1
144
+
145
+ numbered: list[tuple[bool, str]] = []
146
+ for lineno in range(s, e + 1):
147
+ line = lines[lineno - 1]
148
+ hit = start_line <= lineno <= end_line
149
+ numbered.append((hit, f"{lineno:>5} | {line.rstrip()}"))
150
+
151
+ raw = "\n".join(text for _, text in numbered)
152
+ highlighted = _try_pygments(raw)
153
+
154
+ if highlighted is None:
155
+ rendered: list[str] = []
156
+ for hit, text in numbered:
157
+ cls = "hitline" if hit else "line"
158
+ rendered.append(f'<div class="{cls}">{html.escape(text)}</div>')
159
+ body = "\n".join(rendered)
160
+ else:
161
+ body = highlighted
162
+
163
+ return _Snippet(
164
+ filepath=filepath,
165
+ start_line=start_line,
166
+ end_line=end_line,
167
+ code_html=f'<pre class="codebox"><code>{body}</code></pre>',
168
+ )
169
+
170
+
171
+ # ============================
172
+ # HTML report builder
173
+ # ============================
174
+
175
+
176
+ def _escape(v: Any) -> str:
177
+ return html.escape("" if v is None else str(v))
178
+
179
+
180
+ def _group_sort_key(items: list[dict[str, Any]]) -> tuple[int, int]:
181
+ return (
182
+ -len(items),
183
+ -max(int(i.get("loc") or i.get("size") or 0) for i in items),
184
+ )
185
+
186
+
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
+ def build_html_report(
757
+ *,
758
+ func_groups: dict[str, list[dict[str, Any]]],
759
+ block_groups: dict[str, list[dict[str, Any]]],
760
+ title: str = "CodeClone Report",
761
+ context_lines: int = 3,
762
+ max_snippet_lines: int = 220,
763
+ ) -> str:
764
+ file_cache = _FileCache()
765
+
766
+ func_sorted = sorted(func_groups.items(), key=lambda kv: _group_sort_key(kv[1]))
767
+ block_sorted = sorted(block_groups.items(), key=lambda kv: _group_sort_key(kv[1]))
768
+
769
+ has_any = bool(func_sorted) or bool(block_sorted)
770
+
771
+ # Pygments CSS (scoped). Use modern GitHub-like styles when available.
772
+ # We scope per theme to support toggle without reloading.
773
+ pyg_dark_raw = _pygments_css("github-dark")
774
+ if not pyg_dark_raw:
775
+ pyg_dark_raw = _pygments_css("monokai")
776
+ pyg_light_raw = _pygments_css("github-light")
777
+ if not pyg_light_raw:
778
+ pyg_light_raw = _pygments_css("friendly")
779
+
780
+ pyg_dark = _prefix_css(pyg_dark_raw, "html[data-theme='dark']")
781
+ pyg_light = _prefix_css(pyg_light_raw, "html[data-theme='light']")
782
+
783
+ # ============================
784
+ # 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
+ # ----------------------------
796
+ # Section renderer
797
+ # ----------------------------
798
+
799
+ def render_section(
800
+ section_id: str,
801
+ section_title: str,
802
+ groups: list[tuple[str, list[dict[str, Any]]]],
803
+ pill_cls: str,
804
+ ) -> str:
805
+ if not groups:
806
+ return ""
807
+
808
+ # build group DOM with data-search (for fast client-side search)
809
+ out: list[str] = [
810
+ f'<section id="{section_id}" class="section" data-section="{section_id}">',
811
+ '<div class="section-head">',
812
+ f"<h2>{_escape(section_title)} "
813
+ f'<span class="pill {pill_cls}" data-count-pill="{section_id}">{len(groups)} groups</span></h2>',
814
+ f"""
815
+ <div class="section-toolbar" role="toolbar" aria-label="{_escape(section_title)} controls">
816
+ <div class="toolbar-left">
817
+ <div class="search-wrap">
818
+ <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>
821
+ </div>
822
+ <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>
825
+ </div>
826
+ </div>
827
+
828
+ <div class="toolbar-right">
829
+ <div class="pager">
830
+ <button class="btn" type="button" data-prev="{section_id}">{ICON_PREV}</button>
831
+ <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>
833
+ </div>
834
+ <select class="select" data-pagesize="{section_id}" title="Groups per page">
835
+ <option value="5">5 / page</option>
836
+ <option value="10" selected>10 / page</option>
837
+ <option value="20">20 / page</option>
838
+ <option value="50">50 / page</option>
839
+ </select>
840
+ </div>
841
+ </div>
842
+ """,
843
+ "</div>", # section-head
844
+ '<div class="section-body">',
845
+ ]
846
+
847
+ for idx, (gkey, items) in enumerate(groups, start=1):
848
+ search_parts: list[str] = [str(gkey)]
849
+ for it in items:
850
+ search_parts.append(str(it.get("qualname", "")))
851
+ search_parts.append(str(it.get("filepath", "")))
852
+ search_blob = " ".join(search_parts).lower()
853
+ search_blob_escaped = html.escape(search_blob, quote=True)
854
+
855
+ out.append(
856
+ f'<div class="group" data-group="{section_id}" data-search="{search_blob_escaped}">'
857
+ )
858
+
859
+ 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>'
863
+ f'<div class="group-title">Group #{idx}</div>'
864
+ f'<span class="pill small {pill_cls}">{len(items)} items</span>'
865
+ f"</div>"
866
+ f'<div class="group-right">'
867
+ f'<code class="gkey">{_escape(gkey)}</code>'
868
+ f"</div>"
869
+ f"</div>"
870
+ )
871
+
872
+ out.append(f'<div class="items" id="group-body-{section_id}-{idx}">')
873
+
874
+ for a, b in pairwise(items):
875
+ out.append('<div class="item-pair">')
876
+
877
+ for item in (a, b):
878
+ snippet = _render_code_block(
879
+ filepath=item["filepath"],
880
+ start_line=int(item["start_line"]),
881
+ end_line=int(item["end_line"]),
882
+ file_cache=file_cache,
883
+ context=context_lines,
884
+ max_lines=max_snippet_lines,
885
+ )
886
+
887
+ out.append(
888
+ '<div class="item">'
889
+ f'<div class="item-head">{_escape(item["qualname"])}</div>'
890
+ f'<div class="item-file">'
891
+ f"{_escape(item['filepath'])}:"
892
+ f"{item['start_line']}-{item['end_line']}"
893
+ f"</div>"
894
+ f"{snippet.code_html}"
895
+ "</div>"
896
+ )
897
+
898
+ out.append("</div>") # item-pair
899
+
900
+ out.append("</div>") # items
901
+ out.append("</div>") # group
902
+
903
+ out.append("</div>") # section-body
904
+ out.append("</section>")
905
+ return "\n".join(out)
906
+
907
+ # ============================
908
+ # HTML Rendering
909
+ # ============================
910
+
911
+ empty_state_html = ""
912
+ if not has_any:
913
+ empty_state_html = f"""
914
+ <div class="empty">
915
+ <div class="empty-card">
916
+ <div class="empty-icon">{ICON_CHECK}</div>
917
+ <h2>No code clones detected</h2>
918
+ <p>No structural or block-level duplication was found above configured thresholds.</p>
919
+ <p class="muted">This usually indicates healthy abstraction boundaries.</p>
920
+ </div>
921
+ </div>
922
+ """
923
+
924
+ func_section = render_section("functions", "Function clones", func_sorted, "pill-func")
925
+ block_section = render_section("blocks", "Block clones", block_sorted, "pill-block")
926
+
927
+ return REPORT_TEMPLATE.substitute(
928
+ title=_escape(title),
929
+ version=__version__,
930
+ pyg_dark=pyg_dark,
931
+ pyg_light=pyg_light,
932
+ empty_state_html=empty_state_html,
933
+ func_section=func_section,
934
+ block_section=block_section,
935
+ icon_theme=ICON_THEME,
936
+ )