cli-usereq 0.68.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.
- cli_usereq-0.68.0.dist-info/METADATA +1134 -0
- cli_usereq-0.68.0.dist-info/RECORD +37 -0
- cli_usereq-0.68.0.dist-info/WHEEL +5 -0
- cli_usereq-0.68.0.dist-info/entry_points.txt +4 -0
- cli_usereq-0.68.0.dist-info/licenses/LICENSE +674 -0
- cli_usereq-0.68.0.dist-info/top_level.txt +1 -0
- usereq/__init__.py +62 -0
- usereq/__main__.py +17 -0
- usereq/cli.py +4963 -0
- usereq/compress.py +393 -0
- usereq/compress_files.py +134 -0
- usereq/doxygen_parser.py +172 -0
- usereq/find_constructs.py +394 -0
- usereq/generate_markdown.py +158 -0
- usereq/resources/docs/Document_Source_Code_in_Doxygen_Style.md +130 -0
- usereq/resources/docs/HDT_Test_Authoring_Guide.md +318 -0
- usereq/resources/docs/Requirements_Template.md +78 -0
- usereq/resources/prompts/analyze.md +191 -0
- usereq/resources/prompts/change.md +279 -0
- usereq/resources/prompts/check.md +190 -0
- usereq/resources/prompts/cover.md +271 -0
- usereq/resources/prompts/create.md +134 -0
- usereq/resources/prompts/fix.md +273 -0
- usereq/resources/prompts/flowchart.md +272 -0
- usereq/resources/prompts/implement.md +194 -0
- usereq/resources/prompts/new.md +278 -0
- usereq/resources/prompts/readme.md +234 -0
- usereq/resources/prompts/recreate.md +265 -0
- usereq/resources/prompts/refactor.md +265 -0
- usereq/resources/prompts/references.md +131 -0
- usereq/resources/prompts/renumber.md +150 -0
- usereq/resources/prompts/workflow.md +254 -0
- usereq/resources/prompts/write.md +129 -0
- usereq/resources/prompts-version.txt +1 -0
- usereq/source_analyzer.py +2280 -0
- usereq/static_check.py +812 -0
- usereq/token_counter.py +123 -0
usereq/compress.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""!
|
|
3
|
+
@file compress.py
|
|
4
|
+
@brief Source code compressor for LLM context optimization.
|
|
5
|
+
@details Parses a source file and removes all comments (inline, single-line, multi-line), blank lines, trailing whitespace, and redundant spacing while preserving language semantics (e.g. Python indentation). Leverages LanguageSpec from source_analyzer to correctly identify comment syntax for each supported language.
|
|
6
|
+
@author GitHub Copilot
|
|
7
|
+
@version 0.0.70
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from .source_analyzer import build_language_specs
|
|
14
|
+
|
|
15
|
+
# Extension-to-language map (mirrors generate_markdown.py)
|
|
16
|
+
EXT_LANG_MAP = {
|
|
17
|
+
".py": "python", ".js": "javascript", ".mjs": "javascript", ".ts": "typescript",
|
|
18
|
+
".rs": "rust", ".go": "go", ".c": "c", ".cpp": "cpp",
|
|
19
|
+
".h": "c", ".hpp": "cpp", ".java": "java", ".rb": "ruby",
|
|
20
|
+
".php": "php", ".swift": "swift", ".kt": "kotlin",
|
|
21
|
+
".scala": "scala", ".lua": "lua", ".sh": "shell",
|
|
22
|
+
".pl": "perl", ".hs": "haskell", ".zig": "zig",
|
|
23
|
+
".ex": "elixir", ".cs": "csharp",
|
|
24
|
+
}
|
|
25
|
+
"""! @brief Extension-to-language normalization map for compression input."""
|
|
26
|
+
|
|
27
|
+
# Languages where indentation is semantically significant
|
|
28
|
+
INDENT_SIGNIFICANT = {"python", "haskell", "elixir"}
|
|
29
|
+
"""! @brief Languages requiring indentation-preserving compression behavior."""
|
|
30
|
+
|
|
31
|
+
_specs_cache = None
|
|
32
|
+
"""! @brief Cached language specification dictionary initialized lazily."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _get_specs():
|
|
36
|
+
"""! @brief Return cached language specifications, initializing once.
|
|
37
|
+
@return Dictionary mapping normalized language keys to language specs.
|
|
38
|
+
@details If cache is empty, calls `build_language_specs()` to populate it.
|
|
39
|
+
"""
|
|
40
|
+
global _specs_cache
|
|
41
|
+
if _specs_cache is None:
|
|
42
|
+
_specs_cache = build_language_specs()
|
|
43
|
+
return _specs_cache
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def detect_language(filepath: str) -> str | None:
|
|
47
|
+
"""! @brief Detect language key from file extension.
|
|
48
|
+
@param filepath Source file path.
|
|
49
|
+
@return Normalized language key, or None when extension is unsupported.
|
|
50
|
+
@details Uses `EXT_LANG_MAP` for lookup. Case-insensitive extension matching.
|
|
51
|
+
"""
|
|
52
|
+
_, ext = os.path.splitext(filepath)
|
|
53
|
+
return EXT_LANG_MAP.get(ext.lower())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _is_in_string(line: str, pos: int, string_delimiters: tuple) -> bool:
|
|
57
|
+
"""! @brief Check if position `pos` in `line` is inside a string literal.
|
|
58
|
+
@param line The code line string.
|
|
59
|
+
@param pos The character index to check.
|
|
60
|
+
@param string_delimiters Tuple of string delimiter characters/sequences.
|
|
61
|
+
@return True if `pos` is inside a string, False otherwise.
|
|
62
|
+
@details iterates through the line handling escaped delimiters.
|
|
63
|
+
"""
|
|
64
|
+
in_string = None
|
|
65
|
+
i = 0
|
|
66
|
+
sorted_delims = sorted(string_delimiters, key=len, reverse=True)
|
|
67
|
+
while i < pos:
|
|
68
|
+
if in_string is not None:
|
|
69
|
+
if line[i:].startswith(in_string):
|
|
70
|
+
# Check for escaped delimiter (single-char only)
|
|
71
|
+
if len(in_string) == 1 and i > 0 and line[i - 1] == '\\':
|
|
72
|
+
# Count consecutive backslashes
|
|
73
|
+
bs = 0
|
|
74
|
+
j = i - 1
|
|
75
|
+
while j >= 0 and line[j] == '\\':
|
|
76
|
+
bs += 1
|
|
77
|
+
j -= 1
|
|
78
|
+
if bs % 2 == 1:
|
|
79
|
+
i += 1
|
|
80
|
+
continue
|
|
81
|
+
i += len(in_string)
|
|
82
|
+
in_string = None
|
|
83
|
+
continue
|
|
84
|
+
i += 1
|
|
85
|
+
else:
|
|
86
|
+
matched = False
|
|
87
|
+
for delim in sorted_delims:
|
|
88
|
+
if line[i:].startswith(delim):
|
|
89
|
+
in_string = delim
|
|
90
|
+
i += len(delim)
|
|
91
|
+
matched = True
|
|
92
|
+
break
|
|
93
|
+
if not matched:
|
|
94
|
+
i += 1
|
|
95
|
+
return in_string is not None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _remove_inline_comment(line: str, single_comment: str,
|
|
99
|
+
string_delimiters: tuple) -> str:
|
|
100
|
+
"""! @brief Remove trailing single-line comment from a code line.
|
|
101
|
+
@param line The code line string.
|
|
102
|
+
@param single_comment The single-line comment marker (e.g., "//", "#").
|
|
103
|
+
@param string_delimiters Tuple of string delimiters to respect.
|
|
104
|
+
@return The line content before the comment starts.
|
|
105
|
+
@details Respects string literals; does not remove comments inside strings.
|
|
106
|
+
"""
|
|
107
|
+
if not single_comment:
|
|
108
|
+
return line
|
|
109
|
+
sc_len = len(single_comment)
|
|
110
|
+
i = 0
|
|
111
|
+
sorted_delims = sorted(string_delimiters, key=len, reverse=True)
|
|
112
|
+
in_string = None
|
|
113
|
+
while i < len(line):
|
|
114
|
+
if in_string is not None:
|
|
115
|
+
if line[i:].startswith(in_string):
|
|
116
|
+
if len(in_string) == 1 and i > 0 and line[i - 1] == '\\':
|
|
117
|
+
bs = 0
|
|
118
|
+
j = i - 1
|
|
119
|
+
while j >= 0 and line[j] == '\\':
|
|
120
|
+
bs += 1
|
|
121
|
+
j -= 1
|
|
122
|
+
if bs % 2 == 1:
|
|
123
|
+
i += 1
|
|
124
|
+
continue
|
|
125
|
+
i += len(in_string)
|
|
126
|
+
in_string = None
|
|
127
|
+
continue
|
|
128
|
+
i += 1
|
|
129
|
+
else:
|
|
130
|
+
if line[i:i + sc_len] == single_comment:
|
|
131
|
+
return line[:i]
|
|
132
|
+
for delim in sorted_delims:
|
|
133
|
+
if line[i:].startswith(delim):
|
|
134
|
+
in_string = delim
|
|
135
|
+
i += len(delim)
|
|
136
|
+
break
|
|
137
|
+
else:
|
|
138
|
+
i += 1
|
|
139
|
+
return line
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _is_python_docstring_line(line: str) -> bool:
|
|
143
|
+
"""!
|
|
144
|
+
@brief Check if a line is a standalone Python docstring (triple-quote only).
|
|
145
|
+
@param line The code line string.
|
|
146
|
+
@return True if the line appears to be a standalone triple-quoted string.
|
|
147
|
+
@details Implements the _is_python_docstring_line function behavior with deterministic control flow.
|
|
148
|
+
"""
|
|
149
|
+
stripped = line.strip()
|
|
150
|
+
for q in ('"""', "'''"):
|
|
151
|
+
if stripped.startswith(q) and stripped.endswith(q) and len(stripped) >= 6:
|
|
152
|
+
return True
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _format_result(entries: list[tuple[int, str]],
|
|
157
|
+
include_line_numbers: bool) -> str:
|
|
158
|
+
"""!
|
|
159
|
+
@brief Format compressed entries, optionally prefixing original line numbers.
|
|
160
|
+
@param entries List of tuples (line_number, text).
|
|
161
|
+
@param include_line_numbers Boolean flag to enable line prefixes.
|
|
162
|
+
@return Formatted string.
|
|
163
|
+
@details Implements the _format_result function behavior with deterministic control flow.
|
|
164
|
+
"""
|
|
165
|
+
if not include_line_numbers:
|
|
166
|
+
return '\n'.join(text for _, text in entries)
|
|
167
|
+
return '\n'.join(f"{lineno}: {text}" for lineno, text in entries)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def compress_source(source: str, language: str,
|
|
171
|
+
include_line_numbers: bool = True) -> str:
|
|
172
|
+
"""! @brief Compress source code by removing comments, blank lines, and extra whitespace.
|
|
173
|
+
@param source The source code string.
|
|
174
|
+
@param language Language identifier (e.g. "python", "javascript").
|
|
175
|
+
@param include_line_numbers If True (default), prefix each line with <n>: format.
|
|
176
|
+
@return Compressed source code string.
|
|
177
|
+
@throws ValueError If language is unsupported.
|
|
178
|
+
@details Preserves indentation for indent-significant languages (Python, Haskell, Elixir).
|
|
179
|
+
"""
|
|
180
|
+
specs = _get_specs()
|
|
181
|
+
lang_key = language.lower().strip().lstrip(".")
|
|
182
|
+
if lang_key not in specs:
|
|
183
|
+
raise ValueError(f"Unsupported language: {language}")
|
|
184
|
+
|
|
185
|
+
spec = specs[lang_key]
|
|
186
|
+
preserve_indent = lang_key in INDENT_SIGNIFICANT
|
|
187
|
+
lines = source.split('\n')
|
|
188
|
+
result = [] # list of (original_line_number, text)
|
|
189
|
+
|
|
190
|
+
in_multi_comment = False
|
|
191
|
+
mc_end = spec.multi_comment_end
|
|
192
|
+
mc_start = spec.multi_comment_start
|
|
193
|
+
string_delims = spec.string_delimiters
|
|
194
|
+
|
|
195
|
+
# Python: also handle ''' as multi-comment
|
|
196
|
+
is_python = lang_key == "python"
|
|
197
|
+
in_python_docstring = False
|
|
198
|
+
python_docstring_delim = None
|
|
199
|
+
|
|
200
|
+
i = 0
|
|
201
|
+
while i < len(lines):
|
|
202
|
+
line = lines[i]
|
|
203
|
+
|
|
204
|
+
# --- Handle multi-line comment continuation ---
|
|
205
|
+
if in_multi_comment:
|
|
206
|
+
if mc_end and mc_end in line:
|
|
207
|
+
# End of multi-line comment found
|
|
208
|
+
end_pos = line.index(mc_end) + len(mc_end)
|
|
209
|
+
remainder = line[end_pos:]
|
|
210
|
+
in_multi_comment = False
|
|
211
|
+
# Process remainder as a new line
|
|
212
|
+
if remainder.strip():
|
|
213
|
+
lines[i] = remainder
|
|
214
|
+
continue
|
|
215
|
+
i += 1
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
# --- Python docstrings (""" / ''') used as standalone comments ---
|
|
219
|
+
if is_python and in_python_docstring:
|
|
220
|
+
if python_docstring_delim and python_docstring_delim in line:
|
|
221
|
+
end_pos = line.index(python_docstring_delim) + len(python_docstring_delim)
|
|
222
|
+
remainder = line[end_pos:]
|
|
223
|
+
in_python_docstring = False
|
|
224
|
+
python_docstring_delim = None
|
|
225
|
+
if remainder.strip():
|
|
226
|
+
lines[i] = remainder
|
|
227
|
+
continue
|
|
228
|
+
i += 1
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
stripped = line.strip()
|
|
232
|
+
|
|
233
|
+
# Skip empty lines
|
|
234
|
+
if not stripped:
|
|
235
|
+
i += 1
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
# --- Detect multi-line comment start ---
|
|
239
|
+
if mc_start:
|
|
240
|
+
# For Python, triple-quotes can be strings or docstrings.
|
|
241
|
+
# We only strip standalone docstrings (line starts with triple-quote
|
|
242
|
+
# after optional whitespace).
|
|
243
|
+
if is_python:
|
|
244
|
+
for q in ('"""', "'''"):
|
|
245
|
+
if stripped.startswith(q):
|
|
246
|
+
# Single-line docstring: """..."""
|
|
247
|
+
if stripped.count(q) >= 2 and stripped.endswith(q) and len(stripped) > 3:
|
|
248
|
+
# Check it's not a variable assignment like x = """..."""
|
|
249
|
+
leading = line[:len(line) - len(line.lstrip())]
|
|
250
|
+
code_before = line[:line.index(q)].strip()
|
|
251
|
+
if not code_before or code_before.endswith('='):
|
|
252
|
+
# Standalone docstring or assigned — skip if standalone
|
|
253
|
+
if not code_before:
|
|
254
|
+
line = None
|
|
255
|
+
break
|
|
256
|
+
# If code before and not assignment, keep line
|
|
257
|
+
elif stripped.startswith(q) and stripped.count(q) == 1:
|
|
258
|
+
# Multi-line docstring start
|
|
259
|
+
code_before = line[:line.index(q)].strip()
|
|
260
|
+
if not code_before:
|
|
261
|
+
in_python_docstring = True
|
|
262
|
+
python_docstring_delim = q
|
|
263
|
+
line = None
|
|
264
|
+
break
|
|
265
|
+
if line is None:
|
|
266
|
+
i += 1
|
|
267
|
+
continue
|
|
268
|
+
else:
|
|
269
|
+
# Non-Python: check for multi-line comment start
|
|
270
|
+
mc_pos = stripped.find(mc_start)
|
|
271
|
+
if mc_pos != -1:
|
|
272
|
+
# Check if inside a string
|
|
273
|
+
full_pos = line.find(mc_start)
|
|
274
|
+
if not _is_in_string(line, full_pos, string_delims):
|
|
275
|
+
# Check for same-line close
|
|
276
|
+
after_start = line[full_pos + len(mc_start):]
|
|
277
|
+
close_pos = after_start.find(mc_end) if mc_end else -1
|
|
278
|
+
if close_pos != -1 and mc_start != mc_end:
|
|
279
|
+
# Single-line block comment: remove it
|
|
280
|
+
before = line[:full_pos]
|
|
281
|
+
after = after_start[close_pos + len(mc_end):]
|
|
282
|
+
line = before + after
|
|
283
|
+
if not line.strip():
|
|
284
|
+
i += 1
|
|
285
|
+
continue
|
|
286
|
+
# Re-process this reconstructed line
|
|
287
|
+
lines[i] = line
|
|
288
|
+
continue
|
|
289
|
+
else:
|
|
290
|
+
# Multi-line comment starts here
|
|
291
|
+
before = line[:full_pos]
|
|
292
|
+
in_multi_comment = True
|
|
293
|
+
if before.strip():
|
|
294
|
+
line = before
|
|
295
|
+
else:
|
|
296
|
+
i += 1
|
|
297
|
+
continue
|
|
298
|
+
|
|
299
|
+
# --- Full-line single-line comment ---
|
|
300
|
+
if spec.single_comment and stripped.startswith(spec.single_comment):
|
|
301
|
+
# Special: keep shebangs
|
|
302
|
+
if stripped.startswith('#!') and i == 0:
|
|
303
|
+
result.append((i + 1, stripped))
|
|
304
|
+
i += 1
|
|
305
|
+
continue
|
|
306
|
+
|
|
307
|
+
# --- Remove inline comment ---
|
|
308
|
+
if spec.single_comment:
|
|
309
|
+
line = _remove_inline_comment(line, spec.single_comment, string_delims)
|
|
310
|
+
|
|
311
|
+
# --- Clean whitespace ---
|
|
312
|
+
if preserve_indent:
|
|
313
|
+
# Keep leading whitespace, strip trailing
|
|
314
|
+
leading = line[:len(line) - len(line.lstrip())]
|
|
315
|
+
content = line.strip()
|
|
316
|
+
if not content:
|
|
317
|
+
i += 1
|
|
318
|
+
continue
|
|
319
|
+
# Collapse internal multiple spaces (but not in strings)
|
|
320
|
+
line = leading + content
|
|
321
|
+
else:
|
|
322
|
+
line = line.strip()
|
|
323
|
+
if not line:
|
|
324
|
+
i += 1
|
|
325
|
+
continue
|
|
326
|
+
|
|
327
|
+
# Remove trailing whitespace
|
|
328
|
+
line = line.rstrip()
|
|
329
|
+
|
|
330
|
+
if line:
|
|
331
|
+
result.append((i + 1, line))
|
|
332
|
+
|
|
333
|
+
i += 1
|
|
334
|
+
|
|
335
|
+
return _format_result(result, include_line_numbers)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def compress_file(filepath: str, language: str | None = None,
|
|
339
|
+
include_line_numbers: bool = True) -> str:
|
|
340
|
+
"""!
|
|
341
|
+
@brief Compress a source file by removing comments and extra whitespace.
|
|
342
|
+
@param filepath Path to the source file.
|
|
343
|
+
@param language Optional language override. Auto-detected if None.
|
|
344
|
+
@param include_line_numbers If True (default), prefix each line with <n>: format.
|
|
345
|
+
@return Compressed source code string.
|
|
346
|
+
@throws ValueError If language cannot be detected.
|
|
347
|
+
@details Implements the compress_file function behavior with deterministic control flow.
|
|
348
|
+
"""
|
|
349
|
+
if language is None:
|
|
350
|
+
language = detect_language(filepath)
|
|
351
|
+
if language is None:
|
|
352
|
+
raise ValueError(
|
|
353
|
+
f"Cannot detect language for '{filepath}'. "
|
|
354
|
+
"Use --lang to specify explicitly.")
|
|
355
|
+
|
|
356
|
+
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
|
|
357
|
+
source = f.read()
|
|
358
|
+
|
|
359
|
+
return compress_source(source, language, include_line_numbers)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def main():
|
|
363
|
+
"""!
|
|
364
|
+
@brief Execute the standalone compression CLI.
|
|
365
|
+
@details Parses command-line arguments and invokes `compress_file`, printing the result to stdout or errors to stderr.
|
|
366
|
+
@return {None} Function return value.
|
|
367
|
+
"""
|
|
368
|
+
import argparse
|
|
369
|
+
parser = argparse.ArgumentParser(
|
|
370
|
+
description="Compress source code for LLM context optimization.")
|
|
371
|
+
parser.add_argument("file", help="Source file to compress.")
|
|
372
|
+
parser.add_argument("--lang", default=None,
|
|
373
|
+
help="Language override (auto-detected from extension).")
|
|
374
|
+
parser.add_argument("--enable-line-numbers", action="store_true",
|
|
375
|
+
default=False,
|
|
376
|
+
help="Enable line number prefixes (<n>:) in output.")
|
|
377
|
+
args = parser.parse_args()
|
|
378
|
+
|
|
379
|
+
if not os.path.isfile(args.file):
|
|
380
|
+
print(f"Error: file not found: {args.file}", file=sys.stderr)
|
|
381
|
+
sys.exit(1)
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
compressed = compress_file(args.file, args.lang,
|
|
385
|
+
args.enable_line_numbers)
|
|
386
|
+
print(compressed)
|
|
387
|
+
except ValueError as e:
|
|
388
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
389
|
+
sys.exit(1)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
if __name__ == "__main__":
|
|
393
|
+
main()
|
usereq/compress_files.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""!
|
|
3
|
+
@file compress_files.py
|
|
4
|
+
@brief Compress and concatenate multiple source files.
|
|
5
|
+
@details Uses the compress module to strip comments and whitespace from each input file, then concatenates results with a compact header per file for unique identification by an LLM agent.
|
|
6
|
+
@author GitHub Copilot
|
|
7
|
+
@version 0.0.70
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .compress import compress_file, detect_language
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _extract_line_range(compressed_with_line_numbers: str) -> tuple[int, int]:
|
|
18
|
+
"""! @brief Extract source line interval from compressed output with <n>: prefixes.
|
|
19
|
+
@param compressed_with_line_numbers Compressed payload generated with include_line_numbers=True.
|
|
20
|
+
@return Tuple (line_start, line_end) derived from preserved <n>: prefixes; returns (0, 0) when no prefixed lines exist.
|
|
21
|
+
@details Parses the first token of each line as an integer line number.
|
|
22
|
+
"""
|
|
23
|
+
line_numbers: list[int] = []
|
|
24
|
+
for line in compressed_with_line_numbers.splitlines():
|
|
25
|
+
marker, sep, _ = line.partition(":")
|
|
26
|
+
if sep and marker.isdigit():
|
|
27
|
+
line_numbers.append(int(marker))
|
|
28
|
+
|
|
29
|
+
if not line_numbers:
|
|
30
|
+
return 0, 0
|
|
31
|
+
|
|
32
|
+
return line_numbers[0], line_numbers[-1]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _format_output_path(filepath: str, output_base: Path | None) -> str:
|
|
36
|
+
"""!
|
|
37
|
+
@brief Build the header-visible path for one compressed source file.
|
|
38
|
+
@param filepath Absolute or relative source file path.
|
|
39
|
+
@param output_base Project-home base used to relativize output paths.
|
|
40
|
+
@return Original filepath when output_base is None; otherwise POSIX relative path from output_base.
|
|
41
|
+
@details Implements the _format_output_path function behavior with deterministic control flow.
|
|
42
|
+
"""
|
|
43
|
+
if output_base is None:
|
|
44
|
+
return filepath
|
|
45
|
+
absolute_path = Path(filepath).resolve()
|
|
46
|
+
return Path(os.path.relpath(absolute_path, output_base)).as_posix()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def compress_files(filepaths: list[str],
|
|
50
|
+
include_line_numbers: bool = True,
|
|
51
|
+
verbose: bool = False,
|
|
52
|
+
output_base: Path | None = None) -> str:
|
|
53
|
+
"""! @brief Compress multiple source files and concatenate with identifying headers.
|
|
54
|
+
@param filepaths List of source file paths.
|
|
55
|
+
@param include_line_numbers If True (default), keep <n>: prefixes in code block lines.
|
|
56
|
+
@param verbose If True, emits progress status messages on stderr.
|
|
57
|
+
@param output_base Project-home base used to render header paths as relative paths.
|
|
58
|
+
@return Concatenated compressed output string.
|
|
59
|
+
@throws ValueError If no files could be processed.
|
|
60
|
+
@details Each file is compressed and emitted as: header line `@@@ <path> | <lang>`, line-range metadata `> Lines: <start>-<end>`, and fenced code block delimited by triple backticks. Line range is derived from the already computed <n>: prefixes to preserve existing numbering logic. Files are separated by a blank line.
|
|
61
|
+
"""
|
|
62
|
+
parts = []
|
|
63
|
+
ok_count = 0
|
|
64
|
+
fail_count = 0
|
|
65
|
+
resolved_output_base = output_base.resolve() if output_base is not None else None
|
|
66
|
+
|
|
67
|
+
for fpath in filepaths:
|
|
68
|
+
if not os.path.isfile(fpath):
|
|
69
|
+
if verbose:
|
|
70
|
+
print(f" SKIP {fpath} (not found)", file=sys.stderr)
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
lang = detect_language(fpath)
|
|
74
|
+
if not lang:
|
|
75
|
+
if verbose:
|
|
76
|
+
print(f" SKIP {fpath} (unsupported extension)", file=sys.stderr)
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
compressed_with_line_numbers = compress_file(fpath, lang, True)
|
|
81
|
+
line_start, line_end = _extract_line_range(compressed_with_line_numbers)
|
|
82
|
+
compressed = (
|
|
83
|
+
compressed_with_line_numbers
|
|
84
|
+
if include_line_numbers
|
|
85
|
+
else compress_file(fpath, lang, False)
|
|
86
|
+
)
|
|
87
|
+
output_path = _format_output_path(fpath, resolved_output_base)
|
|
88
|
+
header = f"@@@ {output_path} | {lang}"
|
|
89
|
+
parts.append(
|
|
90
|
+
f"{header}\n> Lines: {line_start}-{line_end}\n```\n{compressed}\n```"
|
|
91
|
+
)
|
|
92
|
+
ok_count += 1
|
|
93
|
+
if verbose:
|
|
94
|
+
print(f" OK {fpath}", file=sys.stderr)
|
|
95
|
+
except Exception as e:
|
|
96
|
+
if verbose:
|
|
97
|
+
print(f" FAIL {fpath} ({e})", file=sys.stderr)
|
|
98
|
+
fail_count += 1
|
|
99
|
+
|
|
100
|
+
if not parts:
|
|
101
|
+
raise ValueError("No valid source files processed")
|
|
102
|
+
|
|
103
|
+
if verbose:
|
|
104
|
+
print(f"\n Compressed: {ok_count} ok, {fail_count} failed",
|
|
105
|
+
file=sys.stderr)
|
|
106
|
+
|
|
107
|
+
return "\n\n".join(parts)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def main():
|
|
111
|
+
"""!
|
|
112
|
+
@brief Execute the multi-file compression CLI command.
|
|
113
|
+
@details Parses command-line arguments, calls `compress_files`, and prints output or errors.
|
|
114
|
+
@return {None} Function return value.
|
|
115
|
+
"""
|
|
116
|
+
import argparse
|
|
117
|
+
parser = argparse.ArgumentParser(
|
|
118
|
+
description="Compress and concatenate source files for LLM context.")
|
|
119
|
+
parser.add_argument("files", nargs="+", help="Source files to compress.")
|
|
120
|
+
parser.add_argument("--enable-line-numbers", action="store_true",
|
|
121
|
+
default=False,
|
|
122
|
+
help="Enable line number prefixes (<n>:) in output.")
|
|
123
|
+
args = parser.parse_args()
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
output = compress_files(args.files, args.enable_line_numbers)
|
|
127
|
+
print(output)
|
|
128
|
+
except ValueError as e:
|
|
129
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
130
|
+
sys.exit(1)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
main()
|