codendium 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.
- codendium-1.0.0.dist-info/METADATA +332 -0
- codendium-1.0.0.dist-info/RECORD +45 -0
- codendium-1.0.0.dist-info/WHEEL +5 -0
- codendium-1.0.0.dist-info/entry_points.txt +6 -0
- codendium-1.0.0.dist-info/licenses/LICENSE +201 -0
- codendium-1.0.0.dist-info/top_level.txt +1 -0
- copyright_deposit/__init__.py +15 -0
- copyright_deposit/__main__.py +34 -0
- copyright_deposit/assets/__init__.py +5 -0
- copyright_deposit/assets/fonts/README.md +31 -0
- copyright_deposit/assets/logo.svg +26 -0
- copyright_deposit/cli.py +314 -0
- copyright_deposit/config.py +269 -0
- copyright_deposit/core/__init__.py +1 -0
- copyright_deposit/core/deposit.py +117 -0
- copyright_deposit/core/discovery.py +260 -0
- copyright_deposit/core/encoding.py +136 -0
- copyright_deposit/core/languages.py +190 -0
- copyright_deposit/core/layout.py +349 -0
- copyright_deposit/core/lineranges.py +219 -0
- copyright_deposit/core/manifest.py +282 -0
- copyright_deposit/core/metrics.py +279 -0
- copyright_deposit/core/ordering.py +349 -0
- copyright_deposit/core/pipeline.py +386 -0
- copyright_deposit/core/redaction.py +162 -0
- copyright_deposit/core/render.py +242 -0
- copyright_deposit/core/scanning/__init__.py +61 -0
- copyright_deposit/core/scanning/secrets.py +181 -0
- copyright_deposit/core/scanning/thirdparty.py +190 -0
- copyright_deposit/core/strip/__init__.py +337 -0
- copyright_deposit/core/strip/cfamily_strip.py +235 -0
- copyright_deposit/core/strip/pygments_strip.py +85 -0
- copyright_deposit/core/strip/python_strip.py +131 -0
- copyright_deposit/gui/__init__.py +1 -0
- copyright_deposit/gui/app.py +34 -0
- copyright_deposit/gui/branding.py +83 -0
- copyright_deposit/gui/history.py +192 -0
- copyright_deposit/gui/main_window.py +617 -0
- copyright_deposit/gui/panels/__init__.py +1 -0
- copyright_deposit/gui/panels/estimate.py +166 -0
- copyright_deposit/gui/panels/files.py +635 -0
- copyright_deposit/gui/panels/identification.py +193 -0
- copyright_deposit/gui/panels/options.py +445 -0
- copyright_deposit/gui/panels/preflight.py +260 -0
- copyright_deposit/gui/workers.py +96 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""The Compendium section 721.6 page-selection rule.
|
|
2
|
+
|
|
3
|
+
For a first registration:
|
|
4
|
+
|
|
5
|
+
* 50 pages or fewer -> deposit the entire program, and tell the Office the
|
|
6
|
+
complete code is included.
|
|
7
|
+
* more than 50 pages -> deposit the first 25 and the last 25 pages.
|
|
8
|
+
|
|
9
|
+
Selection operates on the laid-out pages, so the deposit is assembled from
|
|
10
|
+
the same grid as the full copy and page numbers continue to refer to the
|
|
11
|
+
complete program.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
|
|
18
|
+
from ..config import DepositOptions
|
|
19
|
+
|
|
20
|
+
MODE_ENTIRE = "entire"
|
|
21
|
+
MODE_HEAD_TAIL = "head_tail"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class DepositSelection:
|
|
26
|
+
mode: str
|
|
27
|
+
total_pages: int
|
|
28
|
+
page_numbers: list[int] = field(default_factory=list) # 1-based, in order
|
|
29
|
+
omitted: tuple[int, int] | None = None # inclusive range of skipped pages
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def deposited_pages(self) -> int:
|
|
33
|
+
return len(self.page_numbers)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def omitted_count(self) -> int:
|
|
37
|
+
if self.omitted is None:
|
|
38
|
+
return 0
|
|
39
|
+
return self.omitted[1] - self.omitted[0] + 1
|
|
40
|
+
|
|
41
|
+
def separator_notice(self) -> str:
|
|
42
|
+
if self.omitted is None:
|
|
43
|
+
return ""
|
|
44
|
+
first, last = self.omitted
|
|
45
|
+
return (
|
|
46
|
+
f"[ Pages {first}-{last} intentionally omitted. "
|
|
47
|
+
f"Pursuant to Compendium (Third) sec. 721.6, this deposit contains "
|
|
48
|
+
f"the first 25 and the last 25 pages of the program. ]"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def filing_statement(self) -> str:
|
|
52
|
+
"""The sentence to put in the application's note to the Office."""
|
|
53
|
+
if self.mode == MODE_ENTIRE:
|
|
54
|
+
return (
|
|
55
|
+
f"The entire source code for this program is {self.total_pages} "
|
|
56
|
+
"page(s) and is included in full in this deposit."
|
|
57
|
+
)
|
|
58
|
+
return (
|
|
59
|
+
f"The complete program is {self.total_pages} pages. In accordance with "
|
|
60
|
+
"Compendium (Third) sec. 721.6, this deposit contains the first 25 pages "
|
|
61
|
+
f"(pages 1-{self.page_numbers[24] if len(self.page_numbers) > 24 else 25}) "
|
|
62
|
+
f"and the last 25 pages (pages {self.page_numbers[-25]}-{self.total_pages}) "
|
|
63
|
+
"of the source code."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def select_pages(total_pages: int, options: DepositOptions | None = None) -> DepositSelection:
|
|
68
|
+
"""Apply the 50-page rule to a laid-out document."""
|
|
69
|
+
options = options or DepositOptions()
|
|
70
|
+
total = max(0, int(total_pages))
|
|
71
|
+
|
|
72
|
+
if not options.apply_rule or total <= options.threshold:
|
|
73
|
+
return DepositSelection(MODE_ENTIRE, total, list(range(1, total + 1)))
|
|
74
|
+
|
|
75
|
+
head = max(0, options.head_pages)
|
|
76
|
+
tail = max(0, options.tail_pages)
|
|
77
|
+
if head + tail >= total:
|
|
78
|
+
return DepositSelection(MODE_ENTIRE, total, list(range(1, total + 1)))
|
|
79
|
+
|
|
80
|
+
head_pages = list(range(1, head + 1))
|
|
81
|
+
tail_pages = list(range(total - tail + 1, total + 1))
|
|
82
|
+
return DepositSelection(
|
|
83
|
+
mode=MODE_HEAD_TAIL,
|
|
84
|
+
total_pages=total,
|
|
85
|
+
page_numbers=head_pages + tail_pages,
|
|
86
|
+
omitted=(head + 1, total - tail),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def files_entirely_omitted(selection: DepositSelection, file_ranges) -> list[str]:
|
|
91
|
+
"""Files that fall wholly inside the omitted middle.
|
|
92
|
+
|
|
93
|
+
Worth surfacing: this is code the Office will never see, which may
|
|
94
|
+
change how the operator orders the deposit.
|
|
95
|
+
"""
|
|
96
|
+
if selection.omitted is None:
|
|
97
|
+
return []
|
|
98
|
+
low, high = selection.omitted
|
|
99
|
+
return [
|
|
100
|
+
r.rel_path
|
|
101
|
+
for r in file_ranges
|
|
102
|
+
if r.first_page >= low and r.last_page <= high
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def files_partially_shown(selection: DepositSelection, file_ranges) -> list[str]:
|
|
107
|
+
"""Files that straddle the omission boundary."""
|
|
108
|
+
if selection.omitted is None:
|
|
109
|
+
return []
|
|
110
|
+
low, high = selection.omitted
|
|
111
|
+
out = []
|
|
112
|
+
for r in file_ranges:
|
|
113
|
+
inside = r.first_page >= low and r.last_page <= high
|
|
114
|
+
overlaps = r.first_page <= high and r.last_page >= low
|
|
115
|
+
if overlaps and not inside:
|
|
116
|
+
out.append(r.rel_path)
|
|
117
|
+
return out
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Recursive source discovery.
|
|
2
|
+
|
|
3
|
+
Every exclusion is recorded rather than silently applied: a deposit is a
|
|
4
|
+
legal filing, so the operator must be able to see exactly what the tool
|
|
5
|
+
decided to leave out and why.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import fnmatch
|
|
11
|
+
import hashlib
|
|
12
|
+
import os
|
|
13
|
+
import subprocess
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path, PurePosixPath
|
|
16
|
+
|
|
17
|
+
from ..config import DiscoveryOptions
|
|
18
|
+
from . import languages
|
|
19
|
+
|
|
20
|
+
SKIP_EXTENSION = "extension not selected"
|
|
21
|
+
SKIP_IGNORED_DIR = "inside an ignored directory"
|
|
22
|
+
SKIP_IGNORE_GLOB = "matches an ignore pattern"
|
|
23
|
+
SKIP_GITIGNORE = "ignored by .gitignore"
|
|
24
|
+
SKIP_TOO_LARGE = "larger than the size limit"
|
|
25
|
+
SKIP_BINARY = "binary content"
|
|
26
|
+
SKIP_MINIFIED = "appears to be minified or generated"
|
|
27
|
+
SKIP_EMPTY = "empty file"
|
|
28
|
+
SKIP_UNREADABLE = "could not be read"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class DiscoveredFile:
|
|
33
|
+
rel_path: str # posix-style, relative to the source root
|
|
34
|
+
abs_path: str
|
|
35
|
+
size: int
|
|
36
|
+
sha256: str
|
|
37
|
+
language: str
|
|
38
|
+
family: str
|
|
39
|
+
raw_line_count: int
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def name(self) -> str:
|
|
43
|
+
return PurePosixPath(self.rel_path).name
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class SkippedFile:
|
|
48
|
+
rel_path: str
|
|
49
|
+
reason: str
|
|
50
|
+
size: int = 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class DiscoveryResult:
|
|
55
|
+
root: str
|
|
56
|
+
files: list[DiscoveredFile] = field(default_factory=list)
|
|
57
|
+
skipped: list[SkippedFile] = field(default_factory=list)
|
|
58
|
+
warnings: list[str] = field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
def by_path(self) -> dict[str, DiscoveredFile]:
|
|
61
|
+
return {f.rel_path: f for f in self.files}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# .gitignore support
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _GitIgnoreStack:
|
|
70
|
+
"""Nested .gitignore matching.
|
|
71
|
+
|
|
72
|
+
Each .gitignore governs its own directory subtree, so patterns are
|
|
73
|
+
matched against the path relative to the file that declared them.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self) -> None:
|
|
77
|
+
self._specs: list[tuple[str, object]] = []
|
|
78
|
+
|
|
79
|
+
def add_dir(self, dir_rel: str, dir_abs: Path) -> None:
|
|
80
|
+
gi = dir_abs / ".gitignore"
|
|
81
|
+
if not gi.is_file():
|
|
82
|
+
return
|
|
83
|
+
try:
|
|
84
|
+
import pathspec
|
|
85
|
+
|
|
86
|
+
lines = gi.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
87
|
+
spec = pathspec.PathSpec.from_lines("gitwildmatch", lines)
|
|
88
|
+
except Exception:
|
|
89
|
+
return
|
|
90
|
+
self._specs.append((dir_rel, spec))
|
|
91
|
+
|
|
92
|
+
def matches(self, rel_path: str, is_dir: bool = False) -> bool:
|
|
93
|
+
candidate = rel_path + "/" if is_dir else rel_path
|
|
94
|
+
for base, spec in self._specs:
|
|
95
|
+
if base and not candidate.startswith(base + "/"):
|
|
96
|
+
continue
|
|
97
|
+
sub = candidate[len(base) + 1 :] if base else candidate
|
|
98
|
+
if sub and spec.match_file(sub): # type: ignore[attr-defined]
|
|
99
|
+
return True
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# Discovery
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _matches_any_glob(name: str, rel_path: str, patterns: list[str]) -> bool:
|
|
109
|
+
for pattern in patterns:
|
|
110
|
+
if fnmatch.fnmatch(name, pattern) or fnmatch.fnmatch(rel_path, pattern):
|
|
111
|
+
return True
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _inspect(data: bytes) -> tuple[bool, int, float]:
|
|
116
|
+
"""Return (is_binary, line_count, mean_line_length) without decoding."""
|
|
117
|
+
is_binary = b"\x00" in data[:8192]
|
|
118
|
+
line_count = data.count(b"\n") + (1 if data and not data.endswith(b"\n") else 0)
|
|
119
|
+
mean = len(data) / line_count if line_count else float(len(data))
|
|
120
|
+
return is_binary, line_count, mean
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def discover(root: str | Path, options: DiscoveryOptions | None = None) -> DiscoveryResult:
|
|
124
|
+
options = options or DiscoveryOptions()
|
|
125
|
+
root_path = Path(root).resolve()
|
|
126
|
+
result = DiscoveryResult(root=str(root_path))
|
|
127
|
+
|
|
128
|
+
if not root_path.is_dir():
|
|
129
|
+
result.warnings.append(f"Source folder does not exist: {root_path}")
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
ignore_dirs = {d.lower() for d in options.ignore_dirs}
|
|
133
|
+
gitignore = _GitIgnoreStack() if options.respect_gitignore else None
|
|
134
|
+
|
|
135
|
+
for dirpath, dirnames, filenames in os.walk(root_path, followlinks=options.follow_symlinks):
|
|
136
|
+
current = Path(dirpath)
|
|
137
|
+
dir_rel = current.relative_to(root_path).as_posix()
|
|
138
|
+
dir_rel = "" if dir_rel == "." else dir_rel
|
|
139
|
+
|
|
140
|
+
if gitignore is not None:
|
|
141
|
+
gitignore.add_dir(dir_rel, current)
|
|
142
|
+
|
|
143
|
+
# Prune in place so os.walk never descends into excluded trees.
|
|
144
|
+
kept: list[str] = []
|
|
145
|
+
for d in sorted(dirnames):
|
|
146
|
+
child_rel = f"{dir_rel}/{d}" if dir_rel else d
|
|
147
|
+
if d.lower() in ignore_dirs:
|
|
148
|
+
continue
|
|
149
|
+
if gitignore is not None and gitignore.matches(child_rel, is_dir=True):
|
|
150
|
+
continue
|
|
151
|
+
kept.append(d)
|
|
152
|
+
dirnames[:] = kept
|
|
153
|
+
|
|
154
|
+
for name in sorted(filenames):
|
|
155
|
+
rel = f"{dir_rel}/{name}" if dir_rel else name
|
|
156
|
+
abs_path = current / name
|
|
157
|
+
|
|
158
|
+
if not languages.is_supported_extension(name, options.extensions):
|
|
159
|
+
continue # not a source file; not worth reporting as "skipped"
|
|
160
|
+
|
|
161
|
+
if _matches_any_glob(name, rel, options.ignore_globs):
|
|
162
|
+
result.skipped.append(SkippedFile(rel, SKIP_IGNORE_GLOB))
|
|
163
|
+
continue
|
|
164
|
+
if gitignore is not None and gitignore.matches(rel):
|
|
165
|
+
result.skipped.append(SkippedFile(rel, SKIP_GITIGNORE))
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
size = abs_path.stat().st_size
|
|
170
|
+
except OSError:
|
|
171
|
+
result.skipped.append(SkippedFile(rel, SKIP_UNREADABLE))
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
if options.max_file_bytes and size > options.max_file_bytes:
|
|
175
|
+
result.skipped.append(SkippedFile(rel, SKIP_TOO_LARGE, size))
|
|
176
|
+
continue
|
|
177
|
+
if options.skip_empty and size == 0:
|
|
178
|
+
result.skipped.append(SkippedFile(rel, SKIP_EMPTY, size))
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
data = abs_path.read_bytes()
|
|
183
|
+
except OSError:
|
|
184
|
+
result.skipped.append(SkippedFile(rel, SKIP_UNREADABLE, size))
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
is_binary, line_count, mean_len = _inspect(data)
|
|
188
|
+
if is_binary:
|
|
189
|
+
result.skipped.append(SkippedFile(rel, SKIP_BINARY, size))
|
|
190
|
+
continue
|
|
191
|
+
if options.skip_minified and mean_len > 200 and line_count > 0:
|
|
192
|
+
result.skipped.append(SkippedFile(rel, SKIP_MINIFIED, size))
|
|
193
|
+
continue
|
|
194
|
+
if options.skip_empty and not data.strip():
|
|
195
|
+
result.skipped.append(SkippedFile(rel, SKIP_EMPTY, size))
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
lang = languages.detect(name)
|
|
199
|
+
result.files.append(
|
|
200
|
+
DiscoveredFile(
|
|
201
|
+
rel_path=rel,
|
|
202
|
+
abs_path=str(abs_path),
|
|
203
|
+
size=size,
|
|
204
|
+
sha256=hashlib.sha256(data).hexdigest(),
|
|
205
|
+
language=lang.name,
|
|
206
|
+
family=lang.family,
|
|
207
|
+
raw_line_count=line_count,
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
result.files.sort(key=default_sort_key)
|
|
212
|
+
result.skipped.sort(key=lambda s: s.rel_path)
|
|
213
|
+
return result
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def default_sort_key(f: DiscoveredFile) -> tuple:
|
|
217
|
+
"""Deterministic fallback order: shallower directories first, then path.
|
|
218
|
+
|
|
219
|
+
Shallow-first puts entry points and top-level modules near the front,
|
|
220
|
+
which is usually what a reader expects at the beginning of a program.
|
|
221
|
+
"""
|
|
222
|
+
parts = PurePosixPath(f.rel_path).parts
|
|
223
|
+
return (len(parts) - 1, f.rel_path.lower())
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
# Git metadata (prefills the identification block)
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def git_revision(root: str | Path) -> str:
|
|
232
|
+
"""Short commit hash for the source tree, or '' if not a git repo."""
|
|
233
|
+
try:
|
|
234
|
+
out = subprocess.run(
|
|
235
|
+
["git", "-C", str(root), "rev-parse", "--short", "HEAD"],
|
|
236
|
+
capture_output=True,
|
|
237
|
+
text=True,
|
|
238
|
+
timeout=5,
|
|
239
|
+
check=False,
|
|
240
|
+
)
|
|
241
|
+
except (OSError, subprocess.SubprocessError):
|
|
242
|
+
return ""
|
|
243
|
+
if out.returncode != 0:
|
|
244
|
+
return ""
|
|
245
|
+
rev = out.stdout.strip()
|
|
246
|
+
if not rev:
|
|
247
|
+
return ""
|
|
248
|
+
try:
|
|
249
|
+
dirty = subprocess.run(
|
|
250
|
+
["git", "-C", str(root), "status", "--porcelain"],
|
|
251
|
+
capture_output=True,
|
|
252
|
+
text=True,
|
|
253
|
+
timeout=5,
|
|
254
|
+
check=False,
|
|
255
|
+
)
|
|
256
|
+
if dirty.returncode == 0 and dirty.stdout.strip():
|
|
257
|
+
rev += " (uncommitted changes present)"
|
|
258
|
+
except (OSError, subprocess.SubprocessError):
|
|
259
|
+
pass
|
|
260
|
+
return rev
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Reading source files as text, deterministically.
|
|
2
|
+
|
|
3
|
+
A deposit PDF must be reproducible, so decoding must never depend on the
|
|
4
|
+
machine's locale. The order is: BOM -> UTF-8 -> charset_normalizer ->
|
|
5
|
+
cp1252 -> latin-1 (which cannot fail). Whatever happens, we return text.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
_BOMS: tuple[tuple[bytes, str], ...] = (
|
|
14
|
+
(b"\xef\xbb\xbf", "utf-8-sig"),
|
|
15
|
+
(b"\xff\xfe\x00\x00", "utf-32-le"),
|
|
16
|
+
(b"\x00\x00\xfe\xff", "utf-32-be"),
|
|
17
|
+
(b"\xff\xfe", "utf-16-le"),
|
|
18
|
+
(b"\xfe\xff", "utf-16-be"),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Control characters that would corrupt the fixed line grid. Form feed in
|
|
22
|
+
# particular is a real page-break marker in older C sources.
|
|
23
|
+
_CONTROL_TRANSLATE = {
|
|
24
|
+
0x00: None, 0x01: None, 0x02: None, 0x03: None, 0x04: None, 0x05: None,
|
|
25
|
+
0x06: None, 0x07: None, 0x08: None, 0x0B: None, 0x0C: None, 0x0E: None,
|
|
26
|
+
0x0F: None, 0x10: None, 0x11: None, 0x12: None, 0x13: None, 0x14: None,
|
|
27
|
+
0x15: None, 0x16: None, 0x17: None, 0x18: None, 0x19: None, 0x1A: None,
|
|
28
|
+
0x1B: None, 0x1C: None, 0x1D: None, 0x1E: None, 0x1F: None, 0x7F: None,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class DecodedFile:
|
|
34
|
+
text: str
|
|
35
|
+
encoding: str
|
|
36
|
+
had_bom: bool = False
|
|
37
|
+
line_ending: str = "lf" # lf | crlf | cr | mixed
|
|
38
|
+
warnings: list[str] = field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def detect_line_ending(raw: str) -> str:
|
|
42
|
+
crlf = raw.count("\r\n")
|
|
43
|
+
cr = raw.count("\r") - crlf
|
|
44
|
+
lf = raw.count("\n") - crlf
|
|
45
|
+
present = [name for name, count in (("crlf", crlf), ("cr", cr), ("lf", lf)) if count]
|
|
46
|
+
if not present:
|
|
47
|
+
return "lf"
|
|
48
|
+
if len(present) > 1:
|
|
49
|
+
return "mixed"
|
|
50
|
+
return present[0]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_probably_binary(data: bytes) -> bool:
|
|
54
|
+
"""NUL byte in the first block is the classic, reliable sniff."""
|
|
55
|
+
return b"\x00" in data[:8192]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def decode_bytes(data: bytes) -> DecodedFile:
|
|
59
|
+
warnings: list[str] = []
|
|
60
|
+
encoding = ""
|
|
61
|
+
had_bom = False
|
|
62
|
+
text: str | None = None
|
|
63
|
+
|
|
64
|
+
for bom, enc in _BOMS:
|
|
65
|
+
if data.startswith(bom):
|
|
66
|
+
had_bom = True
|
|
67
|
+
encoding = enc
|
|
68
|
+
try:
|
|
69
|
+
text = data.decode(enc)
|
|
70
|
+
except UnicodeDecodeError:
|
|
71
|
+
text = None
|
|
72
|
+
break
|
|
73
|
+
|
|
74
|
+
if text is None and not had_bom:
|
|
75
|
+
try:
|
|
76
|
+
text = data.decode("utf-8")
|
|
77
|
+
encoding = "utf-8"
|
|
78
|
+
except UnicodeDecodeError:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
if text is None:
|
|
82
|
+
try:
|
|
83
|
+
from charset_normalizer import from_bytes
|
|
84
|
+
|
|
85
|
+
best = from_bytes(data).best()
|
|
86
|
+
if best is not None:
|
|
87
|
+
text = str(best)
|
|
88
|
+
encoding = best.encoding
|
|
89
|
+
warnings.append(f"Decoded with detected encoding '{encoding}'.")
|
|
90
|
+
except Exception: # detector is best-effort, never fatal
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
if text is None:
|
|
94
|
+
for enc in ("cp1252", "latin-1"):
|
|
95
|
+
try:
|
|
96
|
+
text = data.decode(enc)
|
|
97
|
+
encoding = enc
|
|
98
|
+
warnings.append(f"Fell back to '{enc}'; characters may be approximate.")
|
|
99
|
+
break
|
|
100
|
+
except UnicodeDecodeError:
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
if text is None: # latin-1 cannot fail, but be explicit
|
|
104
|
+
text = data.decode("latin-1", errors="replace")
|
|
105
|
+
encoding = "latin-1"
|
|
106
|
+
warnings.append("Undecodable bytes were replaced.")
|
|
107
|
+
|
|
108
|
+
line_ending = detect_line_ending(text)
|
|
109
|
+
if line_ending == "mixed":
|
|
110
|
+
warnings.append("Mixed line endings were normalised.")
|
|
111
|
+
|
|
112
|
+
# Normalise newlines, then remove control characters that break the grid.
|
|
113
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
114
|
+
if "\x0c" in text:
|
|
115
|
+
warnings.append("Form-feed page breaks were removed.")
|
|
116
|
+
cleaned = text.translate(_CONTROL_TRANSLATE)
|
|
117
|
+
if cleaned != text:
|
|
118
|
+
text = cleaned
|
|
119
|
+
|
|
120
|
+
return DecodedFile(
|
|
121
|
+
text=text,
|
|
122
|
+
encoding=encoding,
|
|
123
|
+
had_bom=had_bom,
|
|
124
|
+
line_ending=line_ending,
|
|
125
|
+
warnings=warnings,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def read_source(path: str | Path) -> DecodedFile:
|
|
130
|
+
data = Path(path).read_bytes()
|
|
131
|
+
return decode_bytes(data)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def expand_tabs(text: str, width: int) -> str:
|
|
135
|
+
"""Tab expansion must be per-line: str.expandtabs already is."""
|
|
136
|
+
return text.expandtabs(width) if width > 0 else text
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Language identification and comment syntax.
|
|
2
|
+
|
|
3
|
+
Drives three things: which stripper handles a file, how the third-party
|
|
4
|
+
scanner recognises a header comment, and which pygments lexer the
|
|
5
|
+
universal fallback should use.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
# Stripper families.
|
|
14
|
+
FAMILY_PYTHON = "python"
|
|
15
|
+
FAMILY_C = "c" # C-like: // and /* */ with string/char literals
|
|
16
|
+
FAMILY_HASH = "hash" # shell-style: # to end of line, no block comments
|
|
17
|
+
FAMILY_GENERIC = "generic" # anything else -> pygments fallback
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Language:
|
|
22
|
+
name: str
|
|
23
|
+
family: str
|
|
24
|
+
line_comments: tuple[str, ...] = ()
|
|
25
|
+
block_comments: tuple[tuple[str, str], ...] = ()
|
|
26
|
+
pygments_alias: str = ""
|
|
27
|
+
# C++11 raw strings R"tag(...)tag" and friends need special lexing.
|
|
28
|
+
raw_strings: bool = False
|
|
29
|
+
# Languages whose single-quote is a character literal, not a string.
|
|
30
|
+
char_literals: bool = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_C_LINE = ("//",)
|
|
34
|
+
_C_BLOCK = (("/*", "*/"),)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _c_like(name: str, alias: str, *, raw: bool = False, chars: bool = True) -> Language:
|
|
38
|
+
return Language(
|
|
39
|
+
name=name,
|
|
40
|
+
family=FAMILY_C,
|
|
41
|
+
line_comments=_C_LINE,
|
|
42
|
+
block_comments=_C_BLOCK,
|
|
43
|
+
pygments_alias=alias,
|
|
44
|
+
raw_strings=raw,
|
|
45
|
+
char_literals=chars,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _hash_like(name: str, alias: str) -> Language:
|
|
50
|
+
return Language(
|
|
51
|
+
name=name,
|
|
52
|
+
family=FAMILY_HASH,
|
|
53
|
+
line_comments=("#",),
|
|
54
|
+
pygments_alias=alias,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
PYTHON = Language(
|
|
59
|
+
name="Python",
|
|
60
|
+
family=FAMILY_PYTHON,
|
|
61
|
+
line_comments=("#",),
|
|
62
|
+
pygments_alias="python",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Extension -> Language. Lower-cased keys; lookup lower-cases the suffix.
|
|
66
|
+
EXTENSION_MAP: dict[str, Language] = {
|
|
67
|
+
".py": PYTHON,
|
|
68
|
+
".pyi": PYTHON,
|
|
69
|
+
".pyx": PYTHON,
|
|
70
|
+
".pyw": PYTHON,
|
|
71
|
+
".c": _c_like("C", "c"),
|
|
72
|
+
".h": _c_like("C/C++ Header", "c"),
|
|
73
|
+
".cpp": _c_like("C++", "cpp", raw=True),
|
|
74
|
+
".cc": _c_like("C++", "cpp", raw=True),
|
|
75
|
+
".cxx": _c_like("C++", "cpp", raw=True),
|
|
76
|
+
".c++": _c_like("C++", "cpp", raw=True),
|
|
77
|
+
".hpp": _c_like("C++ Header", "cpp", raw=True),
|
|
78
|
+
".hh": _c_like("C++ Header", "cpp", raw=True),
|
|
79
|
+
".hxx": _c_like("C++ Header", "cpp", raw=True),
|
|
80
|
+
".inl": _c_like("C++ Inline", "cpp", raw=True),
|
|
81
|
+
".java": _c_like("Java", "java"),
|
|
82
|
+
".cs": _c_like("C#", "csharp"),
|
|
83
|
+
".js": _c_like("JavaScript", "javascript", chars=False),
|
|
84
|
+
".jsx": _c_like("JavaScript (JSX)", "jsx", chars=False),
|
|
85
|
+
".mjs": _c_like("JavaScript (ESM)", "javascript", chars=False),
|
|
86
|
+
".cjs": _c_like("JavaScript (CJS)", "javascript", chars=False),
|
|
87
|
+
".ts": _c_like("TypeScript", "typescript", chars=False),
|
|
88
|
+
".tsx": _c_like("TypeScript (TSX)", "tsx", chars=False),
|
|
89
|
+
".go": _c_like("Go", "go", raw=True),
|
|
90
|
+
".rs": _c_like("Rust", "rust", raw=True),
|
|
91
|
+
".swift": _c_like("Swift", "swift"),
|
|
92
|
+
".kt": _c_like("Kotlin", "kotlin"),
|
|
93
|
+
".kts": _c_like("Kotlin Script", "kotlin"),
|
|
94
|
+
".m": _c_like("Objective-C", "objective-c"),
|
|
95
|
+
".mm": _c_like("Objective-C++", "objective-c++"),
|
|
96
|
+
".scala": _c_like("Scala", "scala"),
|
|
97
|
+
".dart": _c_like("Dart", "dart"),
|
|
98
|
+
".php": _c_like("PHP", "php"),
|
|
99
|
+
".glsl": _c_like("GLSL", "glsl"),
|
|
100
|
+
".hlsl": _c_like("HLSL", "hlsl"),
|
|
101
|
+
".cu": _c_like("CUDA", "cuda", raw=True),
|
|
102
|
+
".sh": _hash_like("Shell", "bash"),
|
|
103
|
+
".bash": _hash_like("Bash", "bash"),
|
|
104
|
+
".zsh": _hash_like("Zsh", "bash"),
|
|
105
|
+
".rb": _hash_like("Ruby", "ruby"),
|
|
106
|
+
".pl": _hash_like("Perl", "perl"),
|
|
107
|
+
".pm": _hash_like("Perl Module", "perl"),
|
|
108
|
+
".r": _hash_like("R", "r"),
|
|
109
|
+
".jl": _hash_like("Julia", "julia"),
|
|
110
|
+
".yaml": _hash_like("YAML", "yaml"),
|
|
111
|
+
".yml": _hash_like("YAML", "yaml"),
|
|
112
|
+
".toml": _hash_like("TOML", "toml"),
|
|
113
|
+
".cmake": _hash_like("CMake", "cmake"),
|
|
114
|
+
".ps1": Language(
|
|
115
|
+
name="PowerShell",
|
|
116
|
+
family=FAMILY_GENERIC,
|
|
117
|
+
line_comments=("#",),
|
|
118
|
+
block_comments=(("<#", "#>"),),
|
|
119
|
+
pygments_alias="powershell",
|
|
120
|
+
),
|
|
121
|
+
".sql": Language(
|
|
122
|
+
name="SQL",
|
|
123
|
+
family=FAMILY_GENERIC,
|
|
124
|
+
line_comments=("--",),
|
|
125
|
+
block_comments=_C_BLOCK,
|
|
126
|
+
pygments_alias="sql",
|
|
127
|
+
),
|
|
128
|
+
".lua": Language(
|
|
129
|
+
name="Lua",
|
|
130
|
+
family=FAMILY_GENERIC,
|
|
131
|
+
line_comments=("--",),
|
|
132
|
+
block_comments=(("--[[", "]]"),),
|
|
133
|
+
pygments_alias="lua",
|
|
134
|
+
),
|
|
135
|
+
".vb": Language(
|
|
136
|
+
name="Visual Basic",
|
|
137
|
+
family=FAMILY_GENERIC,
|
|
138
|
+
line_comments=("'",),
|
|
139
|
+
pygments_alias="vbnet",
|
|
140
|
+
),
|
|
141
|
+
".f90": Language(
|
|
142
|
+
name="Fortran",
|
|
143
|
+
family=FAMILY_GENERIC,
|
|
144
|
+
line_comments=("!",),
|
|
145
|
+
pygments_alias="fortran",
|
|
146
|
+
),
|
|
147
|
+
".html": Language(
|
|
148
|
+
name="HTML",
|
|
149
|
+
family=FAMILY_GENERIC,
|
|
150
|
+
block_comments=(("<!--", "-->"),),
|
|
151
|
+
pygments_alias="html",
|
|
152
|
+
),
|
|
153
|
+
".css": Language(
|
|
154
|
+
name="CSS",
|
|
155
|
+
family=FAMILY_GENERIC,
|
|
156
|
+
block_comments=_C_BLOCK,
|
|
157
|
+
pygments_alias="css",
|
|
158
|
+
),
|
|
159
|
+
".xml": Language(
|
|
160
|
+
name="XML",
|
|
161
|
+
family=FAMILY_GENERIC,
|
|
162
|
+
block_comments=(("<!--", "-->"),),
|
|
163
|
+
pygments_alias="xml",
|
|
164
|
+
),
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
# Files without a useful suffix.
|
|
168
|
+
FILENAME_MAP: dict[str, Language] = {
|
|
169
|
+
"makefile": _hash_like("Makefile", "make"),
|
|
170
|
+
"dockerfile": _hash_like("Dockerfile", "docker"),
|
|
171
|
+
"cmakelists.txt": _hash_like("CMake", "cmake"),
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
UNKNOWN = Language(name="Unknown", family=FAMILY_GENERIC)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def detect(path: str | Path) -> Language:
|
|
178
|
+
"""Best-effort language for a path. Never raises."""
|
|
179
|
+
p = Path(path)
|
|
180
|
+
by_name = FILENAME_MAP.get(p.name.lower())
|
|
181
|
+
if by_name is not None:
|
|
182
|
+
return by_name
|
|
183
|
+
return EXTENSION_MAP.get(p.suffix.lower(), UNKNOWN)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def is_supported_extension(path: str | Path, extensions: list[str]) -> bool:
|
|
187
|
+
p = Path(path)
|
|
188
|
+
if p.name.lower() in FILENAME_MAP:
|
|
189
|
+
return True
|
|
190
|
+
return p.suffix.lower() in {e.lower() for e in extensions}
|