onepaste 1.3.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.
- codecollector/__init__.py +10 -0
- codecollector/__main__.py +6 -0
- codecollector/cli.py +568 -0
- codecollector/collector.py +235 -0
- codecollector/config.py +151 -0
- codecollector/formatter.py +197 -0
- codecollector/gitignore.py +160 -0
- codecollector/output.py +61 -0
- codecollector/selector.py +115 -0
- codecollector/splitter.py +142 -0
- codecollector/tokens.py +56 -0
- codecollector/uninstall.py +118 -0
- onepaste-1.3.0.dist-info/METADATA +241 -0
- onepaste-1.3.0.dist-info/RECORD +17 -0
- onepaste-1.3.0.dist-info/WHEEL +4 -0
- onepaste-1.3.0.dist-info/entry_points.txt +2 -0
- onepaste-1.3.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""File collector module."""
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Iterable, List, Optional, Set, Tuple
|
|
8
|
+
|
|
9
|
+
from codecollector.config import CollectorConfig
|
|
10
|
+
from codecollector.gitignore import GitignoreMatcher, list_git_visible_files
|
|
11
|
+
|
|
12
|
+
# Own outputs should never be re-collected (prevents runaway growth).
|
|
13
|
+
_OUTPUT_ARTIFACT_RE = re.compile(
|
|
14
|
+
r"^code_collection(?:_\d+)?(?:\.part\d+)?\.(md|txt)$"
|
|
15
|
+
r"|^code_collection(?:_\d+)?\.manifest\.json$"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FileCollector:
|
|
20
|
+
"""Collects code files from directories."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, config: CollectorConfig):
|
|
23
|
+
self.config = config
|
|
24
|
+
self.collected_files: List[Path] = []
|
|
25
|
+
self.skipped_files: List[Tuple[Path, str]] = []
|
|
26
|
+
self._gitignore: Optional[GitignoreMatcher] = None
|
|
27
|
+
self._git_files: Optional[Set[Path]] = None
|
|
28
|
+
|
|
29
|
+
if config.respect_gitignore:
|
|
30
|
+
self._git_files = list_git_visible_files(config.root_path)
|
|
31
|
+
if self._git_files is None:
|
|
32
|
+
# Fallback when git is unavailable
|
|
33
|
+
self._gitignore = GitignoreMatcher(config.root_path)
|
|
34
|
+
self._gitignore.load_all()
|
|
35
|
+
|
|
36
|
+
def _is_output_artifact(self, file_path: Path) -> bool:
|
|
37
|
+
name = file_path.name
|
|
38
|
+
if _OUTPUT_ARTIFACT_RE.match(name):
|
|
39
|
+
return True
|
|
40
|
+
|
|
41
|
+
output_name = Path(self.config.output_file).name
|
|
42
|
+
if name == output_name:
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
stem = Path(self.config.output_file).stem
|
|
46
|
+
suffix = Path(self.config.output_file).suffix
|
|
47
|
+
# matches output_1.md / output.part2.md / output_1.part2.md
|
|
48
|
+
custom_re = re.compile(
|
|
49
|
+
rf"^{re.escape(stem)}(?:_\d+)?(?:\.part\d+)?{re.escape(suffix)}$"
|
|
50
|
+
rf"|^{re.escape(stem)}(?:_\d+)?\.manifest\.json$"
|
|
51
|
+
)
|
|
52
|
+
return bool(custom_re.match(name))
|
|
53
|
+
|
|
54
|
+
def should_collect_file(self, file_path: Path, *, check_git: bool = True) -> bool:
|
|
55
|
+
"""Determine if a file should be collected."""
|
|
56
|
+
if not file_path.is_file():
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
if file_path.is_symlink():
|
|
60
|
+
self.skipped_files.append((file_path, "symlink"))
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
if self._is_output_artifact(file_path):
|
|
64
|
+
self.skipped_files.append((file_path, "collector output"))
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
if check_git:
|
|
68
|
+
if self._git_files is not None:
|
|
69
|
+
if file_path.resolve() not in self._git_files:
|
|
70
|
+
self.skipped_files.append((file_path, "gitignored"))
|
|
71
|
+
return False
|
|
72
|
+
elif self._gitignore and self._gitignore.is_ignored(file_path):
|
|
73
|
+
self.skipped_files.append((file_path, "gitignored"))
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
size_mb = file_path.stat().st_size / (1024 * 1024)
|
|
78
|
+
if size_mb > self.config.max_file_size_mb:
|
|
79
|
+
self.skipped_files.append(
|
|
80
|
+
(file_path, f"too large ({size_mb:.1f}MB > {self.config.max_file_size_mb}MB)")
|
|
81
|
+
)
|
|
82
|
+
return False
|
|
83
|
+
except OSError as e:
|
|
84
|
+
self.skipped_files.append((file_path, f"access error ({e})"))
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
file_name = file_path.name
|
|
88
|
+
rel_posix = self._relative_posix(file_path)
|
|
89
|
+
|
|
90
|
+
if self.config.exclude_patterns and self._matches_any(
|
|
91
|
+
rel_posix, self.config.exclude_patterns
|
|
92
|
+
):
|
|
93
|
+
self.skipped_files.append((file_path, "excluded by pattern"))
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
if self.config.include_patterns:
|
|
97
|
+
# --include overrides the extension whitelist (repomix-style).
|
|
98
|
+
if self._matches_any(rel_posix, self.config.include_patterns):
|
|
99
|
+
return self._is_text_file(file_path)
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
if file_name in self.config.special_files:
|
|
103
|
+
return self._is_text_file(file_path)
|
|
104
|
+
|
|
105
|
+
suffix = file_path.suffix.lower()
|
|
106
|
+
if suffix in self.config.include_extensions:
|
|
107
|
+
return self._is_text_file(file_path)
|
|
108
|
+
|
|
109
|
+
if file_name.startswith("."):
|
|
110
|
+
for ext in self.config.include_extensions:
|
|
111
|
+
if ext.startswith(".") and file_name.endswith(ext.lstrip(".")):
|
|
112
|
+
return self._is_text_file(file_path)
|
|
113
|
+
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
def _relative_posix(self, file_path: Path) -> str:
|
|
117
|
+
try:
|
|
118
|
+
return file_path.relative_to(self.config.root_path).as_posix()
|
|
119
|
+
except ValueError:
|
|
120
|
+
return file_path.name
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def _matches_any(rel_posix: str, patterns: Iterable[str]) -> bool:
|
|
124
|
+
name = Path(rel_posix).name
|
|
125
|
+
for pattern in patterns:
|
|
126
|
+
if pattern.endswith("/"):
|
|
127
|
+
base = pattern.rstrip("/")
|
|
128
|
+
if rel_posix.startswith(base + "/") or f"/{base}/" in f"/{rel_posix}":
|
|
129
|
+
return True
|
|
130
|
+
continue
|
|
131
|
+
if fnmatch.fnmatch(rel_posix, pattern) or fnmatch.fnmatch(name, pattern):
|
|
132
|
+
return True
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
def _is_text_file(self, file_path: Path) -> bool:
|
|
136
|
+
"""Check if a file is a readable text file."""
|
|
137
|
+
try:
|
|
138
|
+
with open(file_path, encoding="utf-8") as f:
|
|
139
|
+
chunk = f.read(1024)
|
|
140
|
+
if "\0" in chunk:
|
|
141
|
+
self.skipped_files.append((file_path, "binary file"))
|
|
142
|
+
return False
|
|
143
|
+
return True
|
|
144
|
+
except UnicodeDecodeError:
|
|
145
|
+
self.skipped_files.append((file_path, "encoding error"))
|
|
146
|
+
return False
|
|
147
|
+
except OSError as e:
|
|
148
|
+
self.skipped_files.append((file_path, f"read error ({e})"))
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
def _path_in_excluded_dir(self, file_path: Path) -> bool:
|
|
152
|
+
try:
|
|
153
|
+
parts = file_path.resolve().relative_to(self.config.root_path.resolve()).parts
|
|
154
|
+
except ValueError:
|
|
155
|
+
parts = file_path.parts
|
|
156
|
+
return any(part in self.config.all_exclude_dirs for part in parts[:-1])
|
|
157
|
+
|
|
158
|
+
def _should_exclude_dir(self, dir_path: Path) -> bool:
|
|
159
|
+
"""Check if a directory should be excluded."""
|
|
160
|
+
for part in dir_path.parts:
|
|
161
|
+
if part in self.config.all_exclude_dirs:
|
|
162
|
+
return True
|
|
163
|
+
|
|
164
|
+
if self._git_files is None and self._gitignore:
|
|
165
|
+
if self._gitignore.is_ignored(dir_path, is_dir=True):
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
dir_name = dir_path.name
|
|
169
|
+
if dir_name.startswith(".") and dir_name != ".git":
|
|
170
|
+
if dir_name not in {".github", ".vscode", ".idea"}:
|
|
171
|
+
return True
|
|
172
|
+
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
def collect_files(self) -> List[Path]:
|
|
176
|
+
"""Collect all matching code files."""
|
|
177
|
+
self.collected_files.clear()
|
|
178
|
+
self.skipped_files.clear()
|
|
179
|
+
|
|
180
|
+
root = self.config.root_path
|
|
181
|
+
|
|
182
|
+
if not root.exists():
|
|
183
|
+
raise FileNotFoundError(f"Directory not found: {root}")
|
|
184
|
+
|
|
185
|
+
if not root.is_dir():
|
|
186
|
+
raise NotADirectoryError(f"Not a directory: {root}")
|
|
187
|
+
|
|
188
|
+
# Filter mode with git: start from git-visible files (stricter, correct).
|
|
189
|
+
if self.config.respect_gitignore and self._git_files is not None:
|
|
190
|
+
self._collect_from_git_files()
|
|
191
|
+
elif self.config.recursive:
|
|
192
|
+
self._collect_by_walk()
|
|
193
|
+
else:
|
|
194
|
+
for item in root.iterdir():
|
|
195
|
+
if item.is_file() and self.should_collect_file(item):
|
|
196
|
+
self.collected_files.append(item)
|
|
197
|
+
|
|
198
|
+
self.collected_files.sort()
|
|
199
|
+
return self.collected_files
|
|
200
|
+
|
|
201
|
+
def _collect_from_git_files(self) -> None:
|
|
202
|
+
assert self._git_files is not None
|
|
203
|
+
root = self.config.root_path.resolve()
|
|
204
|
+
|
|
205
|
+
for file_path in sorted(self._git_files):
|
|
206
|
+
try:
|
|
207
|
+
file_path.relative_to(root)
|
|
208
|
+
except ValueError:
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
if not self.config.recursive:
|
|
212
|
+
if file_path.parent.resolve() != root:
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
if self._path_in_excluded_dir(file_path):
|
|
216
|
+
self.skipped_files.append((file_path, "excluded directory"))
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
# Already known to be git-visible; only apply local filters.
|
|
220
|
+
if self.should_collect_file(file_path, check_git=False):
|
|
221
|
+
self.collected_files.append(file_path)
|
|
222
|
+
|
|
223
|
+
def _collect_by_walk(self) -> None:
|
|
224
|
+
root = self.config.root_path
|
|
225
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
226
|
+
current_dir = Path(dirpath)
|
|
227
|
+
dirnames[:] = [
|
|
228
|
+
d for d in dirnames
|
|
229
|
+
if not self._should_exclude_dir(current_dir / d)
|
|
230
|
+
]
|
|
231
|
+
|
|
232
|
+
for filename in filenames:
|
|
233
|
+
file_path = current_dir / filename
|
|
234
|
+
if self.should_collect_file(file_path):
|
|
235
|
+
self.collected_files.append(file_path)
|
codecollector/config.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Configuration management for CodeCollector."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field, fields
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, List, Optional, Set
|
|
7
|
+
|
|
8
|
+
from codecollector.gitignore import is_inside_git_work_tree
|
|
9
|
+
|
|
10
|
+
CONFIG_DIR = Path.home() / ".config" / "codecollector"
|
|
11
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def ensure_config_dir() -> None:
|
|
15
|
+
"""Ensure configuration directory exists."""
|
|
16
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_SET_FIELDS = {"exclude_dirs", "include_extensions", "special_files", "extra_exclude_dirs"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class CollectorConfig:
|
|
24
|
+
"""Configuration for the code collector."""
|
|
25
|
+
|
|
26
|
+
root_path: Path
|
|
27
|
+
recursive: bool = True
|
|
28
|
+
output_file: str = "code_collection.md"
|
|
29
|
+
output_dir: Optional[Path] = None
|
|
30
|
+
|
|
31
|
+
exclude_dirs: Set[str] = field(default_factory=lambda: {
|
|
32
|
+
"__pycache__", ".git", ".svn", ".hg",
|
|
33
|
+
"node_modules", "venv", ".venv", "env", ".env",
|
|
34
|
+
".idea", ".vscode", "build", "dist", "target",
|
|
35
|
+
".eggs", "*.egg-info", ".tox", ".mypy_cache",
|
|
36
|
+
".pytest_cache", "__pypackages__", ".next",
|
|
37
|
+
".nuxt", ".output", "coverage", ".coverage",
|
|
38
|
+
"tmp", "temp", "logs",
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
extra_exclude_dirs: Set[str] = field(default_factory=set)
|
|
42
|
+
|
|
43
|
+
include_extensions: Set[str] = field(default_factory=lambda: {
|
|
44
|
+
".py", ".js", ".ts", ".jsx", ".tsx", ".vue", ".svelte",
|
|
45
|
+
".html", ".css", ".scss", ".less",
|
|
46
|
+
".java", ".kt", ".go", ".rs", ".rb", ".php", ".swift",
|
|
47
|
+
".c", ".cpp", ".h", ".hpp",
|
|
48
|
+
".sh", ".bash", ".zsh",
|
|
49
|
+
".yml", ".yaml", ".json", ".xml", ".toml",
|
|
50
|
+
".cfg", ".ini", ".conf", ".env",
|
|
51
|
+
".md", ".txt", ".rst",
|
|
52
|
+
".sql", ".graphql",
|
|
53
|
+
"Dockerfile", ".dockerignore",
|
|
54
|
+
"Makefile", ".gitignore",
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
special_files: Set[str] = field(default_factory=lambda: {
|
|
58
|
+
"Dockerfile", "Makefile", "Vagrantfile",
|
|
59
|
+
".gitignore", ".dockerignore", ".env",
|
|
60
|
+
".editorconfig", ".prettierrc",
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
max_file_size_mb: float = 5.0
|
|
64
|
+
max_output_size_mb: float = 2.0
|
|
65
|
+
respect_gitignore: bool = False
|
|
66
|
+
auto_increment_output: bool = True
|
|
67
|
+
write_manifest: bool = True
|
|
68
|
+
show_progress: bool = True
|
|
69
|
+
show_skipped: bool = True
|
|
70
|
+
|
|
71
|
+
include_patterns: List[str] = field(default_factory=list)
|
|
72
|
+
exclude_patterns: List[str] = field(default_factory=list)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def all_exclude_dirs(self) -> Set[str]:
|
|
76
|
+
return self.exclude_dirs | self.extra_exclude_dirs
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def _normalize_dict(cls, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
80
|
+
normalized = {}
|
|
81
|
+
valid_names = {f.name for f in fields(cls)}
|
|
82
|
+
|
|
83
|
+
for key, value in data.items():
|
|
84
|
+
if key not in valid_names:
|
|
85
|
+
continue
|
|
86
|
+
if key in _SET_FIELDS and isinstance(value, list):
|
|
87
|
+
normalized[key] = set(value)
|
|
88
|
+
elif key in ("root_path", "output_dir") and value is not None:
|
|
89
|
+
normalized[key] = Path(value)
|
|
90
|
+
else:
|
|
91
|
+
normalized[key] = value
|
|
92
|
+
|
|
93
|
+
return normalized
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def load_from_file(cls, filepath: str) -> Dict[str, Any]:
|
|
97
|
+
"""Load configuration dict from a JSON file."""
|
|
98
|
+
with open(filepath, encoding="utf-8") as f:
|
|
99
|
+
data = json.load(f)
|
|
100
|
+
return cls._normalize_dict(data)
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def from_sources(
|
|
104
|
+
cls,
|
|
105
|
+
root_path: Path,
|
|
106
|
+
config_file: Optional[str] = None,
|
|
107
|
+
overrides: Optional[Dict[str, Any]] = None,
|
|
108
|
+
) -> "CollectorConfig":
|
|
109
|
+
"""Build config by merging global config, file config, and CLI overrides.
|
|
110
|
+
|
|
111
|
+
When no source specifies `respect_gitignore`, it defaults to on inside
|
|
112
|
+
git work trees and off elsewhere.
|
|
113
|
+
"""
|
|
114
|
+
merged: Dict[str, Any] = {}
|
|
115
|
+
|
|
116
|
+
if CONFIG_FILE.exists():
|
|
117
|
+
merged.update(cls.load_from_file(str(CONFIG_FILE)))
|
|
118
|
+
|
|
119
|
+
if config_file:
|
|
120
|
+
merged.update(cls.load_from_file(config_file))
|
|
121
|
+
|
|
122
|
+
merged["root_path"] = root_path
|
|
123
|
+
|
|
124
|
+
if overrides:
|
|
125
|
+
for key, value in overrides.items():
|
|
126
|
+
if value is not None:
|
|
127
|
+
if key in _SET_FIELDS and isinstance(value, list):
|
|
128
|
+
merged[key] = set(value)
|
|
129
|
+
else:
|
|
130
|
+
merged[key] = value
|
|
131
|
+
|
|
132
|
+
if "respect_gitignore" not in merged:
|
|
133
|
+
merged["respect_gitignore"] = is_inside_git_work_tree(root_path)
|
|
134
|
+
|
|
135
|
+
return cls(**cls._normalize_dict(merged))
|
|
136
|
+
|
|
137
|
+
def save_to_file(self, filepath: str) -> None:
|
|
138
|
+
"""Save configuration to a JSON file."""
|
|
139
|
+
config_dict = {
|
|
140
|
+
"exclude_dirs": sorted(self.exclude_dirs),
|
|
141
|
+
"extra_exclude_dirs": sorted(self.extra_exclude_dirs),
|
|
142
|
+
"include_extensions": sorted(self.include_extensions),
|
|
143
|
+
"max_file_size_mb": self.max_file_size_mb,
|
|
144
|
+
"max_output_size_mb": self.max_output_size_mb,
|
|
145
|
+
"auto_increment_output": self.auto_increment_output,
|
|
146
|
+
"write_manifest": self.write_manifest,
|
|
147
|
+
"include_patterns": sorted(self.include_patterns),
|
|
148
|
+
"exclude_patterns": sorted(self.exclude_patterns),
|
|
149
|
+
}
|
|
150
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
151
|
+
json.dump(config_dict, f, indent=2)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Output formatter module."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Tuple
|
|
7
|
+
|
|
8
|
+
from codecollector.config import CollectorConfig
|
|
9
|
+
from codecollector.tokens import count_tokens, method_label
|
|
10
|
+
|
|
11
|
+
_FENCE_RUN_RE = re.compile(r"`{3,}")
|
|
12
|
+
_TOP_TOKEN_FILES = 10
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OutputFormatter:
|
|
16
|
+
"""Formats collected code files into LLM-ready Markdown."""
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def fence_marker(content: str) -> str:
|
|
20
|
+
"""Return a backtick fence long enough to safely wrap the content."""
|
|
21
|
+
longest = max(
|
|
22
|
+
(len(m.group(0)) for m in _FENCE_RUN_RE.finditer(content)),
|
|
23
|
+
default=0,
|
|
24
|
+
)
|
|
25
|
+
return "`" * max(3, longest + 1)
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def format_file_content(file_path: Path, relative_to: Path) -> str:
|
|
29
|
+
"""Format a single file as detailed Markdown."""
|
|
30
|
+
relative_path = file_path.relative_to(relative_to)
|
|
31
|
+
lang = file_path.suffix.lstrip(".") or "text"
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
with open(file_path, encoding="utf-8") as f:
|
|
35
|
+
content = f.read()
|
|
36
|
+
|
|
37
|
+
line_count = content.count("\n") + 1
|
|
38
|
+
file_size = file_path.stat().st_size
|
|
39
|
+
tokens = count_tokens(content)
|
|
40
|
+
fence = OutputFormatter.fence_marker(content)
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
f"### `{relative_path}`\n\n"
|
|
44
|
+
f"**Lines:** {line_count} | **Size:** {file_size:,} bytes "
|
|
45
|
+
f"| **Tokens:** {tokens:,}\n\n"
|
|
46
|
+
f"{fence}{lang}\n"
|
|
47
|
+
f"{content}\n"
|
|
48
|
+
f"{fence}\n\n"
|
|
49
|
+
)
|
|
50
|
+
except Exception as e:
|
|
51
|
+
return f"### `{relative_path}`\n\n**Read Error:** {e}\n\n"
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def format_summary(
|
|
55
|
+
collected_files: List[Path],
|
|
56
|
+
skipped_files: List[Tuple[Path, str]],
|
|
57
|
+
config: CollectorConfig,
|
|
58
|
+
) -> str:
|
|
59
|
+
"""Format the collection summary as Markdown."""
|
|
60
|
+
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
61
|
+
|
|
62
|
+
total_lines = 0
|
|
63
|
+
total_size = 0
|
|
64
|
+
total_tokens = 0
|
|
65
|
+
file_tokens: List[Tuple[Path, int]] = []
|
|
66
|
+
for fp in collected_files:
|
|
67
|
+
try:
|
|
68
|
+
with open(fp, encoding="utf-8") as f:
|
|
69
|
+
content = f.read()
|
|
70
|
+
total_lines += content.count("\n") + 1
|
|
71
|
+
total_size += fp.stat().st_size
|
|
72
|
+
tokens = count_tokens(content)
|
|
73
|
+
total_tokens += tokens
|
|
74
|
+
file_tokens.append((fp, tokens))
|
|
75
|
+
except Exception:
|
|
76
|
+
file_tokens.append((fp, 0))
|
|
77
|
+
|
|
78
|
+
summary = [
|
|
79
|
+
"# CodeCollector - Collection Summary",
|
|
80
|
+
"",
|
|
81
|
+
f"**Generated:** {now} ",
|
|
82
|
+
f"**Root:** `{config.root_path.absolute()}` ",
|
|
83
|
+
f"**Mode:** {'Recursive' if config.recursive else 'Non-recursive'} ",
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
if config.respect_gitignore:
|
|
87
|
+
summary.append("**Filter:** `.gitignore` enabled ")
|
|
88
|
+
|
|
89
|
+
summary.extend([
|
|
90
|
+
"",
|
|
91
|
+
f"**Files collected:** {len(collected_files)} ",
|
|
92
|
+
f"**Total lines:** {total_lines:,} ",
|
|
93
|
+
f"**Total size:** {total_size / 1024:.1f} KB ",
|
|
94
|
+
f"**Total tokens:** {total_tokens:,} *({method_label()})* ",
|
|
95
|
+
])
|
|
96
|
+
|
|
97
|
+
if collected_files:
|
|
98
|
+
summary.extend([
|
|
99
|
+
"",
|
|
100
|
+
"## Top Files by Tokens",
|
|
101
|
+
"",
|
|
102
|
+
"| File | Tokens |",
|
|
103
|
+
"| --- | ---: |",
|
|
104
|
+
])
|
|
105
|
+
top_files = sorted(file_tokens, key=lambda x: x[1], reverse=True)
|
|
106
|
+
for fp, tokens in top_files[:_TOP_TOKEN_FILES]:
|
|
107
|
+
try:
|
|
108
|
+
rel_path = fp.relative_to(config.root_path).as_posix()
|
|
109
|
+
except ValueError:
|
|
110
|
+
rel_path = str(fp)
|
|
111
|
+
summary.append(f"| `{rel_path}` | {tokens:,} |")
|
|
112
|
+
|
|
113
|
+
if skipped_files:
|
|
114
|
+
summary.append("")
|
|
115
|
+
summary.append(f"**Files skipped:** {len(skipped_files)}")
|
|
116
|
+
summary.append("")
|
|
117
|
+
for fp, reason in skipped_files[:5]:
|
|
118
|
+
try:
|
|
119
|
+
rel_path = fp.relative_to(config.root_path)
|
|
120
|
+
summary.append(f"- `{rel_path}`: {reason}")
|
|
121
|
+
except ValueError:
|
|
122
|
+
summary.append(f"- `{fp}`: {reason}")
|
|
123
|
+
if len(skipped_files) > 5:
|
|
124
|
+
summary.append(f"- ... and {len(skipped_files) - 5} more")
|
|
125
|
+
|
|
126
|
+
summary.extend([
|
|
127
|
+
"",
|
|
128
|
+
"## Directory Tree",
|
|
129
|
+
"",
|
|
130
|
+
"```",
|
|
131
|
+
*OutputFormatter._build_directory_tree(collected_files, config.root_path),
|
|
132
|
+
"```",
|
|
133
|
+
"",
|
|
134
|
+
"## File List",
|
|
135
|
+
"",
|
|
136
|
+
])
|
|
137
|
+
|
|
138
|
+
files_by_ext: dict = {}
|
|
139
|
+
for fp in collected_files:
|
|
140
|
+
ext = fp.suffix or "no_ext"
|
|
141
|
+
files_by_ext.setdefault(ext, [])
|
|
142
|
+
try:
|
|
143
|
+
files_by_ext[ext].append(fp.relative_to(config.root_path))
|
|
144
|
+
except ValueError:
|
|
145
|
+
files_by_ext[ext].append(fp)
|
|
146
|
+
|
|
147
|
+
for ext, files in sorted(files_by_ext.items()):
|
|
148
|
+
summary.append(f"**{ext}** ({len(files)} files)")
|
|
149
|
+
for f in files[:5]:
|
|
150
|
+
summary.append(f"- `{f}`")
|
|
151
|
+
if len(files) > 5:
|
|
152
|
+
summary.append(f"- ... and {len(files) - 5} more")
|
|
153
|
+
summary.append("")
|
|
154
|
+
|
|
155
|
+
summary.extend([
|
|
156
|
+
"---",
|
|
157
|
+
"",
|
|
158
|
+
"# Collected Code Content",
|
|
159
|
+
"",
|
|
160
|
+
])
|
|
161
|
+
|
|
162
|
+
return "\n".join(summary)
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def _build_directory_tree(collected_files: List[Path], root: Path) -> List[str]:
|
|
166
|
+
"""Build a simple directory tree from collected files."""
|
|
167
|
+
tree: dict = {}
|
|
168
|
+
for fp in collected_files:
|
|
169
|
+
try:
|
|
170
|
+
rel = fp.relative_to(root)
|
|
171
|
+
except ValueError:
|
|
172
|
+
rel = fp
|
|
173
|
+
parts = rel.parts
|
|
174
|
+
node = tree
|
|
175
|
+
for part in parts[:-1]:
|
|
176
|
+
node = node.setdefault(part, {})
|
|
177
|
+
if parts:
|
|
178
|
+
node.setdefault(parts[-1], None)
|
|
179
|
+
|
|
180
|
+
lines: List[str] = []
|
|
181
|
+
|
|
182
|
+
def walk(node: dict, prefix: str = "") -> None:
|
|
183
|
+
items = sorted(node.items(), key=lambda x: (x[1] is None, x[0]))
|
|
184
|
+
for i, (name, children) in enumerate(items):
|
|
185
|
+
is_last = i == len(items) - 1
|
|
186
|
+
connector = "└── " if is_last else "├── "
|
|
187
|
+
lines.append(f"{prefix}{connector}{name}")
|
|
188
|
+
if isinstance(children, dict) and children:
|
|
189
|
+
extension = " " if is_last else "│ "
|
|
190
|
+
walk(children, prefix + extension)
|
|
191
|
+
|
|
192
|
+
if tree:
|
|
193
|
+
walk(tree)
|
|
194
|
+
else:
|
|
195
|
+
lines.append("(empty)")
|
|
196
|
+
|
|
197
|
+
return lines
|