codetool-explore 0.5.0__py3-none-win_arm64.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.
- codetool_explore/__init__.py +35 -0
- codetool_explore/_bin/codetool-explore-rust-windows-arm64.exe +0 -0
- codetool_explore/api.py +266 -0
- codetool_explore/cli.py +188 -0
- codetool_explore/compression.py +150 -0
- codetool_explore/cursor.py +71 -0
- codetool_explore/errors.py +23 -0
- codetool_explore/explorer.py +497 -0
- codetool_explore/ignore.py +222 -0
- codetool_explore/py.typed +0 -0
- codetool_explore/python_backend/__init__.py +154 -0
- codetool_explore/python_backend/case.py +19 -0
- codetool_explore/python_backend/config.py +35 -0
- codetool_explore/python_backend/constants.py +39 -0
- codetool_explore/python_backend/file_search.py +51 -0
- codetool_explore/python_backend/ignore_rules.py +40 -0
- codetool_explore/python_backend/literal.py +79 -0
- codetool_explore/python_backend/matcher.py +79 -0
- codetool_explore/python_backend/models.py +49 -0
- codetool_explore/python_backend/output.py +82 -0
- codetool_explore/python_backend/regex_search.py +63 -0
- codetool_explore/python_backend/search.py +327 -0
- codetool_explore/python_backend/text.py +39 -0
- codetool_explore/python_backend/walker.py +119 -0
- codetool_explore/ranking.py +384 -0
- codetool_explore/roots.py +148 -0
- codetool_explore/rust_backend.py +308 -0
- codetool_explore/text_output.py +475 -0
- codetool_explore-0.5.0.dist-info/METADATA +240 -0
- codetool_explore-0.5.0.dist-info/RECORD +33 -0
- codetool_explore-0.5.0.dist-info/WHEEL +4 -0
- codetool_explore-0.5.0.dist-info/entry_points.txt +2 -0
- codetool_explore-0.5.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Fast workspace search for coding-agent tools.
|
|
2
|
+
|
|
3
|
+
The main public API is intentionally a single function:
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
from codetool_explore import explore
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
`explore()` can target file contents, file paths, a content/path union, read-only
|
|
10
|
+
file ranges, or one-level directory listings. Patterns are regexes by default
|
|
11
|
+
for search targets. ``backend="auto"`` dispatches searchable targets to a
|
|
12
|
+
bundled or development Rust CLI accelerator when available, with the pure-Python
|
|
13
|
+
stdlib backend as fallback. Use ``result_format="text"`` for maximally compact
|
|
14
|
+
raw text output. Controlled failures raise ``ExploreError`` subclasses.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from .api import explore
|
|
20
|
+
from .errors import (
|
|
21
|
+
ExploreArgumentError,
|
|
22
|
+
ExploreBackendError,
|
|
23
|
+
ExploreError,
|
|
24
|
+
ExplorePatternError,
|
|
25
|
+
ExploreRootError,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"explore",
|
|
30
|
+
"ExploreArgumentError",
|
|
31
|
+
"ExploreBackendError",
|
|
32
|
+
"ExploreError",
|
|
33
|
+
"ExplorePatternError",
|
|
34
|
+
"ExploreRootError",
|
|
35
|
+
]
|
|
Binary file
|
codetool_explore/api.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Public API for codetool-explore."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
|
|
9
|
+
from .compression import compress_result
|
|
10
|
+
from .errors import ExploreArgumentError, ExploreBackendError, ExplorePatternError
|
|
11
|
+
from .explorer import list_path_target, read_file_target
|
|
12
|
+
from .python_backend import resolve_case, search_python
|
|
13
|
+
from .roots import RootInput
|
|
14
|
+
from .rust_backend import RustBackendUnavailable, find_rust_binary, search_rust
|
|
15
|
+
from .text_output import format_text_result
|
|
16
|
+
|
|
17
|
+
BACKENDS = frozenset({"auto", "python", "rust", "native"})
|
|
18
|
+
SEARCH_TARGETS = frozenset({"content", "path", "content_or_path"})
|
|
19
|
+
EXPLORATION_TARGETS = frozenset({"read", "list"})
|
|
20
|
+
TARGETS = SEARCH_TARGETS | EXPLORATION_TARGETS
|
|
21
|
+
RESULT_FORMATS = frozenset({"compressed", "full", "text"})
|
|
22
|
+
RESULT_FORMAT_ALIASES = {
|
|
23
|
+
"raw": "text",
|
|
24
|
+
"plain": "text",
|
|
25
|
+
"plaintext": "text",
|
|
26
|
+
}
|
|
27
|
+
EMPTY_REGEX_SAMPLES = ("", "a", "abc", " a ", "_", "0", "é")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _normalize_api_target(target: str) -> str:
|
|
31
|
+
selected_target = str(target or "content").lower()
|
|
32
|
+
if selected_target not in TARGETS:
|
|
33
|
+
raise ExploreArgumentError(
|
|
34
|
+
"target must be one of: content, path, content_or_path, read, list"
|
|
35
|
+
)
|
|
36
|
+
return selected_target
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _normalize_result_format(
|
|
40
|
+
result_format: str | None,
|
|
41
|
+
*,
|
|
42
|
+
target: str = "content",
|
|
43
|
+
) -> str:
|
|
44
|
+
default = "text" if target in EXPLORATION_TARGETS else "compressed"
|
|
45
|
+
selected_format = str(result_format or default).lower()
|
|
46
|
+
selected_format = RESULT_FORMAT_ALIASES.get(selected_format, selected_format)
|
|
47
|
+
if selected_format not in RESULT_FORMATS:
|
|
48
|
+
raise ExploreArgumentError(
|
|
49
|
+
"result_format must be one of: compressed, full, text"
|
|
50
|
+
)
|
|
51
|
+
return selected_format
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _finalize_result(result: dict[str, object], result_format: str) -> dict[str, object] | str:
|
|
55
|
+
selected_format = _normalize_result_format(
|
|
56
|
+
result_format,
|
|
57
|
+
target=str(result.get("target", "content")),
|
|
58
|
+
)
|
|
59
|
+
if selected_format == "full":
|
|
60
|
+
return result
|
|
61
|
+
if selected_format == "text":
|
|
62
|
+
return format_text_result(result)
|
|
63
|
+
return compress_result(result)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _regex_can_produce_empty_match(pattern: str, *, case: str) -> bool:
|
|
67
|
+
requested_case = str(case or "smart").lower()
|
|
68
|
+
_, case_sensitive = resolve_case(requested_case, pattern)
|
|
69
|
+
flags = 0 if case_sensitive else re.IGNORECASE
|
|
70
|
+
try:
|
|
71
|
+
compiled = re.compile(pattern, flags)
|
|
72
|
+
except re.error as exc:
|
|
73
|
+
raise ExplorePatternError(f"invalid regex: {exc}") from exc
|
|
74
|
+
return any(
|
|
75
|
+
any(match.start() == match.end() for match in compiled.finditer(sample))
|
|
76
|
+
for sample in EMPTY_REGEX_SAMPLES
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _materialize_root_for_fallback(root: RootInput) -> RootInput:
|
|
81
|
+
if isinstance(root, Iterable) and not isinstance(root, (str, bytes, os.PathLike)):
|
|
82
|
+
return tuple(root)
|
|
83
|
+
return root
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def explore(
|
|
87
|
+
pattern: str,
|
|
88
|
+
root: RootInput = ".",
|
|
89
|
+
target: str = "content",
|
|
90
|
+
regex: bool = True,
|
|
91
|
+
path_scope: str = "path",
|
|
92
|
+
glob: str | Iterable[str] | None = None,
|
|
93
|
+
exclude: str | Iterable[str] | None = None,
|
|
94
|
+
case: str = "smart",
|
|
95
|
+
mode: str = "files",
|
|
96
|
+
context_lines: int = 0,
|
|
97
|
+
limit: int = 50,
|
|
98
|
+
cursor: str | int | None = None,
|
|
99
|
+
backend: str = "auto",
|
|
100
|
+
result_format: str | None = None,
|
|
101
|
+
start_line: int = 1,
|
|
102
|
+
) -> dict[str, object] | str:
|
|
103
|
+
"""Search, read, or list workspace files below ``root``.
|
|
104
|
+
|
|
105
|
+
``target`` selects what the pattern is matched against:
|
|
106
|
+
``"content"`` searches file contents, ``"path"`` searches relative file
|
|
107
|
+
paths without opening files, and ``"content_or_path"`` returns files
|
|
108
|
+
matching either.
|
|
109
|
+
``"read"`` treats ``pattern`` as one file path and returns a controlled
|
|
110
|
+
line range. ``"list"`` treats ``pattern`` as one file/directory path and
|
|
111
|
+
returns a one-level listing.
|
|
112
|
+
Patterns are interpreted as regex by default; pass ``regex=False`` for exact
|
|
113
|
+
literal search. ``root`` may be one directory/file path or a non-empty list
|
|
114
|
+
of directory/file paths. Multi-root searches report paths relative to the
|
|
115
|
+
roots' common base, so sibling roots keep prefixes such as ``src/...``.
|
|
116
|
+
To tolerate common JSON/tool-call mistakes, a whitespace-separated root
|
|
117
|
+
string is treated as multiple roots only when that exact path does not
|
|
118
|
+
exist and every split token is an existing file or directory.
|
|
119
|
+
``backend="auto"`` prefers the optional Rust CLI accelerator when available
|
|
120
|
+
and falls back to the pure-Python backend otherwise. Regex searches use the
|
|
121
|
+
Rust helper when it supports the requested syntax; Python remains the
|
|
122
|
+
compatibility fallback.
|
|
123
|
+
|
|
124
|
+
Search results are returned in a compact structured shape by default; read
|
|
125
|
+
results default to plain text and list results default to tree-compressed
|
|
126
|
+
text. Pass
|
|
127
|
+
``result_format="full"`` to receive the pre-compression backend result
|
|
128
|
+
dictionary unchanged. Pass ``result_format="text"`` (or ``"raw"``) for an
|
|
129
|
+
RTK-inspired plain-text rendering optimized for token compression.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
selected = str(backend or "auto").lower()
|
|
133
|
+
if selected not in BACKENDS:
|
|
134
|
+
raise ExploreArgumentError(
|
|
135
|
+
"backend must be one of: auto, python, rust, native"
|
|
136
|
+
)
|
|
137
|
+
normalised_target = _normalize_api_target(target)
|
|
138
|
+
selected_format = _normalize_result_format(
|
|
139
|
+
result_format,
|
|
140
|
+
target=normalised_target,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if normalised_target == "read":
|
|
144
|
+
result = read_file_target(
|
|
145
|
+
pattern,
|
|
146
|
+
root=root,
|
|
147
|
+
start_line=start_line,
|
|
148
|
+
limit=limit,
|
|
149
|
+
cursor=cursor,
|
|
150
|
+
)
|
|
151
|
+
if selected != "python":
|
|
152
|
+
result["backend_requested"] = selected
|
|
153
|
+
return _finalize_result(result, selected_format)
|
|
154
|
+
|
|
155
|
+
if normalised_target == "list":
|
|
156
|
+
result = list_path_target(
|
|
157
|
+
pattern,
|
|
158
|
+
root=root,
|
|
159
|
+
glob=glob,
|
|
160
|
+
exclude=exclude,
|
|
161
|
+
limit=limit,
|
|
162
|
+
cursor=cursor,
|
|
163
|
+
)
|
|
164
|
+
if selected != "python":
|
|
165
|
+
result["backend_requested"] = selected
|
|
166
|
+
return _finalize_result(result, selected_format)
|
|
167
|
+
|
|
168
|
+
if selected == "python":
|
|
169
|
+
result = search_python(
|
|
170
|
+
pattern,
|
|
171
|
+
root=root,
|
|
172
|
+
regex=regex,
|
|
173
|
+
target=normalised_target,
|
|
174
|
+
path_scope=path_scope,
|
|
175
|
+
glob=glob,
|
|
176
|
+
exclude=exclude,
|
|
177
|
+
case=case,
|
|
178
|
+
mode=mode,
|
|
179
|
+
context_lines=context_lines,
|
|
180
|
+
limit=limit,
|
|
181
|
+
cursor=cursor,
|
|
182
|
+
)
|
|
183
|
+
return _finalize_result(result, selected_format)
|
|
184
|
+
|
|
185
|
+
if selected in {"rust", "native"}:
|
|
186
|
+
result = search_rust(
|
|
187
|
+
pattern,
|
|
188
|
+
root=root,
|
|
189
|
+
regex=regex,
|
|
190
|
+
target=normalised_target,
|
|
191
|
+
path_scope=path_scope,
|
|
192
|
+
glob=glob,
|
|
193
|
+
exclude=exclude,
|
|
194
|
+
case=case,
|
|
195
|
+
mode=mode,
|
|
196
|
+
context_lines=context_lines,
|
|
197
|
+
limit=limit,
|
|
198
|
+
cursor=cursor,
|
|
199
|
+
)
|
|
200
|
+
return _finalize_result(result, selected_format)
|
|
201
|
+
|
|
202
|
+
# auto: prefer Rust if discoverable, then fall back to Python. The fallback
|
|
203
|
+
# preserves Python-regex compatibility for syntax unsupported by Rust's
|
|
204
|
+
# regex engine.
|
|
205
|
+
root_for_auto = _materialize_root_for_fallback(root)
|
|
206
|
+
rust_binary = find_rust_binary()
|
|
207
|
+
if rust_binary:
|
|
208
|
+
fallback_reason: str | None = None
|
|
209
|
+
if (
|
|
210
|
+
regex
|
|
211
|
+
and normalised_target in {"content", "content_or_path"}
|
|
212
|
+
and isinstance(pattern, str)
|
|
213
|
+
and _regex_can_produce_empty_match(pattern, case=case)
|
|
214
|
+
):
|
|
215
|
+
fallback_reason = (
|
|
216
|
+
"Rust backend skipped for regex patterns that can match empty "
|
|
217
|
+
"spans; Python preserves re.finditer count semantics"
|
|
218
|
+
)
|
|
219
|
+
if fallback_reason is None:
|
|
220
|
+
try:
|
|
221
|
+
result = search_rust(
|
|
222
|
+
pattern,
|
|
223
|
+
root=root_for_auto,
|
|
224
|
+
regex=regex,
|
|
225
|
+
target=normalised_target,
|
|
226
|
+
path_scope=path_scope,
|
|
227
|
+
glob=glob,
|
|
228
|
+
exclude=exclude,
|
|
229
|
+
case=case,
|
|
230
|
+
mode=mode,
|
|
231
|
+
context_lines=context_lines,
|
|
232
|
+
limit=limit,
|
|
233
|
+
cursor=cursor,
|
|
234
|
+
binary=rust_binary,
|
|
235
|
+
)
|
|
236
|
+
result["backend_requested"] = "auto"
|
|
237
|
+
return _finalize_result(result, selected_format)
|
|
238
|
+
except (
|
|
239
|
+
RustBackendUnavailable,
|
|
240
|
+
ExploreBackendError,
|
|
241
|
+
ExplorePatternError,
|
|
242
|
+
ExploreArgumentError,
|
|
243
|
+
RuntimeError,
|
|
244
|
+
ValueError,
|
|
245
|
+
) as exc:
|
|
246
|
+
fallback_reason = str(exc)
|
|
247
|
+
else:
|
|
248
|
+
fallback_reason = "Rust backend unavailable"
|
|
249
|
+
|
|
250
|
+
result = search_python(
|
|
251
|
+
pattern,
|
|
252
|
+
root=root_for_auto,
|
|
253
|
+
regex=regex,
|
|
254
|
+
target=normalised_target,
|
|
255
|
+
path_scope=path_scope,
|
|
256
|
+
glob=glob,
|
|
257
|
+
exclude=exclude,
|
|
258
|
+
case=case,
|
|
259
|
+
mode=mode,
|
|
260
|
+
context_lines=context_lines,
|
|
261
|
+
limit=limit,
|
|
262
|
+
cursor=cursor,
|
|
263
|
+
)
|
|
264
|
+
result["backend_requested"] = "auto"
|
|
265
|
+
result["backend_fallback"] = fallback_reason
|
|
266
|
+
return _finalize_result(result, selected_format)
|
codetool_explore/cli.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Command-line interface for codetool-explore."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
|
|
10
|
+
from .api import explore
|
|
11
|
+
from .errors import ExploreError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
prog="codetool-explore",
|
|
17
|
+
description=(
|
|
18
|
+
"Search file contents, file paths, read files, or list directories "
|
|
19
|
+
"with compact JSON or raw text output."
|
|
20
|
+
),
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument("pattern", nargs="?", help="text or regex pattern to search")
|
|
23
|
+
parser.add_argument("path", nargs="?", help="root directory/file (default: .)")
|
|
24
|
+
parser.add_argument("--pattern", dest="pattern_flag", help="text or regex pattern")
|
|
25
|
+
action_group = parser.add_mutually_exclusive_group()
|
|
26
|
+
action_group.add_argument(
|
|
27
|
+
"--read",
|
|
28
|
+
dest="read_path",
|
|
29
|
+
help="read one known file path with controlled line output",
|
|
30
|
+
)
|
|
31
|
+
action_group.add_argument(
|
|
32
|
+
"--list",
|
|
33
|
+
dest="list_path",
|
|
34
|
+
help="list one directory level or one file path",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--root",
|
|
38
|
+
dest="roots",
|
|
39
|
+
action="append",
|
|
40
|
+
default=None,
|
|
41
|
+
help="root directory/file (repeat for multiple roots; default: .)",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--target",
|
|
45
|
+
choices=("content", "path", "content_or_path", "read", "list"),
|
|
46
|
+
default="content",
|
|
47
|
+
help="search target (default: content)",
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--path-scope",
|
|
51
|
+
choices=("path", "basename"),
|
|
52
|
+
default="path",
|
|
53
|
+
help=(
|
|
54
|
+
"path field matched when --target path/content_or_path "
|
|
55
|
+
"(default: path)"
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"--format",
|
|
60
|
+
choices=("compressed", "full", "text", "raw", "plain"),
|
|
61
|
+
default=None,
|
|
62
|
+
help=(
|
|
63
|
+
"output format (default: compressed JSON for search, plain text "
|
|
64
|
+
"for read, tree text for list)"
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"--raw",
|
|
69
|
+
action="store_true",
|
|
70
|
+
help='shortcut for --format text; prints "No Match" when empty',
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--mode",
|
|
74
|
+
choices=("files", "snippets", "count"),
|
|
75
|
+
default="files",
|
|
76
|
+
help="result mode (default: files)",
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument("--context-lines", type=int, default=0)
|
|
79
|
+
parser.add_argument("--limit", type=int, default=50)
|
|
80
|
+
parser.add_argument("--cursor")
|
|
81
|
+
parser.add_argument("--start-line", type=int, default=1)
|
|
82
|
+
parser.add_argument("--glob", action="append")
|
|
83
|
+
parser.add_argument("--exclude", action="append")
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"--case",
|
|
86
|
+
default="smart",
|
|
87
|
+
choices=(
|
|
88
|
+
"smart",
|
|
89
|
+
"sensitive",
|
|
90
|
+
"case-sensitive",
|
|
91
|
+
"exact",
|
|
92
|
+
"insensitive",
|
|
93
|
+
"ignore",
|
|
94
|
+
"ignorecase",
|
|
95
|
+
"case-insensitive",
|
|
96
|
+
"i",
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
parser.add_argument(
|
|
100
|
+
"--backend",
|
|
101
|
+
default="auto",
|
|
102
|
+
choices=("auto", "python", "rust", "native"),
|
|
103
|
+
)
|
|
104
|
+
regex_group = parser.add_mutually_exclusive_group()
|
|
105
|
+
regex_group.add_argument(
|
|
106
|
+
"--regex",
|
|
107
|
+
dest="regex",
|
|
108
|
+
action="store_true",
|
|
109
|
+
default=True,
|
|
110
|
+
help="interpret pattern as regex (default)",
|
|
111
|
+
)
|
|
112
|
+
regex_group.add_argument(
|
|
113
|
+
"-F",
|
|
114
|
+
"--literal",
|
|
115
|
+
dest="regex",
|
|
116
|
+
action="store_false",
|
|
117
|
+
help="interpret pattern literally",
|
|
118
|
+
)
|
|
119
|
+
return parser
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
123
|
+
parser = build_parser()
|
|
124
|
+
args = parser.parse_args(argv)
|
|
125
|
+
cli_root = None
|
|
126
|
+
if args.roots is not None:
|
|
127
|
+
cli_root = args.roots[0] if len(args.roots) == 1 else args.roots
|
|
128
|
+
|
|
129
|
+
action_target = None
|
|
130
|
+
if args.read_path is not None:
|
|
131
|
+
action_target = "read"
|
|
132
|
+
if args.pattern_flag is not None or args.pattern is not None or args.path is not None:
|
|
133
|
+
parser.error("--read cannot be combined with positional pattern/path or --pattern")
|
|
134
|
+
pattern = args.read_path
|
|
135
|
+
root = cli_root or "."
|
|
136
|
+
elif args.list_path is not None:
|
|
137
|
+
action_target = "list"
|
|
138
|
+
if args.pattern_flag is not None or args.pattern is not None or args.path is not None:
|
|
139
|
+
parser.error("--list cannot be combined with positional pattern/path or --pattern")
|
|
140
|
+
pattern = args.list_path
|
|
141
|
+
root = cli_root or "."
|
|
142
|
+
elif args.pattern_flag is not None:
|
|
143
|
+
pattern = args.pattern_flag
|
|
144
|
+
root = cli_root or args.path or args.pattern or "."
|
|
145
|
+
else:
|
|
146
|
+
pattern = args.pattern
|
|
147
|
+
root = cli_root or args.path or "."
|
|
148
|
+
if pattern is None:
|
|
149
|
+
parser.error("missing path" if args.target in {"read", "list"} else "missing pattern")
|
|
150
|
+
result_format = "text" if args.raw else args.format
|
|
151
|
+
target = action_target or args.target
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
result = explore(
|
|
155
|
+
pattern,
|
|
156
|
+
root=root,
|
|
157
|
+
target=target,
|
|
158
|
+
regex=args.regex,
|
|
159
|
+
path_scope=args.path_scope,
|
|
160
|
+
glob=args.glob,
|
|
161
|
+
exclude=args.exclude,
|
|
162
|
+
case=args.case,
|
|
163
|
+
mode=args.mode,
|
|
164
|
+
context_lines=args.context_lines,
|
|
165
|
+
limit=args.limit,
|
|
166
|
+
cursor=args.cursor,
|
|
167
|
+
start_line=args.start_line,
|
|
168
|
+
backend=args.backend,
|
|
169
|
+
result_format=result_format,
|
|
170
|
+
)
|
|
171
|
+
except ExploreError as exc:
|
|
172
|
+
print(f"codetool-explore: {exc}", file=sys.stderr)
|
|
173
|
+
return 2
|
|
174
|
+
if isinstance(result, str):
|
|
175
|
+
sys.stdout.write(result)
|
|
176
|
+
if not result.endswith("\n"):
|
|
177
|
+
sys.stdout.write("\n")
|
|
178
|
+
else:
|
|
179
|
+
print(json.dumps(result, sort_keys=True, separators=(",", ":")))
|
|
180
|
+
return 0
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def run() -> None:
|
|
184
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__":
|
|
188
|
+
run()
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Compact result shaping for public search output.
|
|
2
|
+
|
|
3
|
+
The search backends return the full, compatibility-preserving result shape.
|
|
4
|
+
This module owns the API output compression layer that runs after any backend
|
|
5
|
+
finishes, keeping location/count/pagination data while abbreviating low-value
|
|
6
|
+
metadata for coding-agent token efficiency.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _next_request(result: dict[str, object]) -> dict[str, object] | None:
|
|
13
|
+
next_cursor = result.get("next_cursor")
|
|
14
|
+
if next_cursor is None:
|
|
15
|
+
return None
|
|
16
|
+
request: dict[str, object] = {
|
|
17
|
+
"pattern": result.get("pattern"),
|
|
18
|
+
"root": result.get("root"),
|
|
19
|
+
"cursor": next_cursor,
|
|
20
|
+
"limit": result.get("limit"),
|
|
21
|
+
"backend": result.get("backend_requested", result.get("backend")),
|
|
22
|
+
}
|
|
23
|
+
for key in ("target", "path_scope", "mode", "case", "regex", "glob", "exclude"):
|
|
24
|
+
if key in result:
|
|
25
|
+
request[key] = result[key]
|
|
26
|
+
return request
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _backend_info(result: dict[str, object]) -> dict[str, object]:
|
|
30
|
+
backend: dict[str, object] = {"selected": result.get("backend")}
|
|
31
|
+
if "backend_requested" in result:
|
|
32
|
+
backend["requested"] = result["backend_requested"]
|
|
33
|
+
if "backend_fallback" in result:
|
|
34
|
+
backend["fallback"] = result["backend_fallback"]
|
|
35
|
+
return backend
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _page_info(result: dict[str, object], returned: int) -> dict[str, object]:
|
|
39
|
+
page: dict[str, object] = {
|
|
40
|
+
"returned": result.get("returned", returned),
|
|
41
|
+
"limit": result.get("limit"),
|
|
42
|
+
"offset": result.get("offset"),
|
|
43
|
+
"truncated": result.get("truncated", False),
|
|
44
|
+
"next_cursor": result.get("next_cursor"),
|
|
45
|
+
}
|
|
46
|
+
next_request = _next_request(result)
|
|
47
|
+
if next_request is not None:
|
|
48
|
+
page["next_request"] = next_request
|
|
49
|
+
return page
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _compress_read_result(result: dict[str, object]) -> dict[str, object]:
|
|
53
|
+
output: dict[str, object] = {
|
|
54
|
+
"format": "compressed",
|
|
55
|
+
"target": "read",
|
|
56
|
+
"backend": _backend_info(result),
|
|
57
|
+
"path": result.get("path"),
|
|
58
|
+
"start_line": result.get("start_line"),
|
|
59
|
+
"line_count": result.get("line_count", result.get("returned", 0)),
|
|
60
|
+
"page": _page_info(result, int(result.get("returned", 0) or 0)),
|
|
61
|
+
"text": result.get("text", ""),
|
|
62
|
+
}
|
|
63
|
+
if result.get("content_truncated"):
|
|
64
|
+
output["content_truncated"] = True
|
|
65
|
+
return output
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _compress_list_result(result: dict[str, object]) -> dict[str, object]:
|
|
69
|
+
entries: list[dict[str, object]] = []
|
|
70
|
+
for entry in result.get("entries", []):
|
|
71
|
+
if not isinstance(entry, dict):
|
|
72
|
+
continue
|
|
73
|
+
compact: dict[str, object] = {}
|
|
74
|
+
if "path" in entry:
|
|
75
|
+
compact["p"] = entry["path"]
|
|
76
|
+
if "kind" in entry:
|
|
77
|
+
compact["k"] = entry["kind"]
|
|
78
|
+
entries.append(compact)
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
"format": "compressed",
|
|
82
|
+
"target": "list",
|
|
83
|
+
"backend": _backend_info(result),
|
|
84
|
+
"path": result.get("path"),
|
|
85
|
+
"page": _page_info(result, len(entries)),
|
|
86
|
+
"totals": {
|
|
87
|
+
"entries": result.get("total_entries", 0),
|
|
88
|
+
"files": result.get("total_files", 0),
|
|
89
|
+
"dirs": result.get("total_dirs", 0),
|
|
90
|
+
},
|
|
91
|
+
"entries": entries,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def compress_result(result: dict[str, object]) -> dict[str, object]:
|
|
96
|
+
"""Return the default compact structured search result shape."""
|
|
97
|
+
|
|
98
|
+
target = result.get("target", "content")
|
|
99
|
+
if target == "read":
|
|
100
|
+
return _compress_read_result(result)
|
|
101
|
+
if target == "list":
|
|
102
|
+
return _compress_list_result(result)
|
|
103
|
+
|
|
104
|
+
mode = str(result.get("mode", "files"))
|
|
105
|
+
compact_matches: list[dict[str, object]] = []
|
|
106
|
+
for match in result.get("matches", []):
|
|
107
|
+
if not isinstance(match, dict):
|
|
108
|
+
continue
|
|
109
|
+
compact: dict[str, object] = {}
|
|
110
|
+
if "path" in match:
|
|
111
|
+
compact["p"] = match["path"]
|
|
112
|
+
if mode != "files":
|
|
113
|
+
if "line" in match:
|
|
114
|
+
compact["l"] = match["line"]
|
|
115
|
+
elif "first_line" in match:
|
|
116
|
+
compact["l"] = match["first_line"]
|
|
117
|
+
if "count" in match:
|
|
118
|
+
compact["c"] = match["count"]
|
|
119
|
+
if "snippet" in match:
|
|
120
|
+
compact["s"] = match["snippet"]
|
|
121
|
+
if "context" in match:
|
|
122
|
+
compact["ctx"] = match["context"]
|
|
123
|
+
if "match_kind" in match:
|
|
124
|
+
compact["m"] = match["match_kind"]
|
|
125
|
+
if "kind" in match and match.get("kind") != "file":
|
|
126
|
+
compact["k"] = match["kind"]
|
|
127
|
+
compact_matches.append(compact)
|
|
128
|
+
|
|
129
|
+
totals: dict[str, object] = {
|
|
130
|
+
"files": result.get("total_files", 0),
|
|
131
|
+
"matches": result.get("total_matches", 0),
|
|
132
|
+
"count": result.get("count", 0),
|
|
133
|
+
}
|
|
134
|
+
target = result.get("target", "content")
|
|
135
|
+
if target != "content":
|
|
136
|
+
totals["path"] = result.get("path_matches", 0)
|
|
137
|
+
totals["content_files"] = result.get("content_files", 0)
|
|
138
|
+
totals["content_count"] = result.get("content_count", 0)
|
|
139
|
+
|
|
140
|
+
output: dict[str, object] = {
|
|
141
|
+
"format": "compressed",
|
|
142
|
+
"mode": mode,
|
|
143
|
+
"backend": _backend_info(result),
|
|
144
|
+
"totals": totals,
|
|
145
|
+
"page": _page_info(result, len(compact_matches)),
|
|
146
|
+
"matches": compact_matches,
|
|
147
|
+
}
|
|
148
|
+
if target != "content" and "target" in result:
|
|
149
|
+
output["target"] = result["target"]
|
|
150
|
+
return output
|