codeclone 1.0.0__py3-none-any.whl → 1.1.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,953 @@
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 typing import Any, Optional, Iterable
16
+
17
+ from codeclone import __version__
18
+
19
+
20
+ # ============================
21
+ # Pairwise
22
+ # ============================
23
+
24
+ def pairwise(iterable: Iterable[Any]) -> Iterable[tuple[Any, Any]]:
25
+ a, b = itertools.tee(iterable)
26
+ next(b, None)
27
+ return zip(a, b)
28
+
29
+
30
+ # ============================
31
+ # Code snippet infrastructure
32
+ # ============================
33
+
34
+ @dataclass
35
+ class _Snippet:
36
+ filepath: str
37
+ start_line: int
38
+ end_line: int
39
+ code_html: str
40
+
41
+
42
+ class _FileCache:
43
+ def __init__(self) -> None:
44
+ self._lines: dict[str, list[str]] = {}
45
+
46
+ def get_lines(self, filepath: str) -> list[str]:
47
+ if filepath not in self._lines:
48
+ try:
49
+ text = Path(filepath).read_text("utf-8")
50
+ except UnicodeDecodeError:
51
+ text = Path(filepath).read_text("utf-8", errors="replace")
52
+ self._lines[filepath] = text.splitlines()
53
+ return self._lines[filepath]
54
+
55
+
56
+ def _try_pygments(code: str) -> Optional[str]:
57
+ try:
58
+ from pygments import highlight
59
+ from pygments.formatters import HtmlFormatter
60
+ from pygments.lexers import PythonLexer
61
+ except Exception:
62
+ return None
63
+
64
+ result = highlight(code, PythonLexer(), HtmlFormatter(nowrap=True))
65
+ return result if isinstance(result, str) else None
66
+
67
+
68
+ def _pygments_css(style_name: str) -> str:
69
+ """
70
+ Returns CSS for pygments tokens. Scoped to `.codebox` to avoid leaking styles.
71
+ If Pygments is not available or style missing, returns "".
72
+ """
73
+ try:
74
+ from pygments.formatters import HtmlFormatter
75
+ except Exception:
76
+ return ""
77
+
78
+ try:
79
+ fmt = HtmlFormatter(style=style_name)
80
+ except Exception:
81
+ try:
82
+ fmt = HtmlFormatter()
83
+ except Exception:
84
+ return ""
85
+
86
+ try:
87
+ # `.codebox` scope: pygments will emit selectors like `.codebox .k { ... }`
88
+ return fmt.get_style_defs(".codebox")
89
+ except Exception:
90
+ return ""
91
+
92
+
93
+ def _prefix_css(css: str, prefix: str) -> str:
94
+ """
95
+ Prefix every selector block with `prefix `.
96
+ Safe enough for pygments CSS which is mostly selector blocks and comments.
97
+ """
98
+ out_lines: list[str] = []
99
+ for line in css.splitlines():
100
+ stripped = line.strip()
101
+ if not stripped:
102
+ out_lines.append(line)
103
+ continue
104
+ if stripped.startswith("/*") or stripped.startswith("*") or stripped.startswith("*/"):
105
+ out_lines.append(line)
106
+ continue
107
+ # Selector lines usually end with `{`
108
+ if "{" in line:
109
+ # naive prefix: split at "{", prefix selector part
110
+ before, after = line.split("{", 1)
111
+ sel = before.strip()
112
+ if sel:
113
+ out_lines.append(f"{prefix} {sel} {{ {after}".rstrip())
114
+ else:
115
+ out_lines.append(line)
116
+ else:
117
+ out_lines.append(line)
118
+ return "\n".join(out_lines)
119
+
120
+
121
+ def _render_code_block(
122
+ *,
123
+ filepath: str,
124
+ start_line: int,
125
+ end_line: int,
126
+ file_cache: _FileCache,
127
+ context: int,
128
+ max_lines: int,
129
+ ) -> _Snippet:
130
+ lines = file_cache.get_lines(filepath)
131
+
132
+ s = max(1, start_line - context)
133
+ e = min(len(lines), end_line + context)
134
+
135
+ if e - s + 1 > max_lines:
136
+ e = s + max_lines - 1
137
+
138
+ numbered: list[tuple[bool, str]] = []
139
+ for lineno in range(s, e + 1):
140
+ line = lines[lineno - 1]
141
+ hit = start_line <= lineno <= end_line
142
+ numbered.append((hit, f"{lineno:>5} | {line.rstrip()}"))
143
+
144
+ raw = "\n".join(text for _, text in numbered)
145
+ highlighted = _try_pygments(raw)
146
+
147
+ if highlighted is None:
148
+ rendered: list[str] = []
149
+ for hit, text in numbered:
150
+ cls = "hitline" if hit else "line"
151
+ rendered.append(f'<div class="{cls}">{html.escape(text)}</div>')
152
+ body = "\n".join(rendered)
153
+ else:
154
+ body = highlighted
155
+
156
+ return _Snippet(
157
+ filepath=filepath,
158
+ start_line=start_line,
159
+ end_line=end_line,
160
+ code_html=f'<pre class="codebox"><code>{body}</code></pre>',
161
+ )
162
+
163
+
164
+ # ============================
165
+ # HTML report builder
166
+ # ============================
167
+
168
+ def _escape(v: Any) -> str:
169
+ return html.escape("" if v is None else str(v))
170
+
171
+
172
+ def _group_sort_key(items: list[dict[str, Any]]) -> tuple[int, int]:
173
+ return (
174
+ -len(items),
175
+ -max(int(i.get("loc") or i.get("size") or 0) for i in items),
176
+ )
177
+
178
+
179
+ def build_html_report(
180
+ *,
181
+ func_groups: dict[str, list[dict[str, Any]]],
182
+ block_groups: dict[str, list[dict[str, Any]]],
183
+ title: str = "CodeClone Report",
184
+ context_lines: int = 3,
185
+ max_snippet_lines: int = 220,
186
+ ) -> str:
187
+ file_cache = _FileCache()
188
+
189
+ func_sorted = sorted(func_groups.items(), key=lambda kv: _group_sort_key(kv[1]))
190
+ block_sorted = sorted(block_groups.items(), key=lambda kv: _group_sort_key(kv[1]))
191
+
192
+ has_any = bool(func_sorted) or bool(block_sorted)
193
+
194
+ # Pygments CSS (scoped). Use modern GitHub-like styles when available.
195
+ # We scope per theme to support toggle without reloading.
196
+ pyg_dark_raw = _pygments_css("github-dark")
197
+ if not pyg_dark_raw:
198
+ pyg_dark_raw = _pygments_css("monokai")
199
+ pyg_light_raw = _pygments_css("github-light")
200
+ if not pyg_light_raw:
201
+ pyg_light_raw = _pygments_css("friendly")
202
+
203
+ pyg_dark = _prefix_css(pyg_dark_raw, "html[data-theme='dark']")
204
+ pyg_light = _prefix_css(pyg_light_raw, "html[data-theme='light']")
205
+
206
+ # ----------------------------
207
+ # Section renderer
208
+ # ----------------------------
209
+
210
+ def render_section(
211
+ section_id: str,
212
+ section_title: str,
213
+ groups: list[tuple[str, list[dict[str, Any]]]],
214
+ pill_cls: str,
215
+ ) -> str:
216
+ if not groups:
217
+ return ""
218
+
219
+ # build group DOM with data-search (for fast client-side search)
220
+ out: list[str] = [
221
+ f'<section id="{section_id}" class="section" data-section="{section_id}">',
222
+ '<div class="section-head">',
223
+ f"<h2>{_escape(section_title)} "
224
+ f'<span class="pill {pill_cls}" data-count-pill="{section_id}">{len(groups)} groups</span></h2>',
225
+ f"""
226
+ <div class="section-toolbar" role="toolbar" aria-label="{_escape(section_title)} controls">
227
+ <div class="toolbar-left">
228
+ <div class="search-wrap">
229
+ <span class="search-ico">⌕</span>
230
+ <input class="search" id="search-{section_id}" placeholder="Search by qualname / path / fingerprint…" autocomplete="off" />
231
+ <button class="btn ghost" type="button" data-clear="{section_id}" title="Clear search">×</button>
232
+ </div>
233
+ <div class="segmented">
234
+ <button class="btn seg" type="button" data-collapse-all="{section_id}">Collapse</button>
235
+ <button class="btn seg" type="button" data-expand-all="{section_id}">Expand</button>
236
+ </div>
237
+ </div>
238
+
239
+ <div class="toolbar-right">
240
+ <div class="pager">
241
+ <button class="btn" type="button" data-prev="{section_id}">‹</button>
242
+ <span class="page-meta" data-page-meta="{section_id}">Page 1</span>
243
+ <button class="btn" type="button" data-next="{section_id}">›</button>
244
+ </div>
245
+ <select class="select" data-pagesize="{section_id}" title="Groups per page">
246
+ <option value="5">5 / page</option>
247
+ <option value="10" selected>10 / page</option>
248
+ <option value="20">20 / page</option>
249
+ <option value="50">50 / page</option>
250
+ </select>
251
+ </div>
252
+ </div>
253
+ """,
254
+ "</div>", # section-head
255
+ '<div class="section-body">',
256
+ ]
257
+
258
+ for idx, (gkey, items) in enumerate(groups, start=1):
259
+ # Create search blob for group:
260
+ # - gkey (fingerprint)
261
+ # - all qualnames + filepaths
262
+ # This is used by JS filtering (no heavy DOM scans on each keystroke).
263
+ search_parts: list[str] = [str(gkey)]
264
+ for it in items:
265
+ search_parts.append(str(it.get("qualname", "")))
266
+ search_parts.append(str(it.get("filepath", "")))
267
+ search_blob = " ".join(search_parts).lower()
268
+ search_blob_escaped = html.escape(search_blob, quote=True)
269
+
270
+ out.append(
271
+ f'<div class="group" data-group="{section_id}" data-search="{search_blob_escaped}">'
272
+ )
273
+
274
+ out.append(
275
+ f'<div class="group-head">'
276
+ f'<div class="group-left">'
277
+ f'<button class="chev" type="button" aria-label="Toggle group" data-toggle-group="{section_id}-{idx}">▾</button>'
278
+ f'<div class="group-title">Group #{idx}</div>'
279
+ f'<span class="pill small {pill_cls}">{len(items)} items</span>'
280
+ f"</div>"
281
+ f'<div class="group-right">'
282
+ f'<code class="gkey">{_escape(gkey)}</code>'
283
+ f"</div>"
284
+ f"</div>"
285
+ )
286
+
287
+ out.append(f'<div class="items" id="group-body-{section_id}-{idx}">')
288
+
289
+ for a, b in pairwise(items):
290
+ out.append('<div class="item-pair">')
291
+
292
+ for item in (a, b):
293
+ snippet = _render_code_block(
294
+ filepath=item["filepath"],
295
+ start_line=int(item["start_line"]),
296
+ end_line=int(item["end_line"]),
297
+ file_cache=file_cache,
298
+ context=context_lines,
299
+ max_lines=max_snippet_lines,
300
+ )
301
+
302
+ out.append(
303
+ '<div class="item">'
304
+ f'<div class="item-head">{_escape(item["qualname"])}</div>'
305
+ f'<div class="item-file">'
306
+ f'{_escape(item["filepath"])}:'
307
+ f'{item["start_line"]}-{item["end_line"]}'
308
+ f'</div>'
309
+ f'{snippet.code_html}'
310
+ '</div>'
311
+ )
312
+
313
+ out.append("</div>") # item-pair
314
+
315
+ out.append("</div>") # items
316
+ out.append("</div>") # group
317
+
318
+ out.append("</div>") # section-body
319
+ out.append("</section>")
320
+ return "\n".join(out)
321
+
322
+ # ============================
323
+ # HTML
324
+ # ============================
325
+
326
+ empty_state_html = ""
327
+ if not has_any:
328
+ empty_state_html = """
329
+ <div class="empty">
330
+ <div class="empty-card">
331
+ <div class="empty-icon">✓</div>
332
+ <h2>No code clones detected</h2>
333
+ <p>No structural or block-level duplication was found above configured thresholds.</p>
334
+ <p class="muted">This usually indicates healthy abstraction boundaries.</p>
335
+ </div>
336
+ </div>
337
+ """
338
+
339
+ return f"""<!doctype html>
340
+ <html lang="en" data-theme="dark">
341
+ <head>
342
+ <meta charset="utf-8">
343
+ <meta name="viewport" content="width=device-width, initial-scale=1">
344
+ <title>{_escape(title)}</title>
345
+
346
+ <style>
347
+ /* ============================
348
+ CodeClone UI/UX
349
+ ============================ */
350
+
351
+ :root {{
352
+ --bg: #0b0f14;
353
+ --panel: rgba(255,255,255,0.04);
354
+ --panel2: rgba(255,255,255,0.06);
355
+ --text: rgba(255,255,255,0.92);
356
+ --muted: rgba(255,255,255,0.62);
357
+ --border: rgba(255,255,255,0.10);
358
+ --border2: rgba(255,255,255,0.14);
359
+ --accent: #6aa6ff;
360
+ --accent2: rgba(106,166,255,0.18);
361
+ --good: #7cffa0;
362
+ --shadow: 0 18px 60px rgba(0,0,0,.55);
363
+ --shadow2: 0 10px 26px rgba(0,0,0,.45);
364
+ --radius: 14px;
365
+ --radius2: 18px;
366
+ --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
367
+ }}
368
+
369
+ html[data-theme="light"] {{
370
+ --bg: #f6f8fb;
371
+ --panel: rgba(0,0,0,0.03);
372
+ --panel2: rgba(0,0,0,0.05);
373
+ --text: rgba(0,0,0,0.88);
374
+ --muted: rgba(0,0,0,0.55);
375
+ --border: rgba(0,0,0,0.10);
376
+ --border2: rgba(0,0,0,0.14);
377
+ --accent: #1f6feb;
378
+ --accent2: rgba(31,111,235,0.14);
379
+ --good: #1f883d;
380
+ --shadow: 0 18px 60px rgba(0,0,0,.12);
381
+ --shadow2: 0 10px 26px rgba(0,0,0,.10);
382
+ }}
383
+
384
+ * {{ box-sizing: border-box; }}
385
+
386
+ body {{
387
+ margin: 0;
388
+ background: radial-gradient(1200px 800px at 20% -10%, rgba(106,166,255,.18), transparent 45%),
389
+ radial-gradient(900px 600px at 80% 0%, rgba(124,255,160,.10), transparent 35%),
390
+ var(--bg);
391
+ color: var(--text);
392
+ font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
393
+ line-height: 1.55;
394
+ }}
395
+
396
+ .container {{
397
+ max-width: 1600px;
398
+ margin: 0 auto;
399
+ padding: 26px 22px 110px;
400
+ }}
401
+
402
+ .topbar {{
403
+ position: sticky;
404
+ top: 0;
405
+ z-index: 50;
406
+ backdrop-filter: blur(14px);
407
+ -webkit-backdrop-filter: blur(14px);
408
+ background: linear-gradient(to bottom, rgba(0,0,0,.35), rgba(0,0,0,0));
409
+ border-bottom: 1px solid var(--border);
410
+ padding: 14px 0 12px;
411
+ }}
412
+
413
+ .topbar-inner {{
414
+ display: flex;
415
+ align-items: center;
416
+ justify-content: space-between;
417
+ gap: 14px;
418
+ }}
419
+
420
+ .brand {{
421
+ display: flex;
422
+ flex-direction: column;
423
+ gap: 3px;
424
+ }}
425
+
426
+ .brand h1 {{
427
+ margin: 0;
428
+ font-size: 22px;
429
+ letter-spacing: 0.2px;
430
+ }}
431
+
432
+ .brand .sub {{
433
+ color: var(--muted);
434
+ font-size: 12.5px;
435
+ }}
436
+
437
+ .top-actions {{
438
+ display: flex;
439
+ align-items: center;
440
+ gap: 10px;
441
+ }}
442
+
443
+ .btn {{
444
+ display: inline-flex;
445
+ align-items: center;
446
+ justify-content: center;
447
+ gap: 8px;
448
+ padding: 8px 10px;
449
+ border-radius: 10px;
450
+ border: 1px solid var(--border);
451
+ background: var(--panel);
452
+ color: var(--text);
453
+ cursor: pointer;
454
+ user-select: none;
455
+ font-weight: 600;
456
+ font-size: 12.5px;
457
+ box-shadow: var(--shadow2);
458
+ }}
459
+
460
+ .btn:hover {{
461
+ border-color: var(--border2);
462
+ background: var(--panel2);
463
+ }}
464
+
465
+ .btn:active {{
466
+ transform: translateY(1px);
467
+ }}
468
+
469
+ .btn.ghost {{
470
+ background: transparent;
471
+ box-shadow: none;
472
+ }}
473
+
474
+ .select {{
475
+ padding: 8px 10px;
476
+ border-radius: 10px;
477
+ border: 1px solid var(--border);
478
+ background: var(--panel);
479
+ color: var(--text);
480
+ font-weight: 600;
481
+ font-size: 12.5px;
482
+ }}
483
+
484
+ .section {{
485
+ margin-top: 22px;
486
+ }}
487
+
488
+ .section-head {{
489
+ display: flex;
490
+ flex-direction: column;
491
+ gap: 10px;
492
+ margin-top: 18px;
493
+ }}
494
+
495
+ .section-head h2 {{
496
+ margin: 0;
497
+ font-size: 16px;
498
+ letter-spacing: 0.2px;
499
+ }}
500
+
501
+ .section-toolbar {{
502
+ display: flex;
503
+ justify-content: space-between;
504
+ align-items: center;
505
+ gap: 12px;
506
+ flex-wrap: wrap;
507
+ }}
508
+
509
+ .toolbar-left, .toolbar-right {{
510
+ display: flex;
511
+ align-items: center;
512
+ gap: 10px;
513
+ }}
514
+
515
+ .search-wrap {{
516
+ display: flex;
517
+ align-items: center;
518
+ gap: 8px;
519
+ padding: 8px 10px;
520
+ border-radius: 12px;
521
+ border: 1px solid var(--border);
522
+ background: var(--panel);
523
+ box-shadow: var(--shadow2);
524
+ min-width: 320px;
525
+ }}
526
+
527
+ .search-ico {{
528
+ opacity: .72;
529
+ font-weight: 800;
530
+ }}
531
+
532
+ .search {{
533
+ width: 100%;
534
+ border: none;
535
+ outline: none;
536
+ background: transparent;
537
+ color: var(--text);
538
+ font-size: 13px;
539
+ }}
540
+
541
+ .search::placeholder {{
542
+ color: var(--muted);
543
+ }}
544
+
545
+ .segmented {{
546
+ display: inline-flex;
547
+ border-radius: 12px;
548
+ overflow: hidden;
549
+ border: 1px solid var(--border);
550
+ }}
551
+
552
+ .btn.seg {{
553
+ border: none;
554
+ border-radius: 0;
555
+ box-shadow: none;
556
+ background: transparent;
557
+ }}
558
+
559
+ .pager {{
560
+ display: inline-flex;
561
+ align-items: center;
562
+ gap: 8px;
563
+ }}
564
+
565
+ .page-meta {{
566
+ color: var(--muted);
567
+ font-size: 12.5px;
568
+ min-width: 120px;
569
+ text-align: center;
570
+ }}
571
+
572
+ .pill {{
573
+ padding: 4px 10px;
574
+ border-radius: 999px;
575
+ background: var(--accent2);
576
+ border: 1px solid rgba(106,166,255,0.25);
577
+ font-size: 12px;
578
+ font-weight: 800;
579
+ color: var(--text);
580
+ }}
581
+
582
+ .pill.small {{
583
+ padding: 3px 8px;
584
+ font-size: 11px;
585
+ }}
586
+
587
+ .pill-func {{
588
+ background: rgba(106,166,255,0.14);
589
+ border-color: rgba(106,166,255,0.25);
590
+ }}
591
+
592
+ .pill-block {{
593
+ background: rgba(124,255,160,0.10);
594
+ border-color: rgba(124,255,160,0.22);
595
+ }}
596
+
597
+ .section-body {{
598
+ margin-top: 12px;
599
+ }}
600
+
601
+ .group {{
602
+ margin-top: 14px;
603
+ border: 1px solid var(--border);
604
+ border-radius: var(--radius2);
605
+ background: var(--panel);
606
+ box-shadow: var(--shadow);
607
+ overflow: hidden;
608
+ }}
609
+
610
+ .group-head {{
611
+ display: flex;
612
+ justify-content: space-between;
613
+ align-items: center;
614
+ gap: 12px;
615
+ padding: 12px 14px;
616
+ border-bottom: 1px solid var(--border);
617
+ }}
618
+
619
+ .group-left {{
620
+ display: flex;
621
+ align-items: center;
622
+ gap: 10px;
623
+ }}
624
+
625
+ .group-title {{
626
+ font-weight: 900;
627
+ font-size: 13px;
628
+ }}
629
+
630
+ .group-right {{
631
+ display: flex;
632
+ align-items: center;
633
+ gap: 10px;
634
+ max-width: 60%;
635
+ }}
636
+
637
+ .gkey {{
638
+ font-family: var(--mono);
639
+ font-size: 11.5px;
640
+ color: var(--muted);
641
+ overflow: hidden;
642
+ text-overflow: ellipsis;
643
+ white-space: nowrap;
644
+ }}
645
+
646
+ .chev {{
647
+ width: 30px;
648
+ height: 30px;
649
+ border-radius: 10px;
650
+ border: 1px solid var(--border);
651
+ background: var(--panel2);
652
+ color: var(--text);
653
+ cursor: pointer;
654
+ font-weight: 900;
655
+ }}
656
+
657
+ .items {{
658
+ padding: 14px;
659
+ }}
660
+
661
+ .item-pair {{
662
+ display: grid;
663
+ grid-template-columns: 1fr 1fr;
664
+ gap: 14px;
665
+ margin-top: 14px;
666
+ }}
667
+
668
+ @media (max-width: 1100px) {{
669
+ .item-pair {{
670
+ grid-template-columns: 1fr;
671
+ }}
672
+ .search-wrap {{
673
+ min-width: 260px;
674
+ }}
675
+ }}
676
+
677
+ .item {{
678
+ border: 1px solid var(--border);
679
+ border-radius: var(--radius);
680
+ overflow: hidden;
681
+ background: rgba(0,0,0,0.10);
682
+ }}
683
+
684
+ html[data-theme="light"] .item {{
685
+ background: rgba(255,255,255,0.60);
686
+ }}
687
+
688
+ .item-head {{
689
+ padding: 10px 12px;
690
+ font-weight: 900;
691
+ font-size: 12.8px;
692
+ border-bottom: 1px solid var(--border);
693
+ }}
694
+
695
+ .item-file {{
696
+ padding: 6px 12px;
697
+ font-family: var(--mono);
698
+ font-size: 11.5px;
699
+ color: var(--muted);
700
+ border-bottom: 1px solid var(--border);
701
+ }}
702
+
703
+ .codebox {{
704
+ margin: 0;
705
+ padding: 12px;
706
+ font-family: var(--mono);
707
+ font-size: 12.5px;
708
+ overflow: auto;
709
+ background: rgba(0,0,0,0.18);
710
+ }}
711
+
712
+ html[data-theme="light"] .codebox {{
713
+ background: rgba(0,0,0,0.03);
714
+ }}
715
+
716
+ .line {{
717
+ white-space: pre;
718
+ }}
719
+
720
+ .hitline {{
721
+ white-space: pre;
722
+ background: rgba(255, 184, 107, .18);
723
+ }}
724
+
725
+ .empty {{
726
+ margin-top: 34px;
727
+ display: flex;
728
+ justify-content: center;
729
+ }}
730
+
731
+ .empty-card {{
732
+ max-width: 640px;
733
+ border-radius: 22px;
734
+ border: 1px solid var(--border);
735
+ background: var(--panel);
736
+ box-shadow: var(--shadow);
737
+ padding: 26px 26px;
738
+ text-align: center;
739
+ }}
740
+
741
+ .empty-icon {{
742
+ font-size: 52px;
743
+ color: var(--good);
744
+ font-weight: 900;
745
+ margin-bottom: 8px;
746
+ }}
747
+
748
+ .footer {{
749
+ margin-top: 40px;
750
+ text-align: center;
751
+ color: var(--muted);
752
+ font-size: 12px;
753
+ }}
754
+
755
+ .muted {{
756
+ color: var(--muted);
757
+ }}
758
+
759
+ /* ============================
760
+ Pygments CSS (SCOPED)
761
+ IMPORTANT: without this, `nowrap=True` output won't be colored.
762
+ ============================ */
763
+ {pyg_dark}
764
+ {pyg_light}
765
+ </style>
766
+ </head>
767
+
768
+ <body>
769
+ <div class="topbar">
770
+ <div class="container">
771
+ <div class="topbar-inner">
772
+ <div class="brand">
773
+ <h1>{_escape(title)}</h1>
774
+ <div class="sub">AST + CFG clone detection • CodeClone v{__version__}</div>
775
+ </div>
776
+
777
+ <div class="top-actions">
778
+ <button class="btn" type="button" id="theme-toggle" title="Toggle theme">🌓 Theme</button>
779
+ </div>
780
+ </div>
781
+ </div>
782
+ </div>
783
+
784
+ <div class="container">
785
+ {empty_state_html}
786
+
787
+ {render_section("functions", "Function clones (Type-2)", func_sorted, "pill-func")}
788
+ {render_section("blocks", "Block clones (Type-3-lite)", block_sorted, "pill-block")}
789
+
790
+ <div class="footer">Generated by CodeClone v{__version__}</div>
791
+ </div>
792
+
793
+ <script>
794
+ (() => {{
795
+ // ----------------------------
796
+ // Theme toggle
797
+ // ----------------------------
798
+ const htmlEl = document.documentElement;
799
+ const btnTheme = document.getElementById("theme-toggle");
800
+
801
+ const stored = localStorage.getItem("codeclone_theme");
802
+ if (stored === "light" || stored === "dark") {{
803
+ htmlEl.setAttribute("data-theme", stored);
804
+ }}
805
+
806
+ btnTheme?.addEventListener("click", () => {{
807
+ const cur = htmlEl.getAttribute("data-theme") || "dark";
808
+ const next = cur === "dark" ? "light" : "dark";
809
+ htmlEl.setAttribute("data-theme", next);
810
+ localStorage.setItem("codeclone_theme", next);
811
+ }});
812
+
813
+ // ----------------------------
814
+ // Group collapse toggles
815
+ // ----------------------------
816
+ document.querySelectorAll("[data-toggle-group]").forEach((btn) => {{
817
+ btn.addEventListener("click", () => {{
818
+ const id = btn.getAttribute("data-toggle-group");
819
+ const body = document.getElementById("group-body-" + id);
820
+ if (!body) return;
821
+
822
+ const isHidden = body.style.display === "none";
823
+ body.style.display = isHidden ? "" : "none";
824
+ btn.textContent = isHidden ? "▾" : "▸";
825
+ }});
826
+ }});
827
+
828
+ // ----------------------------
829
+ // Search + Pagination ("soft virtualization")
830
+ // ----------------------------
831
+ function initSection(sectionId) {{
832
+ const section = document.querySelector(`section[data-section='${{sectionId}}']`);
833
+ if (!section) return;
834
+
835
+ const groups = Array.from(section.querySelectorAll(`.group[data-group='${{sectionId}}']`));
836
+ const searchInput = document.getElementById(`search-${{sectionId}}`);
837
+
838
+ const btnPrev = section.querySelector(`[data-prev='${{sectionId}}']`);
839
+ const btnNext = section.querySelector(`[data-next='${{sectionId}}']`);
840
+ const meta = section.querySelector(`[data-page-meta='${{sectionId}}']`);
841
+ const selPageSize = section.querySelector(`[data-pagesize='${{sectionId}}']`);
842
+
843
+ const btnClear = section.querySelector(`[data-clear='${{sectionId}}']`);
844
+ const btnCollapseAll = section.querySelector(`[data-collapse-all='${{sectionId}}']`);
845
+ const btnExpandAll = section.querySelector(`[data-expand-all='${{sectionId}}']`);
846
+ const pill = section.querySelector(`[data-count-pill='${{sectionId}}']`);
847
+
848
+ const state = {{
849
+ q: "",
850
+ page: 1,
851
+ pageSize: parseInt(selPageSize?.value || "10", 10),
852
+ filtered: groups
853
+ }};
854
+
855
+ function setGroupVisible(el, yes) {{
856
+ el.style.display = yes ? "" : "none";
857
+ }}
858
+
859
+ function applyFilter() {{
860
+ const q = (state.q || "").trim().toLowerCase();
861
+ if (!q) {{
862
+ state.filtered = groups;
863
+ }} else {{
864
+ state.filtered = groups.filter(g => {{
865
+ const blob = g.getAttribute("data-search") || "";
866
+ return blob.indexOf(q) !== -1;
867
+ }});
868
+ }}
869
+ state.page = 1;
870
+ render();
871
+ }}
872
+
873
+ function render() {{
874
+ const total = state.filtered.length;
875
+ const pageSize = Math.max(1, state.pageSize);
876
+ const pages = Math.max(1, Math.ceil(total / pageSize));
877
+ state.page = Math.min(Math.max(1, state.page), pages);
878
+
879
+ const start = (state.page - 1) * pageSize;
880
+ const end = Math.min(total, start + pageSize);
881
+
882
+ // hide all (this is the "virtualization": only show the slice)
883
+ groups.forEach(g => setGroupVisible(g, false));
884
+ state.filtered.slice(start, end).forEach(g => setGroupVisible(g, true));
885
+
886
+ if (meta) {{
887
+ meta.textContent = `Page ${{state.page}} / ${{pages}} • ${{total}} groups`;
888
+ }}
889
+ if (pill) {{
890
+ pill.textContent = `${{total}} groups`;
891
+ }}
892
+
893
+ if (btnPrev) btnPrev.disabled = state.page <= 1;
894
+ if (btnNext) btnNext.disabled = state.page >= pages;
895
+ }}
896
+
897
+ // Wiring
898
+ searchInput?.addEventListener("input", (e) => {{
899
+ state.q = e.target.value || "";
900
+ applyFilter();
901
+ }});
902
+
903
+ btnClear?.addEventListener("click", () => {{
904
+ if (searchInput) searchInput.value = "";
905
+ state.q = "";
906
+ applyFilter();
907
+ }});
908
+
909
+ selPageSize?.addEventListener("change", () => {{
910
+ state.pageSize = parseInt(selPageSize.value || "10", 10);
911
+ state.page = 1;
912
+ render();
913
+ }});
914
+
915
+ btnPrev?.addEventListener("click", () => {{
916
+ state.page -= 1;
917
+ render();
918
+ }});
919
+
920
+ btnNext?.addEventListener("click", () => {{
921
+ state.page += 1;
922
+ render();
923
+ }});
924
+
925
+ btnCollapseAll?.addEventListener("click", () => {{
926
+ section.querySelectorAll(".items").forEach((b) => {{
927
+ b.style.display = "none";
928
+ }});
929
+ section.querySelectorAll("[data-toggle-group]").forEach((c) => {{
930
+ c.textContent = "▸";
931
+ }});
932
+ }});
933
+
934
+ btnExpandAll?.addEventListener("click", () => {{
935
+ section.querySelectorAll(".items").forEach((b) => {{
936
+ b.style.display = "";
937
+ }});
938
+ section.querySelectorAll("[data-toggle-group]").forEach((c) => {{
939
+ c.textContent = "▾";
940
+ }});
941
+ }});
942
+
943
+ // Initial render
944
+ render();
945
+ }}
946
+
947
+ initSection("functions");
948
+ initSection("blocks");
949
+ }})();
950
+ </script>
951
+ </body>
952
+ </html>
953
+ """