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.
Files changed (45) hide show
  1. codendium-1.0.0.dist-info/METADATA +332 -0
  2. codendium-1.0.0.dist-info/RECORD +45 -0
  3. codendium-1.0.0.dist-info/WHEEL +5 -0
  4. codendium-1.0.0.dist-info/entry_points.txt +6 -0
  5. codendium-1.0.0.dist-info/licenses/LICENSE +201 -0
  6. codendium-1.0.0.dist-info/top_level.txt +1 -0
  7. copyright_deposit/__init__.py +15 -0
  8. copyright_deposit/__main__.py +34 -0
  9. copyright_deposit/assets/__init__.py +5 -0
  10. copyright_deposit/assets/fonts/README.md +31 -0
  11. copyright_deposit/assets/logo.svg +26 -0
  12. copyright_deposit/cli.py +314 -0
  13. copyright_deposit/config.py +269 -0
  14. copyright_deposit/core/__init__.py +1 -0
  15. copyright_deposit/core/deposit.py +117 -0
  16. copyright_deposit/core/discovery.py +260 -0
  17. copyright_deposit/core/encoding.py +136 -0
  18. copyright_deposit/core/languages.py +190 -0
  19. copyright_deposit/core/layout.py +349 -0
  20. copyright_deposit/core/lineranges.py +219 -0
  21. copyright_deposit/core/manifest.py +282 -0
  22. copyright_deposit/core/metrics.py +279 -0
  23. copyright_deposit/core/ordering.py +349 -0
  24. copyright_deposit/core/pipeline.py +386 -0
  25. copyright_deposit/core/redaction.py +162 -0
  26. copyright_deposit/core/render.py +242 -0
  27. copyright_deposit/core/scanning/__init__.py +61 -0
  28. copyright_deposit/core/scanning/secrets.py +181 -0
  29. copyright_deposit/core/scanning/thirdparty.py +190 -0
  30. copyright_deposit/core/strip/__init__.py +337 -0
  31. copyright_deposit/core/strip/cfamily_strip.py +235 -0
  32. copyright_deposit/core/strip/pygments_strip.py +85 -0
  33. copyright_deposit/core/strip/python_strip.py +131 -0
  34. copyright_deposit/gui/__init__.py +1 -0
  35. copyright_deposit/gui/app.py +34 -0
  36. copyright_deposit/gui/branding.py +83 -0
  37. copyright_deposit/gui/history.py +192 -0
  38. copyright_deposit/gui/main_window.py +617 -0
  39. copyright_deposit/gui/panels/__init__.py +1 -0
  40. copyright_deposit/gui/panels/estimate.py +166 -0
  41. copyright_deposit/gui/panels/files.py +635 -0
  42. copyright_deposit/gui/panels/identification.py +193 -0
  43. copyright_deposit/gui/panels/options.py +445 -0
  44. copyright_deposit/gui/panels/preflight.py +260 -0
  45. 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}