code-prune 1.0.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.
- code_prune/.codepruneignore +9 -0
- code_prune/__init__.py +25 -0
- code_prune/cli.py +475 -0
- code_prune/config.ini +2 -0
- code_prune/config.py +44 -0
- code_prune/logging.ini +28 -0
- code_prune/parser.py +67 -0
- code_prune/reporter.py +360 -0
- code_prune/scanner.py +800 -0
- code_prune/store.py +266 -0
- code_prune/symbol_ignore +18 -0
- code_prune-1.0.0.dist-info/METADATA +164 -0
- code_prune-1.0.0.dist-info/RECORD +16 -0
- code_prune-1.0.0.dist-info/WHEEL +4 -0
- code_prune-1.0.0.dist-info/entry_points.txt +3 -0
- code_prune-1.0.0.dist-info/licenses/LICENSE +21 -0
code_prune/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Package initialization for code_prune.
|
|
3
|
+
|
|
4
|
+
Bootstraps the configuration directory and logging setup.
|
|
5
|
+
|
|
6
|
+
Author: Ron Webb
|
|
7
|
+
Since: 1.0.0
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from env_dir_bootstrap import EnvDirBootstrap
|
|
11
|
+
|
|
12
|
+
__app_name__ = "code-prune"
|
|
13
|
+
__version__ = "1.0.0"
|
|
14
|
+
|
|
15
|
+
_bootstrapper = EnvDirBootstrap(
|
|
16
|
+
env_var="CODE_PRUNE_CONFIG_DIR",
|
|
17
|
+
resources=["logging.ini", "config.ini", ".codepruneignore", "symbol_ignore"],
|
|
18
|
+
package="code_prune",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_bootstrapper.setup()
|
|
22
|
+
|
|
23
|
+
CONF_DIR = str(_bootstrapper.get_dir())
|
|
24
|
+
PATH_IGNORE_PATH = _bootstrapper.resolve(".codepruneignore")
|
|
25
|
+
SYMBOL_IGNORE_PATH = _bootstrapper.resolve("symbol_ignore")
|
code_prune/cli.py
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""Command-line interface for code_prune.
|
|
2
|
+
|
|
3
|
+
Author: Ron Webb
|
|
4
|
+
Since: 1.0.0
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import concurrent.futures
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import uuid
|
|
14
|
+
from collections.abc import Generator
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
from braincraft.ignorefile import IgnoreFile
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
from rich.progress import (
|
|
21
|
+
BarColumn,
|
|
22
|
+
MofNCompleteColumn,
|
|
23
|
+
Progress,
|
|
24
|
+
SpinnerColumn,
|
|
25
|
+
TextColumn,
|
|
26
|
+
TimeElapsedColumn,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from logenrich import setup_logger
|
|
30
|
+
|
|
31
|
+
from . import __version__
|
|
32
|
+
from . import __app_name__ as _APP_NAME
|
|
33
|
+
from . import CONF_DIR
|
|
34
|
+
from . import PATH_IGNORE_PATH
|
|
35
|
+
from . import SYMBOL_IGNORE_PATH
|
|
36
|
+
from .config import Config
|
|
37
|
+
from .reporter import write_outputs
|
|
38
|
+
from .scanner import CLI_CATEGORIES, load_name_ignore_patterns, scan_file
|
|
39
|
+
from .store import SessionStore
|
|
40
|
+
|
|
41
|
+
_VALID_FORMATS = ("json", "html", "all")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _configure_stream_encoding(stream: object) -> None:
|
|
45
|
+
"""Force *stream* to UTF-8 with a replacing error handler when possible.
|
|
46
|
+
|
|
47
|
+
Prevents UnicodeEncodeError on legacy Windows code pages or restrictive
|
|
48
|
+
locales. Streams without ``reconfigure`` (e.g. click's test runner) are
|
|
49
|
+
left untouched instead of raising.
|
|
50
|
+
|
|
51
|
+
Author: Ron Webb
|
|
52
|
+
Since: 1.0.0
|
|
53
|
+
"""
|
|
54
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
55
|
+
if callable(reconfigure):
|
|
56
|
+
reconfigure(encoding="utf-8", errors="replace")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_configure_stream_encoding(sys.stdout)
|
|
60
|
+
_configure_stream_encoding(sys.stderr)
|
|
61
|
+
_console = Console(stderr=True)
|
|
62
|
+
_logger = setup_logger(__name__, conf_dir=CONF_DIR)
|
|
63
|
+
_config = Config()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _parse_extensions(raw: str) -> list[str]:
|
|
67
|
+
"""Parse a comma-separated extension string into a normalised list.
|
|
68
|
+
|
|
69
|
+
Each entry is lowercased and prefixed with a dot when absent.
|
|
70
|
+
Example: ``"py,js, TS"`` -> ``['.py', '.js', '.ts']``
|
|
71
|
+
|
|
72
|
+
Author: Ron Webb
|
|
73
|
+
Since: 1.0.0
|
|
74
|
+
"""
|
|
75
|
+
result: list[str] = []
|
|
76
|
+
for part in raw.split(","):
|
|
77
|
+
ext = part.strip().lower()
|
|
78
|
+
if ext and not ext.startswith("."):
|
|
79
|
+
ext = "." + ext
|
|
80
|
+
if ext:
|
|
81
|
+
result.append(ext)
|
|
82
|
+
return result
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _parse_paths(raw: str) -> list[Path]:
|
|
86
|
+
"""Parse a semicolon-separated path string into a list of Path objects.
|
|
87
|
+
|
|
88
|
+
Example: ``"/src/a; /src/b"`` -> ``[Path('/src/a'), Path('/src/b')]``
|
|
89
|
+
|
|
90
|
+
Author: Ron Webb
|
|
91
|
+
Since: 1.0.0
|
|
92
|
+
"""
|
|
93
|
+
result: list[Path] = []
|
|
94
|
+
for part in raw.split(";"):
|
|
95
|
+
stripped = part.strip()
|
|
96
|
+
if stripped:
|
|
97
|
+
result.append(Path(stripped))
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _read_target_list(list_file: Path) -> list[Path]:
|
|
102
|
+
"""Read one target path per line from *list_file*.
|
|
103
|
+
|
|
104
|
+
Blank lines and lines starting with ``#`` are skipped.
|
|
105
|
+
|
|
106
|
+
Author: Ron Webb
|
|
107
|
+
Since: 1.0.0
|
|
108
|
+
"""
|
|
109
|
+
result: list[Path] = []
|
|
110
|
+
for line in list_file.read_text(encoding="utf-8").splitlines():
|
|
111
|
+
stripped = line.strip()
|
|
112
|
+
if stripped and not stripped.startswith("#"):
|
|
113
|
+
result.append(Path(stripped))
|
|
114
|
+
return result
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _parse_categories(raw: str) -> set[str]:
|
|
118
|
+
"""Parse a comma-separated category string into a validated set.
|
|
119
|
+
|
|
120
|
+
Raises :class:`click.UsageError` when an unknown category is given.
|
|
121
|
+
|
|
122
|
+
Author: Ron Webb
|
|
123
|
+
Since: 1.0.0
|
|
124
|
+
"""
|
|
125
|
+
requested = {part.strip().lower() for part in raw.split(",") if part.strip()}
|
|
126
|
+
unknown = requested - set(CLI_CATEGORIES)
|
|
127
|
+
if unknown:
|
|
128
|
+
raise click.UsageError(
|
|
129
|
+
f"Unknown categories: {', '.join(sorted(unknown))}. "
|
|
130
|
+
f"Valid categories: {', '.join(CLI_CATEGORIES)}."
|
|
131
|
+
)
|
|
132
|
+
return requested
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _scan_and_store(task: tuple[Path, SessionStore, str, set[str]]) -> None:
|
|
136
|
+
"""Scan one file and write its results to the session store.
|
|
137
|
+
|
|
138
|
+
Accepts a 4-tuple so the function can be passed directly to
|
|
139
|
+
:meth:`concurrent.futures.Executor.map` without a closure.
|
|
140
|
+
|
|
141
|
+
Author: Ron Webb
|
|
142
|
+
Since: 1.0.0
|
|
143
|
+
"""
|
|
144
|
+
file_path, store, session_id, categories = task
|
|
145
|
+
result = scan_file(file_path)
|
|
146
|
+
findings = [f for f in result.findings if _category_enabled(f.category, categories)]
|
|
147
|
+
store.insert_findings(session_id, findings)
|
|
148
|
+
if "unused" in categories:
|
|
149
|
+
store.insert_definitions(session_id, result.definitions)
|
|
150
|
+
store.insert_identifiers(session_id, result.usages)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _category_enabled(category: str, categories: set[str]) -> bool:
|
|
154
|
+
"""Return whether *category* should be reported given selected *categories*.
|
|
155
|
+
|
|
156
|
+
Author: Ron Webb
|
|
157
|
+
Since: 1.0.0
|
|
158
|
+
"""
|
|
159
|
+
if category == "unreachable":
|
|
160
|
+
return "unreachable" in categories
|
|
161
|
+
if category in ("unused-import", "unused-local", "unused-symbol"):
|
|
162
|
+
return "unused" in categories
|
|
163
|
+
if category == "obsolete":
|
|
164
|
+
return "obsolete" in categories
|
|
165
|
+
if category == "commented-out":
|
|
166
|
+
return "commented-out" in categories
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _load_ignore_file(ignore_path: Path, base_dir: Path) -> IgnoreFile | None:
|
|
171
|
+
"""Load an :class:`IgnoreFile` from *ignore_path*, logging failures.
|
|
172
|
+
|
|
173
|
+
Returns ``None`` on a missing or non-UTF-8 file instead of raising.
|
|
174
|
+
|
|
175
|
+
Author: Ron Webb
|
|
176
|
+
Since: 1.0.0
|
|
177
|
+
"""
|
|
178
|
+
try:
|
|
179
|
+
return IgnoreFile(ignore_path, base_dir=base_dir)
|
|
180
|
+
except FileNotFoundError:
|
|
181
|
+
_logger.warning("Ignore file not found at %s", ignore_path)
|
|
182
|
+
return None
|
|
183
|
+
except UnicodeDecodeError as exc:
|
|
184
|
+
_logger.warning("Ignore file at %s is not valid UTF-8: %s", ignore_path, exc)
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _build_ignore(base_dir: Path) -> IgnoreFile | None:
|
|
189
|
+
"""Build a path-ignore matcher anchored at *base_dir*.
|
|
190
|
+
|
|
191
|
+
The ignore filename is resolved from ``config.ini``'s
|
|
192
|
+
``[override] ignore-file`` setting under :data:`CONF_DIR`; when that file
|
|
193
|
+
is missing, the bundled default ``.codepruneignore`` is tried instead.
|
|
194
|
+
Returns ``None`` when neither ignore file is usable, so callers can
|
|
195
|
+
proceed without path filtering instead of failing the whole scan.
|
|
196
|
+
|
|
197
|
+
Author: Ron Webb
|
|
198
|
+
Since: 1.0.0
|
|
199
|
+
"""
|
|
200
|
+
custom_path = Path(CONF_DIR) / _config.get_ignore_file()
|
|
201
|
+
ignore = _load_ignore_file(custom_path, base_dir)
|
|
202
|
+
_logger.debug("Loaded ignore file from %s: %s", custom_path, ignore)
|
|
203
|
+
if ignore is not None:
|
|
204
|
+
return ignore
|
|
205
|
+
if custom_path == Path(PATH_IGNORE_PATH):
|
|
206
|
+
return None
|
|
207
|
+
return _load_ignore_file(Path(PATH_IGNORE_PATH), base_dir)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _walk_unignored(
|
|
211
|
+
root: Path, ignore: IgnoreFile | None
|
|
212
|
+
) -> Generator[Path, None, None]:
|
|
213
|
+
"""Recursively yield files under *root*, pruning directories matched by *ignore*.
|
|
214
|
+
|
|
215
|
+
Author: Ron Webb
|
|
216
|
+
Since: 1.0.0
|
|
217
|
+
"""
|
|
218
|
+
for entry in root.iterdir():
|
|
219
|
+
if ignore is not None and ignore.is_ignored(entry):
|
|
220
|
+
continue
|
|
221
|
+
if entry.is_dir():
|
|
222
|
+
yield from _walk_unignored(entry, ignore)
|
|
223
|
+
elif entry.is_file():
|
|
224
|
+
yield entry
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def discover_files(
|
|
228
|
+
path: Path, extensions: list[str], ignore: IgnoreFile | None = None
|
|
229
|
+
) -> list[Path]:
|
|
230
|
+
"""Discover files under *path* that match the given extensions.
|
|
231
|
+
|
|
232
|
+
When *extensions* is empty every file is included.
|
|
233
|
+
Both files and directories are accepted; for a plain file the extension
|
|
234
|
+
filter still applies. When *ignore* is given, matching files are skipped
|
|
235
|
+
and matching directories are pruned from the walk entirely.
|
|
236
|
+
|
|
237
|
+
Author: Ron Webb
|
|
238
|
+
Since: 1.0.0
|
|
239
|
+
"""
|
|
240
|
+
candidates: list[Path]
|
|
241
|
+
if path.is_file():
|
|
242
|
+
candidates = [] if ignore is not None and ignore.is_ignored(path) else [path]
|
|
243
|
+
elif path.is_dir():
|
|
244
|
+
candidates = sorted(_walk_unignored(path, ignore))
|
|
245
|
+
else:
|
|
246
|
+
return []
|
|
247
|
+
|
|
248
|
+
if not extensions:
|
|
249
|
+
return candidates
|
|
250
|
+
return [f for f in candidates if f.suffix.lower() in extensions]
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _run_concurrent_scan(
|
|
254
|
+
files: list[Path],
|
|
255
|
+
store: SessionStore,
|
|
256
|
+
session_id: str,
|
|
257
|
+
workers: int,
|
|
258
|
+
categories: set[str],
|
|
259
|
+
) -> None:
|
|
260
|
+
"""Scan *files* concurrently and store results under *session_id*.
|
|
261
|
+
|
|
262
|
+
Displays a live progress bar via the module-level rich console.
|
|
263
|
+
|
|
264
|
+
Author: Ron Webb
|
|
265
|
+
Since: 1.0.0
|
|
266
|
+
"""
|
|
267
|
+
with Progress(
|
|
268
|
+
SpinnerColumn(),
|
|
269
|
+
TextColumn("[progress.description]{task.description}"),
|
|
270
|
+
BarColumn(),
|
|
271
|
+
MofNCompleteColumn(),
|
|
272
|
+
TimeElapsedColumn(),
|
|
273
|
+
console=_console,
|
|
274
|
+
transient=True,
|
|
275
|
+
) as progress:
|
|
276
|
+
task = progress.add_task("[cyan]Scanning\u2026", total=len(files))
|
|
277
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
|
278
|
+
futures = {
|
|
279
|
+
executor.submit(_scan_and_store, (f, store, session_id, categories)): f
|
|
280
|
+
for f in files
|
|
281
|
+
}
|
|
282
|
+
for future in concurrent.futures.as_completed(futures):
|
|
283
|
+
try:
|
|
284
|
+
future.result()
|
|
285
|
+
except Exception as exc: # pylint: disable=broad-exception-caught
|
|
286
|
+
_logger.warning("Failed to scan %s: %s", futures[future], exc)
|
|
287
|
+
progress.advance(task)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@click.command()
|
|
291
|
+
@click.version_option(
|
|
292
|
+
version=__version__, prog_name=_APP_NAME, message="%(prog)s v%(version)s"
|
|
293
|
+
)
|
|
294
|
+
@click.argument("path")
|
|
295
|
+
@click.option(
|
|
296
|
+
"--ext",
|
|
297
|
+
default="",
|
|
298
|
+
help=(
|
|
299
|
+
"Comma-separated file extensions to include "
|
|
300
|
+
"(e.g. py,java,js,ts,gs,gsx). Omit to scan all files."
|
|
301
|
+
),
|
|
302
|
+
)
|
|
303
|
+
@click.option(
|
|
304
|
+
"--output",
|
|
305
|
+
default="code-prune-output",
|
|
306
|
+
help=(
|
|
307
|
+
"Base name (without extension) for the output file(s) "
|
|
308
|
+
"(default: code-prune-output)."
|
|
309
|
+
),
|
|
310
|
+
)
|
|
311
|
+
@click.option(
|
|
312
|
+
"--output-dir",
|
|
313
|
+
"output_dir",
|
|
314
|
+
default="reports",
|
|
315
|
+
type=click.Path(path_type=Path),
|
|
316
|
+
help=(
|
|
317
|
+
"Directory where the output file will be written "
|
|
318
|
+
"(default: reports). "
|
|
319
|
+
"The filename from --output is placed inside this directory."
|
|
320
|
+
),
|
|
321
|
+
)
|
|
322
|
+
@click.option(
|
|
323
|
+
"--format",
|
|
324
|
+
"fmt",
|
|
325
|
+
default="json",
|
|
326
|
+
type=click.Choice(_VALID_FORMATS),
|
|
327
|
+
help="Output format: json, html, or all (default: json).",
|
|
328
|
+
)
|
|
329
|
+
@click.option(
|
|
330
|
+
"--workers",
|
|
331
|
+
type=int,
|
|
332
|
+
default=min(32, (os.cpu_count() or 1) + 4),
|
|
333
|
+
help=(
|
|
334
|
+
"Number of parallel worker threads used to scan files "
|
|
335
|
+
"(default: min(32, cpu_count + 4))."
|
|
336
|
+
),
|
|
337
|
+
)
|
|
338
|
+
@click.option(
|
|
339
|
+
"--db",
|
|
340
|
+
"db_path",
|
|
341
|
+
default=str(Path(tempfile.gettempdir()) / "code_prune.db"),
|
|
342
|
+
type=click.Path(path_type=Path),
|
|
343
|
+
help=(
|
|
344
|
+
"Path to the SQLite scratch database used to store scan results "
|
|
345
|
+
"during a run (default: <system-temp>/code_prune.db). "
|
|
346
|
+
"Session records are removed after the report is written."
|
|
347
|
+
),
|
|
348
|
+
)
|
|
349
|
+
@click.option(
|
|
350
|
+
"--categories",
|
|
351
|
+
default=",".join(CLI_CATEGORIES),
|
|
352
|
+
help=(
|
|
353
|
+
"Comma-separated categories to report: "
|
|
354
|
+
f"{', '.join(CLI_CATEGORIES)} (default: all). "
|
|
355
|
+
"'unused' covers unused imports, local variables, and "
|
|
356
|
+
"cross-file-unused functions/classes/methods."
|
|
357
|
+
),
|
|
358
|
+
)
|
|
359
|
+
@click.option(
|
|
360
|
+
"--target-list",
|
|
361
|
+
"target_list",
|
|
362
|
+
is_flag=True,
|
|
363
|
+
default=False,
|
|
364
|
+
help=(
|
|
365
|
+
"Treat PATH as a single existing file listing target paths (files "
|
|
366
|
+
"and/or directories), one per line, instead of a semicolon-separated "
|
|
367
|
+
"path list. Blank lines and lines starting with '#' are skipped."
|
|
368
|
+
),
|
|
369
|
+
)
|
|
370
|
+
def main( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
|
|
371
|
+
path: str,
|
|
372
|
+
ext: str,
|
|
373
|
+
output: str,
|
|
374
|
+
output_dir: Path,
|
|
375
|
+
fmt: str,
|
|
376
|
+
workers: int,
|
|
377
|
+
db_path: Path,
|
|
378
|
+
categories: str,
|
|
379
|
+
target_list: bool,
|
|
380
|
+
) -> None:
|
|
381
|
+
"""Scan source files for unreachable, unused, and obsolete code.
|
|
382
|
+
|
|
383
|
+
Author: Ron Webb
|
|
384
|
+
Since: 1.0.0
|
|
385
|
+
"""
|
|
386
|
+
_header = f"{_APP_NAME} v{__version__}"
|
|
387
|
+
_logger.info(_header)
|
|
388
|
+
_console.print(f"[bold]{_header}[/bold]")
|
|
389
|
+
extensions = _parse_extensions(ext) if ext else []
|
|
390
|
+
selected_categories = _parse_categories(categories)
|
|
391
|
+
paths = _parse_paths(path)
|
|
392
|
+
if target_list:
|
|
393
|
+
if len(paths) != 1 or not paths[0].is_file():
|
|
394
|
+
raise click.UsageError(
|
|
395
|
+
"--target-list requires PATH to be a single existing file."
|
|
396
|
+
)
|
|
397
|
+
paths = _read_target_list(paths[0])
|
|
398
|
+
|
|
399
|
+
files = _discover_all_files(paths, extensions)
|
|
400
|
+
if not files:
|
|
401
|
+
_logger.info("No files found in %s", path)
|
|
402
|
+
_console.print("[yellow]No files found.[/yellow]")
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
_console.print(f"[bold]Scanning[/bold] {len(files)} file(s)\u2026")
|
|
406
|
+
|
|
407
|
+
findings, interrupted = _scan_project(files, db_path, workers, selected_categories)
|
|
408
|
+
|
|
409
|
+
if interrupted:
|
|
410
|
+
raise SystemExit(1)
|
|
411
|
+
|
|
412
|
+
stem = Path(output).stem
|
|
413
|
+
written = write_outputs(
|
|
414
|
+
findings,
|
|
415
|
+
output_dir,
|
|
416
|
+
stem,
|
|
417
|
+
fmt,
|
|
418
|
+
paths_scanned=[str(p) for p in paths],
|
|
419
|
+
categories=sorted(selected_categories),
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
_logger.info(
|
|
423
|
+
"Found %s finding(s) -> %s", len(findings), ", ".join(str(w) for w in written)
|
|
424
|
+
)
|
|
425
|
+
_console.print(
|
|
426
|
+
f"[bold green]Found {len(findings)} finding(s)[/bold green] -> "
|
|
427
|
+
+ ", ".join(str(w) for w in written)
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _discover_all_files(paths: list[Path], extensions: list[str]) -> list[Path]:
|
|
432
|
+
"""Discover the de-duplicated set of files to scan across *paths*.
|
|
433
|
+
|
|
434
|
+
Author: Ron Webb
|
|
435
|
+
Since: 1.0.0
|
|
436
|
+
"""
|
|
437
|
+
seen: set[Path] = set()
|
|
438
|
+
files: list[Path] = []
|
|
439
|
+
with _console.status("[bold cyan]Discovering files\u2026", spinner="dots"):
|
|
440
|
+
for target_path in paths:
|
|
441
|
+
ignore = _build_ignore(target_path)
|
|
442
|
+
for found_file in discover_files(target_path, extensions, ignore):
|
|
443
|
+
if found_file not in seen:
|
|
444
|
+
seen.add(found_file)
|
|
445
|
+
files.append(found_file)
|
|
446
|
+
return files
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _scan_project(
|
|
450
|
+
files: list[Path], db_path: Path, workers: int, categories: set[str]
|
|
451
|
+
) -> tuple[list[dict[str, object]], bool]:
|
|
452
|
+
"""Run the full scan pipeline and return ``(findings, interrupted)``.
|
|
453
|
+
|
|
454
|
+
Author: Ron Webb
|
|
455
|
+
Since: 1.0.0
|
|
456
|
+
"""
|
|
457
|
+
session_id = str(uuid.uuid4())
|
|
458
|
+
store = SessionStore(db_path)
|
|
459
|
+
interrupted = False
|
|
460
|
+
findings: list[dict[str, object]] = []
|
|
461
|
+
try:
|
|
462
|
+
_run_concurrent_scan(files, store, session_id, workers, categories)
|
|
463
|
+
findings = store.read_findings(session_id)
|
|
464
|
+
if "unused" in categories:
|
|
465
|
+
name_ignore_patterns = load_name_ignore_patterns(Path(SYMBOL_IGNORE_PATH))
|
|
466
|
+
findings.extend(
|
|
467
|
+
store.compute_unused_symbols(session_id, name_ignore_patterns)
|
|
468
|
+
)
|
|
469
|
+
except KeyboardInterrupt:
|
|
470
|
+
interrupted = True
|
|
471
|
+
_console.print("[yellow]Scan interrupted by user.[/yellow]")
|
|
472
|
+
finally:
|
|
473
|
+
store.delete_session(session_id)
|
|
474
|
+
store.close()
|
|
475
|
+
return findings, interrupted
|
code_prune/config.ini
ADDED
code_prune/config.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""User-configurable overrides for code_prune, read from ``config.ini``.
|
|
2
|
+
|
|
3
|
+
Author: Ron Webb
|
|
4
|
+
Since: 1.0.0
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import configparser
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from . import CONF_DIR
|
|
13
|
+
|
|
14
|
+
_DEFAULT_IGNORE_FILE = ".codepruneignore"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Config:
|
|
18
|
+
"""Reads user-configurable overrides from ``config.ini`` in *conf_dir*.
|
|
19
|
+
|
|
20
|
+
Author: Ron Webb
|
|
21
|
+
Since: 1.0.0
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, conf_dir: str | None = None) -> None:
|
|
25
|
+
"""Load ``config.ini`` from *conf_dir* (defaults to ``CONF_DIR``).
|
|
26
|
+
|
|
27
|
+
Author: Ron Webb
|
|
28
|
+
Since: 1.0.0
|
|
29
|
+
"""
|
|
30
|
+
self._config = configparser.ConfigParser()
|
|
31
|
+
config_path = Path(conf_dir or CONF_DIR) / "config.ini"
|
|
32
|
+
self._config.read(config_path)
|
|
33
|
+
|
|
34
|
+
def get_ignore_file(self) -> str:
|
|
35
|
+
"""Return the configured ``.codepruneignore`` filename override.
|
|
36
|
+
|
|
37
|
+
Falls back to ``.codepruneignore`` when the section/key is absent.
|
|
38
|
+
|
|
39
|
+
Author: Ron Webb
|
|
40
|
+
Since: 1.0.0
|
|
41
|
+
"""
|
|
42
|
+
return self._config.get(
|
|
43
|
+
"override", "ignore-file", fallback=_DEFAULT_IGNORE_FILE
|
|
44
|
+
)
|
code_prune/logging.ini
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[loggers]
|
|
2
|
+
keys=root
|
|
3
|
+
|
|
4
|
+
[handlers]
|
|
5
|
+
keys=consoleHandler,fileHandler
|
|
6
|
+
|
|
7
|
+
[formatters]
|
|
8
|
+
keys=logFormatter,consoleFormatter
|
|
9
|
+
|
|
10
|
+
[logger_root]
|
|
11
|
+
level=INFO
|
|
12
|
+
handlers=consoleHandler,fileHandler
|
|
13
|
+
|
|
14
|
+
[handler_consoleHandler]
|
|
15
|
+
class=StreamHandler
|
|
16
|
+
formatter=consoleFormatter
|
|
17
|
+
args=(sys.stderr,)
|
|
18
|
+
|
|
19
|
+
[handler_fileHandler]
|
|
20
|
+
class=FileHandler
|
|
21
|
+
formatter=logFormatter
|
|
22
|
+
args=('code_prune.log', 'a')
|
|
23
|
+
|
|
24
|
+
[formatter_logFormatter]
|
|
25
|
+
format=%(asctime)s [%(levelname)s] %(name)s - %(message)s
|
|
26
|
+
|
|
27
|
+
[formatter_consoleFormatter]
|
|
28
|
+
format=%(asctime)s - %(name)s - %(levelname)s - %(message)s
|
code_prune/parser.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Tree-sitter language loading and source parsing utilities.
|
|
2
|
+
|
|
3
|
+
Author: Ron Webb
|
|
4
|
+
Since: 1.0.0
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import importlib
|
|
10
|
+
import logging
|
|
11
|
+
from functools import lru_cache
|
|
12
|
+
|
|
13
|
+
from tree_sitter import Language, Parser, Tree
|
|
14
|
+
|
|
15
|
+
_logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# Maps language name -> (module, callable-name) for dynamic import.
|
|
18
|
+
_LANG_MODULES: dict[str, tuple[str, str]] = {
|
|
19
|
+
"Python": ("tree_sitter_python", "language"),
|
|
20
|
+
"JavaScript": ("tree_sitter_javascript", "language"),
|
|
21
|
+
"TypeScript": ("tree_sitter_typescript", "language_typescript"),
|
|
22
|
+
"Java": ("tree_sitter_java", "language"),
|
|
23
|
+
"Gosu": ("tree_sitter_gosu", "language"),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@lru_cache(maxsize=None)
|
|
28
|
+
def get_language(language_name: str) -> Language | None:
|
|
29
|
+
"""Return the tree-sitter :class:`Language` object for *language_name*.
|
|
30
|
+
|
|
31
|
+
Results are cached so the binding is loaded only once per interpreter
|
|
32
|
+
session. Returns ``None`` when the language name is not recognised or
|
|
33
|
+
the corresponding grammar package is unavailable.
|
|
34
|
+
|
|
35
|
+
Author: Ron Webb
|
|
36
|
+
Since: 1.0.0
|
|
37
|
+
"""
|
|
38
|
+
spec = _LANG_MODULES.get(language_name)
|
|
39
|
+
if spec is None:
|
|
40
|
+
return None
|
|
41
|
+
module_name, func_name = spec
|
|
42
|
+
try:
|
|
43
|
+
module = importlib.import_module(module_name)
|
|
44
|
+
return Language(getattr(module, func_name)())
|
|
45
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
46
|
+
_logger.warning("Failed to load tree-sitter language: %s", language_name)
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse(source_bytes: bytes, language_name: str) -> Tree | None:
|
|
51
|
+
"""Parse *source_bytes* with a fresh :class:`Parser` for *language_name*.
|
|
52
|
+
|
|
53
|
+
A new :class:`Parser` instance is created on every call so that no internal
|
|
54
|
+
C extension state carries over between files (tree-sitter 0.26+ requirement).
|
|
55
|
+
Returns ``None`` when the language cannot be loaded or parsing fails.
|
|
56
|
+
|
|
57
|
+
Author: Ron Webb
|
|
58
|
+
Since: 1.0.0
|
|
59
|
+
"""
|
|
60
|
+
language = get_language(language_name)
|
|
61
|
+
if language is None:
|
|
62
|
+
return None
|
|
63
|
+
try:
|
|
64
|
+
return Parser(language).parse(source_bytes)
|
|
65
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
66
|
+
_logger.warning("Failed to parse source as %s", language_name)
|
|
67
|
+
return None
|