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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
codeclone/html_report.py CHANGED
@@ -9,29 +9,35 @@ Licensed under the MIT License.
9
9
  from __future__ import annotations
10
10
 
11
11
  import html
12
+ import importlib
12
13
  import itertools
14
+ from collections.abc import Iterable
13
15
  from dataclasses import dataclass
14
- from pathlib import Path
15
- from typing import Any, Optional, Iterable
16
+ from functools import lru_cache
17
+ from typing import Any, NamedTuple, cast
16
18
 
17
19
  from codeclone import __version__
20
+ from codeclone.errors import FileProcessingError
18
21
 
22
+ from .templates import FONT_CSS_URL, REPORT_TEMPLATE
19
23
 
20
24
  # ============================
21
25
  # Pairwise
22
26
  # ============================
23
27
 
28
+
24
29
  def pairwise(iterable: Iterable[Any]) -> Iterable[tuple[Any, Any]]:
25
30
  a, b = itertools.tee(iterable)
26
31
  next(b, None)
27
- return zip(a, b)
32
+ return zip(a, b, strict=False)
28
33
 
29
34
 
30
35
  # ============================
31
36
  # Code snippet infrastructure
32
37
  # ============================
33
38
 
34
- @dataclass
39
+
40
+ @dataclass(slots=True)
35
41
  class _Snippet:
36
42
  filepath: str
37
43
  start_line: int
@@ -40,28 +46,79 @@ class _Snippet:
40
46
 
41
47
 
42
48
  class _FileCache:
43
- def __init__(self) -> None:
44
- self._lines: dict[str, list[str]] = {}
49
+ __slots__ = ("_get_lines_impl", "maxsize")
50
+
51
+ def __init__(self, maxsize: int = 128) -> None:
52
+ self.maxsize = maxsize
53
+ # Create a bound method with lru_cache
54
+ # We need to cache on the method to have instance-level caching if we wanted
55
+ # different caches per instance. But lru_cache on method actually caches
56
+ # on the function object (class level) if not careful,
57
+ # or we use a wrapper.
58
+ # However, for this script, we usually have one reporter.
59
+ # To be safe and cleaner, we can use a method that delegates to a cached
60
+ # function, OR just use lru_cache on a method (which requires 'self' to be
61
+ # hashable, which it is by default id).
62
+ # But 'self' changes if we create new instances.
63
+ # Let's use the audit's pattern: cache the implementation.
64
+
65
+ self._get_lines_impl = lru_cache(maxsize=maxsize)(self._read_file_range)
66
+
67
+ def _read_file_range(
68
+ self, filepath: str, start_line: int, end_line: int
69
+ ) -> tuple[str, ...]:
70
+ if start_line < 1:
71
+ start_line = 1
72
+ if end_line < start_line:
73
+ return ()
74
+
75
+ try:
76
+
77
+ def _read_with_errors(errors: str) -> tuple[str, ...]:
78
+ lines: list[str] = []
79
+ with open(filepath, encoding="utf-8", errors=errors) as f:
80
+ for lineno, line in enumerate(f, start=1):
81
+ if lineno < start_line:
82
+ continue
83
+ if lineno > end_line:
84
+ break
85
+ lines.append(line.rstrip("\n"))
86
+ return tuple(lines)
45
87
 
46
- def get_lines(self, filepath: str) -> list[str]:
47
- if filepath not in self._lines:
48
88
  try:
49
- text = Path(filepath).read_text("utf-8")
89
+ return _read_with_errors("strict")
50
90
  except UnicodeDecodeError:
51
- text = Path(filepath).read_text("utf-8", errors="replace")
52
- self._lines[filepath] = text.splitlines()
53
- return self._lines[filepath]
91
+ return _read_with_errors("replace")
92
+ except OSError as e:
93
+ raise FileProcessingError(f"Cannot read {filepath}: {e}") from e
94
+
95
+ def get_lines_range(
96
+ self, filepath: str, start_line: int, end_line: int
97
+ ) -> tuple[str, ...]:
98
+ return self._get_lines_impl(filepath, start_line, end_line)
99
+
100
+ class _CacheInfo(NamedTuple):
101
+ hits: int
102
+ misses: int
103
+ maxsize: int | None
104
+ currsize: int
105
+
106
+ def cache_info(self) -> _CacheInfo:
107
+ return cast(_FileCache._CacheInfo, self._get_lines_impl.cache_info())
54
108
 
55
109
 
56
- def _try_pygments(code: str) -> Optional[str]:
110
+ def _try_pygments(code: str) -> str | None:
57
111
  try:
58
- from pygments import highlight
59
- from pygments.formatters import HtmlFormatter
60
- from pygments.lexers import PythonLexer
112
+ pygments = importlib.import_module("pygments")
113
+ formatters = importlib.import_module("pygments.formatters")
114
+ lexers = importlib.import_module("pygments.lexers")
61
115
  except Exception:
62
116
  return None
63
117
 
64
- result = highlight(code, PythonLexer(), HtmlFormatter(nowrap=True))
118
+ highlight = pygments.highlight
119
+ formatter_cls = formatters.HtmlFormatter
120
+ lexer_cls = lexers.PythonLexer
121
+ result = highlight(code, lexer_cls(), formatter_cls(nowrap=True))
65
122
  return result if isinstance(result, str) else None
66
123
 
67
124
 
@@ -71,21 +128,23 @@ def _pygments_css(style_name: str) -> str:
71
128
  If Pygments is not available or style missing, returns "".
72
129
  """
73
130
  try:
74
- from pygments.formatters import HtmlFormatter
131
+ formatters = importlib.import_module("pygments.formatters")
75
132
  except Exception:
76
133
  return ""
77
134
 
78
135
  try:
79
- fmt = HtmlFormatter(style=style_name)
136
+ formatter_cls = formatters.HtmlFormatter
137
+ fmt = formatter_cls(style=style_name)
80
138
  except Exception:
81
139
  try:
82
- fmt = HtmlFormatter()
140
+ fmt = formatter_cls()
83
141
  except Exception:
84
142
  return ""
85
143
 
86
144
  try:
87
145
  # `.codebox` scope: pygments will emit selectors like `.codebox .k { ... }`
88
- return fmt.get_style_defs(".codebox")
146
+ css = fmt.get_style_defs(".codebox")
147
+ return css if isinstance(css, str) else ""
89
148
  except Exception:
90
149
  return ""
91
150
 
@@ -101,10 +160,10 @@ def _prefix_css(css: str, prefix: str) -> str:
101
160
  if not stripped:
102
161
  out_lines.append(line)
103
162
  continue
104
- if stripped.startswith("/*") or stripped.startswith("*") or stripped.startswith("*/"):
163
+ if stripped.startswith(("/*", "*", "*/")):
105
164
  out_lines.append(line)
106
165
  continue
107
- # Selector lines usually end with `{`
166
+ # Selector lines usually end with `{
108
167
  if "{" in line:
109
168
  # naive prefix: split at "{", prefix selector part
110
169
  before, after = line.split("{", 1)
@@ -119,25 +178,24 @@ def _prefix_css(css: str, prefix: str) -> str:
119
178
 
120
179
 
121
180
  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,
181
+ *,
182
+ filepath: str,
183
+ start_line: int,
184
+ end_line: int,
185
+ file_cache: _FileCache,
186
+ context: int,
187
+ max_lines: int,
129
188
  ) -> _Snippet:
130
- lines = file_cache.get_lines(filepath)
131
-
132
189
  s = max(1, start_line - context)
133
- e = min(len(lines), end_line + context)
190
+ e = end_line + context
134
191
 
135
192
  if e - s + 1 > max_lines:
136
193
  e = s + max_lines - 1
137
194
 
195
+ lines = file_cache.get_lines_range(filepath, s, e)
196
+
138
197
  numbered: list[tuple[bool, str]] = []
139
- for lineno in range(s, e + 1):
140
- line = lines[lineno - 1]
198
+ for lineno, line in enumerate(lines, start=s):
141
199
  hit = start_line <= lineno <= end_line
142
200
  numbered.append((hit, f"{lineno:>5} | {line.rstrip()}"))
143
201
 
@@ -157,7 +215,7 @@ def _render_code_block(
157
215
  filepath=filepath,
158
216
  start_line=start_line,
159
217
  end_line=end_line,
160
- code_html=f'<pre class="codebox"><code>{body}</code></pre>',
218
+ code_html=f'<div class="codebox"><pre><code>{body}</code></pre></div>',
161
219
  )
162
220
 
163
221
 
@@ -165,6 +223,7 @@ def _render_code_block(
165
223
  # HTML report builder
166
224
  # ============================
167
225
 
226
+
168
227
  def _escape(v: Any) -> str:
169
228
  return html.escape("" if v is None else str(v))
170
229
 
@@ -177,12 +236,12 @@ def _group_sort_key(items: list[dict[str, Any]]) -> tuple[int, int]:
177
236
 
178
237
 
179
238
  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,
239
+ *,
240
+ func_groups: dict[str, list[dict[str, Any]]],
241
+ block_groups: dict[str, list[dict[str, Any]]],
242
+ title: str = "CodeClone Report",
243
+ context_lines: int = 3,
244
+ max_snippet_lines: int = 220,
186
245
  ) -> str:
187
246
  file_cache = _FileCache()
188
247
 
@@ -203,15 +262,77 @@ def build_html_report(
203
262
  pyg_dark = _prefix_css(pyg_dark_raw, "html[data-theme='dark']")
204
263
  pyg_light = _prefix_css(pyg_light_raw, "html[data-theme='light']")
205
264
 
265
+ # ============================
266
+ # Icons (Inline SVG)
267
+ # ============================
268
+ ICON_SEARCH = (
269
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" '
270
+ 'stroke="currentColor" stroke-width="2.5" stroke-linecap="round" '
271
+ 'stroke-linejoin="round">'
272
+ '<circle cx="11" cy="11" r="8"></circle>'
273
+ '<line x1="21" y1="21" x2="16.65" y2="16.65"></line>'
274
+ "</svg>"
275
+ )
276
+ ICON_X = (
277
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" '
278
+ 'stroke="currentColor" stroke-width="2.5" stroke-linecap="round" '
279
+ 'stroke-linejoin="round">'
280
+ '<line x1="18" y1="6" x2="6" y2="18"></line>'
281
+ '<line x1="6" y1="6" x2="18" y2="18"></line>'
282
+ "</svg>"
283
+ )
284
+ ICON_CHEV_DOWN = (
285
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" '
286
+ 'stroke="currentColor" stroke-width="2.5" stroke-linecap="round" '
287
+ 'stroke-linejoin="round">'
288
+ '<polyline points="6 9 12 15 18 9"></polyline>'
289
+ "</svg>"
290
+ )
291
+ # ICON_CHEV_RIGHT = (
292
+ # '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" '
293
+ # 'stroke="currentColor" stroke-width="2.5" stroke-linecap="round" '
294
+ # 'stroke-linejoin="round">'
295
+ # '<polyline points="9 18 15 12 9 6"></polyline>'
296
+ # "</svg>"
297
+ # )
298
+ ICON_THEME = (
299
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" '
300
+ 'stroke="currentColor" stroke-width="2" stroke-linecap="round" '
301
+ 'stroke-linejoin="round">'
302
+ '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>'
303
+ "</svg>"
304
+ )
305
+ ICON_CHECK = (
306
+ '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" '
307
+ 'stroke="currentColor" stroke-width="2" stroke-linecap="round" '
308
+ 'stroke-linejoin="round">'
309
+ '<polyline points="20 6 9 17 4 12"></polyline>'
310
+ "</svg>"
311
+ )
312
+ ICON_PREV = (
313
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" '
314
+ 'stroke="currentColor" stroke-width="2" stroke-linecap="round" '
315
+ 'stroke-linejoin="round">'
316
+ '<polyline points="15 18 9 12 15 6"></polyline>'
317
+ "</svg>"
318
+ )
319
+ ICON_NEXT = (
320
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" '
321
+ 'stroke="currentColor" stroke-width="2" stroke-linecap="round" '
322
+ 'stroke-linejoin="round">'
323
+ '<polyline points="9 18 15 12 9 6"></polyline>'
324
+ "</svg>"
325
+ )
326
+
206
327
  # ----------------------------
207
328
  # Section renderer
208
329
  # ----------------------------
209
330
 
210
331
  def render_section(
211
- section_id: str,
212
- section_title: str,
213
- groups: list[tuple[str, list[dict[str, Any]]]],
214
- pill_cls: str,
332
+ section_id: str,
333
+ section_title: str,
334
+ groups: list[tuple[str, list[dict[str, Any]]]],
335
+ pill_cls: str,
215
336
  ) -> str:
216
337
  if not groups:
217
338
  return ""
@@ -221,26 +342,43 @@ def build_html_report(
221
342
  f'<section id="{section_id}" class="section" data-section="{section_id}">',
222
343
  '<div class="section-head">',
223
344
  f"<h2>{_escape(section_title)} "
224
- f'<span class="pill {pill_cls}" data-count-pill="{section_id}">{len(groups)} groups</span></h2>',
345
+ f'<span class="pill {pill_cls}" data-count-pill="{section_id}">'
346
+ f"{len(groups)} groups</span></h2>",
225
347
  f"""
226
- <div class="section-toolbar" role="toolbar" aria-label="{_escape(section_title)} controls">
348
+ <div class="section-toolbar"
349
+ role="toolbar"
350
+ aria-label="{_escape(section_title)} controls">
227
351
  <div class="toolbar-left">
228
352
  <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>
353
+ <span class="search-ico">{ICON_SEARCH}</span>
354
+ <input class="search"
355
+ id="search-{section_id}"
356
+ placeholder="Search..."
357
+ autocomplete="off" />
358
+ <button class="btn ghost"
359
+ type="button"
360
+ data-clear="{section_id}"
361
+ title="Clear search">{ICON_X}</button>
232
362
  </div>
233
363
  <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>
364
+ <button class="btn seg"
365
+ type="button"
366
+ data-collapse-all="{section_id}">Collapse</button>
367
+ <button class="btn seg"
368
+ type="button"
369
+ data-expand-all="{section_id}">Expand</button>
236
370
  </div>
237
371
  </div>
238
372
 
239
373
  <div class="toolbar-right">
240
374
  <div class="pager">
241
- <button class="btn" type="button" data-prev="{section_id}">‹</button>
375
+ <button class="btn"
376
+ type="button"
377
+ data-prev="{section_id}">{ICON_PREV}</button>
242
378
  <span class="page-meta" data-page-meta="{section_id}">Page 1</span>
243
- <button class="btn" type="button" data-next="{section_id}">›</button>
379
+ <button class="btn"
380
+ type="button"
381
+ data-next="{section_id}">{ICON_NEXT}</button>
244
382
  </div>
245
383
  <select class="select" data-pagesize="{section_id}" title="Groups per page">
246
384
  <option value="5">5 / page</option>
@@ -256,10 +394,6 @@ def build_html_report(
256
394
  ]
257
395
 
258
396
  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
397
  search_parts: list[str] = [str(gkey)]
264
398
  for it in items:
265
399
  search_parts.append(str(it.get("qualname", "")))
@@ -268,20 +402,22 @@ def build_html_report(
268
402
  search_blob_escaped = html.escape(search_blob, quote=True)
269
403
 
270
404
  out.append(
271
- f'<div class="group" data-group="{section_id}" data-search="{search_blob_escaped}">'
405
+ f'<div class="group" data-group="{section_id}" '
406
+ f'data-search="{search_blob_escaped}">'
272
407
  )
273
408
 
274
409
  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>'
410
+ '<div class="group-head">'
411
+ '<div class="group-left">'
412
+ f'<button class="chev" type="button" aria-label="Toggle group" '
413
+ f'data-toggle-group="{section_id}-{idx}">{ICON_CHEV_DOWN}</button>'
278
414
  f'<div class="group-title">Group #{idx}</div>'
279
415
  f'<span class="pill small {pill_cls}">{len(items)} items</span>'
280
- f"</div>"
281
- f'<div class="group-right">'
416
+ "</div>"
417
+ '<div class="group-right">'
282
418
  f'<code class="gkey">{_escape(gkey)}</code>'
283
- f"</div>"
284
- f"</div>"
419
+ "</div>"
420
+ "</div>"
285
421
  )
286
422
 
287
423
  out.append(f'<div class="items" id="group-body-{section_id}-{idx}">')
@@ -303,11 +439,11 @@ def build_html_report(
303
439
  '<div class="item">'
304
440
  f'<div class="item-head">{_escape(item["qualname"])}</div>'
305
441
  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>'
442
+ f"{_escape(item['filepath'])}:"
443
+ f"{item['start_line']}-{item['end_line']}"
444
+ f"</div>"
445
+ f"{snippet.code_html}"
446
+ "</div>"
311
447
  )
312
448
 
313
449
  out.append("</div>") # item-pair
@@ -320,634 +456,37 @@ def build_html_report(
320
456
  return "\n".join(out)
321
457
 
322
458
  # ============================
323
- # HTML
459
+ # HTML Rendering
324
460
  # ============================
325
461
 
326
462
  empty_state_html = ""
327
463
  if not has_any:
328
- empty_state_html = """
464
+ empty_state_html = f"""
329
465
  <div class="empty">
330
466
  <div class="empty-card">
331
- <div class="empty-icon">✓</div>
467
+ <div class="empty-icon">{ICON_CHECK}</div>
332
468
  <h2>No code clones detected</h2>
333
- <p>No structural or block-level duplication was found above configured thresholds.</p>
469
+ <p>
470
+ No structural or block-level duplication was found above configured thresholds.
471
+ </p>
334
472
  <p class="muted">This usually indicates healthy abstraction boundaries.</p>
335
473
  </div>
336
474
  </div>
337
475
  """
338
476
 
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
- """
477
+ func_section = render_section(
478
+ "functions", "Function clones", func_sorted, "pill-func"
479
+ )
480
+ block_section = render_section("blocks", "Block clones", block_sorted, "pill-block")
481
+
482
+ return REPORT_TEMPLATE.substitute(
483
+ title=_escape(title),
484
+ version=__version__,
485
+ pyg_dark=pyg_dark,
486
+ pyg_light=pyg_light,
487
+ empty_state_html=empty_state_html,
488
+ func_section=func_section,
489
+ block_section=block_section,
490
+ icon_theme=ICON_THEME,
491
+ font_css_url=FONT_CSS_URL,
492
+ )