codeclone 1.1.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.
codeclone/html_report.py CHANGED
@@ -12,14 +12,16 @@ import html
12
12
  import itertools
13
13
  from dataclasses import dataclass
14
14
  from pathlib import Path
15
+ from string import Template
15
16
  from typing import Any, Optional, Iterable
16
17
 
17
18
  from codeclone import __version__
18
19
 
19
20
 
20
- # ============================
21
+ # ============================
21
22
  # Pairwise
22
- # ============================
23
+ # ============================
24
+
23
25
 
24
26
  def pairwise(iterable: Iterable[Any]) -> Iterable[tuple[Any, Any]]:
25
27
  a, b = itertools.tee(iterable)
@@ -27,9 +29,10 @@ def pairwise(iterable: Iterable[Any]) -> Iterable[tuple[Any, Any]]:
27
29
  return zip(a, b)
28
30
 
29
31
 
30
- # ============================
32
+ # ============================
31
33
  # Code snippet infrastructure
32
- # ============================
34
+ # ============================
35
+
33
36
 
34
37
  @dataclass
35
38
  class _Snippet:
@@ -101,10 +104,14 @@ def _prefix_css(css: str, prefix: str) -> str:
101
104
  if not stripped:
102
105
  out_lines.append(line)
103
106
  continue
104
- if stripped.startswith("/*") or stripped.startswith("*") or stripped.startswith("*/"):
107
+ if (
108
+ stripped.startswith("/*")
109
+ or stripped.startswith("*")
110
+ or stripped.startswith("*/")
111
+ ):
105
112
  out_lines.append(line)
106
113
  continue
107
- # Selector lines usually end with `{`
114
+ # Selector lines usually end with `{
108
115
  if "{" in line:
109
116
  # naive prefix: split at "{", prefix selector part
110
117
  before, after = line.split("{", 1)
@@ -119,13 +126,13 @@ def _prefix_css(css: str, prefix: str) -> str:
119
126
 
120
127
 
121
128
  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
+ *,
130
+ filepath: str,
131
+ start_line: int,
132
+ end_line: int,
133
+ file_cache: _FileCache,
134
+ context: int,
135
+ max_lines: int,
129
136
  ) -> _Snippet:
130
137
  lines = file_cache.get_lines(filepath)
131
138
 
@@ -161,9 +168,10 @@ def _render_code_block(
161
168
  )
162
169
 
163
170
 
164
- # ============================
171
+ # ============================
165
172
  # HTML report builder
166
- # ============================
173
+ # ============================
174
+
167
175
 
168
176
  def _escape(v: Any) -> str:
169
177
  return html.escape("" if v is None else str(v))
@@ -176,701 +184,494 @@ def _group_sort_key(items: list[dict[str, Any]]) -> tuple[int, int]:
176
184
  )
177
185
 
178
186
 
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>
187
+ REPORT_TEMPLATE = Template(r"""
188
+ <!doctype html>
340
189
  <html lang="en" data-theme="dark">
341
190
  <head>
342
191
  <meta charset="utf-8">
343
192
  <meta name="viewport" content="width=device-width, initial-scale=1">
344
- <title>{_escape(title)}</title>
193
+ <title>${title}</title>
345
194
 
346
195
  <style>
347
- /* ============================
196
+ /* ============================
348
197
  CodeClone UI/UX
349
198
  ============================ */
350
199
 
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 {{
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 {
387
237
  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);
238
+ background: var(--bg);
391
239
  color: var(--text);
392
- font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
393
- line-height: 1.55;
394
- }}
240
+ font-family: var(--font);
241
+ line-height: 1.5;
242
+ }
395
243
 
396
- .container {{
397
- max-width: 1600px;
244
+ .container {
245
+ max-width: 1400px;
398
246
  margin: 0 auto;
399
- padding: 26px 22px 110px;
400
- }}
247
+ padding: 20px 20px 80px;
248
+ }
401
249
 
402
- .topbar {{
250
+ .topbar {
403
251
  position: sticky;
404
252
  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));
253
+ z-index: 100;
254
+ backdrop-filter: blur(8px);
255
+ -webkit-backdrop-filter: blur(8px);
256
+ background: var(--bg);
409
257
  border-bottom: 1px solid var(--border);
410
- padding: 14px 0 12px;
411
- }}
258
+ opacity: 0.98;
259
+ }
412
260
 
413
- .topbar-inner {{
261
+ .topbar-inner {
414
262
  display: flex;
415
263
  align-items: center;
416
264
  justify-content: space-between;
417
- gap: 14px;
418
- }}
265
+ height: 60px;
266
+ padding: 0 20px;
267
+ max-width: 1400px;
268
+ margin: 0 auto;
269
+ }
419
270
 
420
- .brand {{
271
+ .brand {
421
272
  display: flex;
422
- flex-direction: column;
423
- gap: 3px;
424
- }}
273
+ align-items: center;
274
+ gap: 12px;
275
+ }
425
276
 
426
- .brand h1 {{
277
+ .brand h1 {
427
278
  margin: 0;
428
- font-size: 22px;
429
- letter-spacing: 0.2px;
430
- }}
279
+ font-size: 18px;
280
+ font-weight: 600;
281
+ }
431
282
 
432
- .brand .sub {{
283
+ .brand .sub {
433
284
  color: var(--muted);
434
- font-size: 12.5px;
435
- }}
436
-
437
- .top-actions {{
438
- display: flex;
439
- align-items: center;
440
- gap: 10px;
441
- }}
285
+ font-size: 13px;
286
+ background: var(--panel2);
287
+ padding: 2px 8px;
288
+ border-radius: 99px;
289
+ font-weight: 500;
290
+ }
442
291
 
443
- .btn {{
292
+ .btn {
444
293
  display: inline-flex;
445
294
  align-items: center;
446
295
  justify-content: center;
447
- gap: 8px;
448
- padding: 8px 10px;
449
- border-radius: 10px;
296
+ gap: 6px;
297
+ padding: 6px 12px;
298
+ border-radius: 6px;
450
299
  border: 1px solid var(--border);
451
300
  background: var(--panel);
452
301
  color: var(--text);
453
302
  cursor: pointer;
454
- user-select: none;
455
- font-weight: 600;
456
- font-size: 12.5px;
457
- box-shadow: var(--shadow2);
458
- }}
303
+ font-size: 13px;
304
+ font-weight: 500;
305
+ transition: 0.2s;
306
+ height: 32px;
307
+ }
459
308
 
460
- .btn:hover {{
309
+ .btn:hover {
461
310
  border-color: var(--border2);
462
311
  background: var(--panel2);
463
- }}
464
-
465
- .btn:active {{
466
- transform: translateY(1px);
467
- }}
312
+ }
468
313
 
469
- .btn.ghost {{
314
+ .btn.ghost {
470
315
  background: transparent;
471
- box-shadow: none;
472
- }}
473
-
474
- .select {{
475
- padding: 8px 10px;
476
- border-radius: 10px;
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;
477
326
  border: 1px solid var(--border);
478
327
  background: var(--panel);
479
328
  color: var(--text);
480
- font-weight: 600;
481
- font-size: 12.5px;
482
- }}
329
+ font-size: 13px;
330
+ }
483
331
 
484
- .section {{
485
- margin-top: 22px;
486
- }}
332
+ .section {
333
+ margin-top: 32px;
334
+ }
487
335
 
488
- .section-head {{
336
+ .section-head {
489
337
  display: flex;
490
338
  flex-direction: column;
491
- gap: 10px;
492
- margin-top: 18px;
493
- }}
339
+ gap: 16px;
340
+ margin-bottom: 16px;
341
+ }
494
342
 
495
- .section-head h2 {{
343
+ .section-head h2 {
496
344
  margin: 0;
497
- font-size: 16px;
498
- letter-spacing: 0.2px;
499
- }}
500
-
501
- .section-toolbar {{
345
+ font-size: 20px;
346
+ font-weight: 600;
502
347
  display: flex;
503
- justify-content: space-between;
504
348
  align-items: center;
505
349
  gap: 12px;
506
- flex-wrap: wrap;
507
- }}
350
+ }
508
351
 
509
- .toolbar-left, .toolbar-right {{
352
+ .section-toolbar {
510
353
  display: flex;
354
+ justify-content: space-between;
511
355
  align-items: center;
512
- gap: 10px;
513
- }}
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
+ }
514
363
 
515
- .search-wrap {{
364
+ .search-wrap {
516
365
  display: flex;
517
366
  align-items: center;
518
367
  gap: 8px;
519
- padding: 8px 10px;
520
- border-radius: 12px;
368
+ padding: 4px 8px;
369
+ border-radius: 6px;
521
370
  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
- }}
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
+ }
531
384
 
532
- .search {{
385
+ .search {
533
386
  width: 100%;
534
387
  border: none;
535
388
  outline: none;
536
389
  background: transparent;
537
390
  color: var(--text);
538
391
  font-size: 13px;
539
- }}
540
-
541
- .search::placeholder {{
542
- color: var(--muted);
543
- }}
392
+ }
544
393
 
545
- .segmented {{
394
+ .segmented {
546
395
  display: inline-flex;
547
- border-radius: 12px;
548
- overflow: hidden;
549
- border: 1px solid var(--border);
550
- }}
396
+ background: var(--panel2);
397
+ padding: 2px;
398
+ border-radius: 6px;
399
+ }
551
400
 
552
- .btn.seg {{
401
+ .btn.seg {
553
402
  border: none;
554
- border-radius: 0;
555
- box-shadow: none;
556
403
  background: transparent;
557
- }}
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
+ }
558
411
 
559
- .pager {{
412
+ .pager {
560
413
  display: inline-flex;
561
414
  align-items: center;
562
415
  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
- }}
416
+ font-size: 13px;
417
+ }
571
418
 
572
- .pill {{
573
- padding: 4px 10px;
574
- border-radius: 999px;
419
+ .pill {
420
+ padding: 2px 10px;
421
+ border-radius: 99px;
575
422
  background: var(--accent2);
576
- border: 1px solid rgba(106,166,255,0.25);
423
+ border: 1px solid rgba(56, 139, 253, 0.3);
577
424
  font-size: 12px;
578
- font-weight: 800;
579
- color: var(--text);
580
- }}
581
-
582
- .pill.small {{
583
- padding: 3px 8px;
425
+ font-weight: 600;
426
+ color: var(--accent);
427
+ }
428
+ .pill.small {
429
+ padding: 1px 8px;
584
430
  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
- }}
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
+ }
600
441
 
601
- .group {{
602
- margin-top: 14px;
442
+ .group {
443
+ margin-bottom: 16px;
603
444
  border: 1px solid var(--border);
604
- border-radius: var(--radius2);
605
- background: var(--panel);
606
- box-shadow: var(--shadow);
607
- overflow: hidden;
608
- }}
445
+ border-radius: 6px;
446
+ background: var(--bg);
447
+ box-shadow: var(--shadow2);
448
+ }
609
449
 
610
- .group-head {{
450
+ .group-head {
611
451
  display: flex;
612
452
  justify-content: space-between;
613
453
  align-items: center;
614
- gap: 12px;
615
- padding: 12px 14px;
454
+ padding: 12px 16px;
455
+ background: var(--panel);
616
456
  border-bottom: 1px solid var(--border);
617
- }}
457
+ cursor: pointer;
458
+ }
618
459
 
619
- .group-left {{
460
+ .group-left {
620
461
  display: flex;
621
462
  align-items: center;
622
- gap: 10px;
623
- }}
624
-
625
- .group-title {{
626
- font-weight: 900;
627
- font-size: 13px;
628
- }}
463
+ gap: 12px;
464
+ }
629
465
 
630
- .group-right {{
631
- display: flex;
632
- align-items: center;
633
- gap: 10px;
634
- max-width: 60%;
635
- }}
466
+ .group-title {
467
+ font-weight: 600;
468
+ font-size: 14px;
469
+ }
636
470
 
637
- .gkey {{
471
+ .gkey {
638
472
  font-family: var(--mono);
639
- font-size: 11.5px;
473
+ font-size: 12px;
640
474
  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
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 {
652
493
  color: var(--text);
653
- cursor: pointer;
654
- font-weight: 900;
655
- }}
494
+ border-color: var(--border2);
495
+ }
656
496
 
657
- .items {{
658
- padding: 14px;
659
- }}
497
+ .items {
498
+ padding: 16px;
499
+ }
660
500
 
661
- .item-pair {{
501
+ .item-pair {
662
502
  display: grid;
663
503
  grid-template-columns: 1fr 1fr;
664
- gap: 14px;
665
- margin-top: 14px;
666
- }}
667
-
668
- @media (max-width: 1100px) {{
669
- .item-pair {{
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 {
670
513
  grid-template-columns: 1fr;
671
- }}
672
- .search-wrap {{
673
- min-width: 260px;
674
- }}
675
- }}
514
+ }
515
+ }
676
516
 
677
- .item {{
517
+ .item {
678
518
  border: 1px solid var(--border);
679
- border-radius: var(--radius);
519
+ border-radius: 6px;
680
520
  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
- }}
521
+ display: flex;
522
+ flex-direction: column;
523
+ }
687
524
 
688
- .item-head {{
689
- padding: 10px 12px;
690
- font-weight: 900;
691
- font-size: 12.8px;
525
+ .item-head {
526
+ padding: 8px 12px;
527
+ background: var(--panel);
692
528
  border-bottom: 1px solid var(--border);
693
- }}
529
+ font-size: 13px;
530
+ font-weight: 600;
531
+ color: var(--accent);
532
+ }
694
533
 
695
- .item-file {{
534
+ .item-file {
696
535
  padding: 6px 12px;
536
+ background: var(--panel2);
537
+ border-bottom: 1px solid var(--border);
697
538
  font-family: var(--mono);
698
- font-size: 11.5px;
539
+ font-size: 11px;
699
540
  color: var(--muted);
700
- border-bottom: 1px solid var(--border);
701
- }}
541
+ }
702
542
 
703
- .codebox {{
543
+ .codebox {
704
544
  margin: 0;
705
545
  padding: 12px;
706
546
  font-family: var(--mono);
707
- font-size: 12.5px;
547
+ font-size: 12px;
548
+ line-height: 1.5;
708
549
  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
- }}
550
+ background: var(--bg);
551
+ flex: 1;
552
+ }
719
553
 
720
- .hitline {{
721
- white-space: pre;
722
- background: rgba(255, 184, 107, .18);
723
- }}
724
-
725
- .empty {{
726
- margin-top: 34px;
554
+ .empty {
555
+ padding: 60px 0;
727
556
  display: flex;
728
557
  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;
558
+ }
559
+ .empty-card {
738
560
  text-align: center;
739
- }}
740
-
741
- .empty-icon {{
742
- font-size: 52px;
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 {
743
568
  color: var(--good);
744
- font-weight: 900;
745
- margin-bottom: 8px;
746
- }}
569
+ margin-bottom: 16px;
570
+ display: flex;
571
+ justify-content: center;
572
+ }
747
573
 
748
- .footer {{
749
- margin-top: 40px;
574
+ .footer {
575
+ margin-top: 60px;
750
576
  text-align: center;
751
577
  color: var(--muted);
752
578
  font-size: 12px;
753
- }}
754
-
755
- .muted {{
756
- color: var(--muted);
757
- }}
579
+ border-top: 1px solid var(--border);
580
+ padding-top: 24px;
581
+ }
758
582
 
759
- /* ============================
760
- Pygments CSS (SCOPED)
761
- IMPORTANT: without this, `nowrap=True` output won't be colored.
762
- ============================ */
763
- {pyg_dark}
764
- {pyg_light}
583
+ ${pyg_dark}
584
+ ${pyg_light}
765
585
  </style>
766
586
  </head>
767
587
 
768
588
  <body>
769
589
  <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>
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>
780
597
  </div>
781
598
  </div>
782
599
  </div>
783
600
 
784
601
  <div class="container">
785
- {empty_state_html}
602
+ ${empty_state_html}
786
603
 
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")}
604
+ ${func_section}
605
+ ${block_section}
789
606
 
790
- <div class="footer">Generated by CodeClone v{__version__}</div>
607
+ <div class="footer">Generated by CodeClone v${version}</div>
791
608
  </div>
792
609
 
793
610
  <script>
794
- (() => {{
795
- // ----------------------------
796
- // Theme toggle
797
- // ----------------------------
611
+ (() => {
798
612
  const htmlEl = document.documentElement;
799
613
  const btnTheme = document.getElementById("theme-toggle");
800
614
 
801
615
  const stored = localStorage.getItem("codeclone_theme");
802
- if (stored === "light" || stored === "dark") {{
616
+ if (stored === "light" || stored === "dark") {
803
617
  htmlEl.setAttribute("data-theme", stored);
804
- }}
618
+ }
805
619
 
806
- btnTheme?.addEventListener("click", () => {{
620
+ btnTheme?.addEventListener("click", () => {
807
621
  const cur = htmlEl.getAttribute("data-theme") || "dark";
808
622
  const next = cur === "dark" ? "light" : "dark";
809
623
  htmlEl.setAttribute("data-theme", next);
810
624
  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", () => {{
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", () => {
818
638
  const id = btn.getAttribute("data-toggle-group");
819
639
  const body = document.getElementById("group-body-" + id);
820
640
  if (!body) return;
821
641
 
822
642
  const isHidden = body.style.display === "none";
823
643
  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}}']`);
644
+ btn.style.transform = isHidden ? "rotate(0deg)" : "rotate(-90deg)";
645
+ });
646
+ });
842
647
 
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}}']`);
648
+ function initSection(sectionId) {
649
+ const section = document.querySelector(`section[data-section='$${sectionId}']`);
650
+ if (!section) return;
847
651
 
848
- const state = {{
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 = {
849
664
  q: "",
850
665
  page: 1,
851
666
  pageSize: parseInt(selPageSize?.value || "10", 10),
852
667
  filtered: groups
853
- }};
668
+ };
854
669
 
855
- function setGroupVisible(el, yes) {{
670
+ function setGroupVisible(el, yes) {
856
671
  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
- }}
672
+ }
872
673
 
873
- function render() {{
674
+ function render() {
874
675
  const total = state.filtered.length;
875
676
  const pageSize = Math.max(1, state.pageSize);
876
677
  const pages = Math.max(1, Math.ceil(total / pageSize));
@@ -879,75 +680,257 @@ html[data-theme="light"] .codebox {{
879
680
  const start = (state.page - 1) * pageSize;
880
681
  const end = Math.min(total, start + pageSize);
881
682
 
882
- // hide all (this is the "virtualization": only show the slice)
883
683
  groups.forEach(g => setGroupVisible(g, false));
884
684
  state.filtered.slice(start, end).forEach(g => setGroupVisible(g, true));
885
685
 
886
- if (meta) {{
887
- meta.textContent = `Page ${{state.page}} / ${{pages}} • ${{total}} groups`;
888
- }}
889
- if (pill) {{
890
- pill.textContent = `${{total}} groups`;
891
- }}
686
+ if (meta) meta.textContent = `Page $${state.page} / $${pages} • $${total} groups`;
687
+ if (pill) pill.textContent = `$${total} groups`;
892
688
 
893
689
  if (btnPrev) btnPrev.disabled = state.page <= 1;
894
690
  if (btnNext) btnNext.disabled = state.page >= pages;
895
- }}
691
+ }
896
692
 
897
- // Wiring
898
- searchInput?.addEventListener("input", (e) => {{
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) => {
899
708
  state.q = e.target.value || "";
900
709
  applyFilter();
901
- }});
710
+ });
902
711
 
903
- btnClear?.addEventListener("click", () => {{
712
+ btnClear?.addEventListener("click", () => {
904
713
  if (searchInput) searchInput.value = "";
905
714
  state.q = "";
906
715
  applyFilter();
907
- }});
716
+ });
908
717
 
909
- selPageSize?.addEventListener("change", () => {{
718
+ selPageSize?.addEventListener("change", () => {
910
719
  state.pageSize = parseInt(selPageSize.value || "10", 10);
911
720
  state.page = 1;
912
721
  render();
913
- }});
722
+ });
914
723
 
915
- btnPrev?.addEventListener("click", () => {{
724
+ btnPrev?.addEventListener("click", () => {
916
725
  state.page -= 1;
917
726
  render();
918
- }});
727
+ });
919
728
 
920
- btnNext?.addEventListener("click", () => {{
729
+ btnNext?.addEventListener("click", () => {
921
730
  state.page += 1;
922
731
  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
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
+
944
744
  render();
945
- }}
745
+ }
946
746
 
947
747
  initSection("functions");
948
748
  initSection("blocks");
949
- }})();
749
+ })();
950
750
  </script>
951
751
  </body>
952
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>
953
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
+ )