codetool-explore 0.5.0__py3-none-macosx_11_0_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.
Files changed (33) hide show
  1. codetool_explore/__init__.py +35 -0
  2. codetool_explore/_bin/codetool-explore-rust-macos-arm64 +0 -0
  3. codetool_explore/api.py +266 -0
  4. codetool_explore/cli.py +188 -0
  5. codetool_explore/compression.py +150 -0
  6. codetool_explore/cursor.py +71 -0
  7. codetool_explore/errors.py +23 -0
  8. codetool_explore/explorer.py +497 -0
  9. codetool_explore/ignore.py +222 -0
  10. codetool_explore/py.typed +0 -0
  11. codetool_explore/python_backend/__init__.py +154 -0
  12. codetool_explore/python_backend/case.py +19 -0
  13. codetool_explore/python_backend/config.py +35 -0
  14. codetool_explore/python_backend/constants.py +39 -0
  15. codetool_explore/python_backend/file_search.py +51 -0
  16. codetool_explore/python_backend/ignore_rules.py +40 -0
  17. codetool_explore/python_backend/literal.py +79 -0
  18. codetool_explore/python_backend/matcher.py +79 -0
  19. codetool_explore/python_backend/models.py +49 -0
  20. codetool_explore/python_backend/output.py +82 -0
  21. codetool_explore/python_backend/regex_search.py +63 -0
  22. codetool_explore/python_backend/search.py +327 -0
  23. codetool_explore/python_backend/text.py +39 -0
  24. codetool_explore/python_backend/walker.py +119 -0
  25. codetool_explore/ranking.py +384 -0
  26. codetool_explore/roots.py +148 -0
  27. codetool_explore/rust_backend.py +308 -0
  28. codetool_explore/text_output.py +475 -0
  29. codetool_explore-0.5.0.dist-info/METADATA +240 -0
  30. codetool_explore-0.5.0.dist-info/RECORD +33 -0
  31. codetool_explore-0.5.0.dist-info/WHEEL +4 -0
  32. codetool_explore-0.5.0.dist-info/entry_points.txt +2 -0
  33. 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
+ ]
@@ -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)
@@ -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