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/__init__.py +1 -1
- codeclone/baseline.py +44 -14
- codeclone/blockhash.py +1 -1
- codeclone/blocks.py +4 -3
- codeclone/cache.py +154 -17
- codeclone/cfg.py +128 -38
- codeclone/cfg_model.py +47 -0
- codeclone/cli.py +524 -100
- codeclone/errors.py +27 -0
- codeclone/extractor.py +101 -24
- codeclone/html_report.py +230 -691
- codeclone/normalize.py +43 -13
- codeclone/py.typed +0 -0
- codeclone/report.py +23 -12
- codeclone/scanner.py +66 -3
- codeclone/templates.py +1262 -0
- {codeclone-1.1.0.dist-info → codeclone-1.2.1.dist-info}/METADATA +62 -34
- codeclone-1.2.1.dist-info/RECORD +23 -0
- {codeclone-1.1.0.dist-info → codeclone-1.2.1.dist-info}/WHEEL +1 -1
- codeclone-1.1.0.dist-info/RECORD +0 -19
- {codeclone-1.1.0.dist-info → codeclone-1.2.1.dist-info}/entry_points.txt +0 -0
- {codeclone-1.1.0.dist-info → codeclone-1.2.1.dist-info}/licenses/LICENSE +0 -0
- {codeclone-1.1.0.dist-info → codeclone-1.2.1.dist-info}/top_level.txt +0 -0
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
|
|
15
|
-
from typing import Any,
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
89
|
+
return _read_with_errors("strict")
|
|
50
90
|
except UnicodeDecodeError:
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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) ->
|
|
110
|
+
def _try_pygments(code: str) -> str | None:
|
|
57
111
|
try:
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
+
formatters = importlib.import_module("pygments.formatters")
|
|
75
132
|
except Exception:
|
|
76
133
|
return ""
|
|
77
134
|
|
|
78
135
|
try:
|
|
79
|
-
|
|
136
|
+
formatter_cls = formatters.HtmlFormatter
|
|
137
|
+
fmt = formatter_cls(style=style_name)
|
|
80
138
|
except Exception:
|
|
81
139
|
try:
|
|
82
|
-
fmt =
|
|
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
|
-
|
|
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("/*"
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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 =
|
|
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
|
|
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'<
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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}">
|
|
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"
|
|
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"
|
|
230
|
-
<input class="search"
|
|
231
|
-
|
|
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"
|
|
235
|
-
|
|
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"
|
|
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"
|
|
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}"
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
f'<button class="chev" type="button" aria-label="Toggle group"
|
|
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
|
-
|
|
281
|
-
|
|
416
|
+
"</div>"
|
|
417
|
+
'<div class="group-right">'
|
|
282
418
|
f'<code class="gkey">{_escape(gkey)}</code>'
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
307
|
-
f
|
|
308
|
-
f
|
|
309
|
-
f
|
|
310
|
-
|
|
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"
|
|
467
|
+
<div class="empty-icon">{ICON_CHECK}</div>
|
|
332
468
|
<h2>No code clones detected</h2>
|
|
333
|
-
<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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
+
)
|