cocoindex-code 0.2.36__tar.gz → 0.2.37__tar.gz
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.
- {cocoindex_code-0.2.36 → cocoindex_code-0.2.37}/PKG-INFO +36 -5
- {cocoindex_code-0.2.36 → cocoindex_code-0.2.37}/README.md +31 -0
- {cocoindex_code-0.2.36 → cocoindex_code-0.2.37}/pyproject.toml +5 -5
- {cocoindex_code-0.2.36 → cocoindex_code-0.2.37}/src/cocoindex_code/_version.py +2 -2
- {cocoindex_code-0.2.36 → cocoindex_code-0.2.37}/src/cocoindex_code/cli.py +75 -0
- {cocoindex_code-0.2.36 → cocoindex_code-0.2.37}/src/cocoindex_code/daemon.py +3 -10
- cocoindex_code-0.2.36/src/cocoindex_code/indexer.py → cocoindex_code-0.2.37/src/cocoindex_code/file_walk.py +63 -118
- cocoindex_code-0.2.37/src/cocoindex_code/grep.py +411 -0
- cocoindex_code-0.2.37/src/cocoindex_code/indexer.py +123 -0
- {cocoindex_code-0.2.36 → cocoindex_code-0.2.37}/.gitignore +0 -0
- {cocoindex_code-0.2.36 → cocoindex_code-0.2.37}/LICENSE +0 -0
- {cocoindex_code-0.2.36 → cocoindex_code-0.2.37}/src/cocoindex_code/__init__.py +0 -0
- {cocoindex_code-0.2.36 → cocoindex_code-0.2.37}/src/cocoindex_code/__main__.py +0 -0
- {cocoindex_code-0.2.36 → cocoindex_code-0.2.37}/src/cocoindex_code/_daemon_paths.py +0 -0
- {cocoindex_code-0.2.36 → cocoindex_code-0.2.37}/src/cocoindex_code/chunking.py +0 -0
- {cocoindex_code-0.2.36 → cocoindex_code-0.2.37}/src/cocoindex_code/client.py +0 -0
- {cocoindex_code-0.2.36 → cocoindex_code-0.2.37}/src/cocoindex_code/embedder_defaults.py +0 -0
- {cocoindex_code-0.2.36 → cocoindex_code-0.2.37}/src/cocoindex_code/embedder_params.py +0 -0
- {cocoindex_code-0.2.36 → cocoindex_code-0.2.37}/src/cocoindex_code/litellm_embedder.py +0 -0
- {cocoindex_code-0.2.36 → cocoindex_code-0.2.37}/src/cocoindex_code/project.py +0 -0
- {cocoindex_code-0.2.36 → cocoindex_code-0.2.37}/src/cocoindex_code/protocol.py +0 -0
- {cocoindex_code-0.2.36 → cocoindex_code-0.2.37}/src/cocoindex_code/query.py +0 -0
- {cocoindex_code-0.2.36 → cocoindex_code-0.2.37}/src/cocoindex_code/schema.py +0 -0
- {cocoindex_code-0.2.36 → cocoindex_code-0.2.37}/src/cocoindex_code/server.py +0 -0
- {cocoindex_code-0.2.36 → cocoindex_code-0.2.37}/src/cocoindex_code/settings.py +0 -0
- {cocoindex_code-0.2.36 → cocoindex_code-0.2.37}/src/cocoindex_code/shared.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cocoindex-code
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.37
|
|
4
4
|
Summary: MCP server for indexing and querying codebases using CocoIndex
|
|
5
5
|
Project-URL: Homepage, https://github.com/cocoindex-io/cocoindex-code
|
|
6
6
|
Project-URL: Repository, https://github.com/cocoindex-io/cocoindex-code
|
|
@@ -17,7 +17,7 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.13
|
|
18
18
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
19
|
Requires-Python: >=3.11
|
|
20
|
-
Requires-Dist: cocoindex[litellm]<1.1.0,>=1.0.
|
|
20
|
+
Requires-Dist: cocoindex[litellm]<1.1.0,>=1.0.13
|
|
21
21
|
Requires-Dist: einops>=0.8.2
|
|
22
22
|
Requires-Dist: mcp>=1.0.0
|
|
23
23
|
Requires-Dist: msgspec>=0.19.0
|
|
@@ -29,7 +29,7 @@ Requires-Dist: questionary>=2.0.0
|
|
|
29
29
|
Requires-Dist: sqlite-vec>=0.1.0
|
|
30
30
|
Requires-Dist: typer>=0.9.0
|
|
31
31
|
Provides-Extra: dev
|
|
32
|
-
Requires-Dist: cocoindex[sentence-transformers]<1.1.0,>=1.0.
|
|
32
|
+
Requires-Dist: cocoindex[sentence-transformers]<1.1.0,>=1.0.13; extra == 'dev'
|
|
33
33
|
Requires-Dist: mypy>=1.0.0; extra == 'dev'
|
|
34
34
|
Requires-Dist: prek>=0.1.0; extra == 'dev'
|
|
35
35
|
Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
|
|
@@ -37,9 +37,9 @@ Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
|
|
|
37
37
|
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
38
38
|
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
39
39
|
Provides-Extra: embeddings-local
|
|
40
|
-
Requires-Dist: cocoindex[sentence-transformers]<1.1.0,>=1.0.
|
|
40
|
+
Requires-Dist: cocoindex[sentence-transformers]<1.1.0,>=1.0.13; extra == 'embeddings-local'
|
|
41
41
|
Provides-Extra: full
|
|
42
|
-
Requires-Dist: cocoindex[sentence-transformers]<1.1.0,>=1.0.
|
|
42
|
+
Requires-Dist: cocoindex[sentence-transformers]<1.1.0,>=1.0.13; extra == 'full'
|
|
43
43
|
Description-Content-Type: text/markdown
|
|
44
44
|
|
|
45
45
|
<p align="center">
|
|
@@ -224,6 +224,7 @@ The background daemon starts automatically on first use.
|
|
|
224
224
|
| `ccc init` | Initialize a project — creates settings files, adds `.cocoindex_code/` to `.gitignore` |
|
|
225
225
|
| `ccc index` | Build or update the index (auto-inits if needed). Shows streaming progress. |
|
|
226
226
|
| `ccc search <query>` | Semantic search across the codebase |
|
|
227
|
+
| `ccc grep <pattern> [path]` | Structural code search by example (no index needed) |
|
|
227
228
|
| `ccc status` | Show index stats (chunk count, file count, language breakdown) |
|
|
228
229
|
| `ccc mcp` | Run as MCP server in stdio mode |
|
|
229
230
|
| `ccc doctor` | Run diagnostics — checks settings, daemon, model, file matching, and index health |
|
|
@@ -244,6 +245,36 @@ ccc search --refresh database schema # update index first, then
|
|
|
244
245
|
|
|
245
246
|
By default, `ccc search` scopes results to your current working directory (relative to the project root). Use `--path` to override.
|
|
246
247
|
|
|
248
|
+
### Structural Search (`ccc grep`)
|
|
249
|
+
|
|
250
|
+
`ccc grep` finds code by **structure**, not text — you write a by-example pattern
|
|
251
|
+
and it matches the syntax tree (via cocoindex's `code_match`), so formatting,
|
|
252
|
+
whitespace, and intervening tokens don't matter. It runs entirely locally: no
|
|
253
|
+
index, daemon, or embeddings required.
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
ccc grep 'def \NAME(\(ARGS*\)):' # every Python function def under the cwd
|
|
257
|
+
ccc grep 'foo(\(ARGS*\))' src/ # calls to foo(...) anywhere under src/
|
|
258
|
+
ccc grep 'fn \NAME(\(A*\))' --lang rust # restrict to one language
|
|
259
|
+
ccc grep 'class \NAME:' --path 'tests/**' # restrict to a path glob
|
|
260
|
+
ccc grep 'TODO(\(A*\))' path/to/file.py # a single file
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Metavariables use the `\` sigil: `\NAME` captures one node, `\(NAME*\)` a run of
|
|
264
|
+
siblings, `\_`/`\*` match anonymously. The pattern is matched per language, so a
|
|
265
|
+
single invocation scans every supported source file (others are skipped). Inside
|
|
266
|
+
an initialized project, `ccc grep` honors the project's include/exclude patterns
|
|
267
|
+
and `.gitignore`; otherwise it scans all supported source files under the path.
|
|
268
|
+
|
|
269
|
+
Results stream to the terminal file-by-file as each match is found (in completion
|
|
270
|
+
order, since files are matched in parallel) rather than all at once at the end.
|
|
271
|
+
Each matching file shows its matched line range; under a TTY the path is colored,
|
|
272
|
+
line numbers are dimmed, and the unmatched context around a match is dimmed so the
|
|
273
|
+
match stands out.
|
|
274
|
+
|
|
275
|
+
> **Note:** `ccc grep` relies on cocoindex's structural `code_match` feature.
|
|
276
|
+
> Until it ships in a released cocoindex, run against a local cocoindex build.
|
|
277
|
+
|
|
247
278
|
## Docker
|
|
248
279
|
|
|
249
280
|
A Docker image is available for teams who want a reproducible, dependency-free
|
|
@@ -180,6 +180,7 @@ The background daemon starts automatically on first use.
|
|
|
180
180
|
| `ccc init` | Initialize a project — creates settings files, adds `.cocoindex_code/` to `.gitignore` |
|
|
181
181
|
| `ccc index` | Build or update the index (auto-inits if needed). Shows streaming progress. |
|
|
182
182
|
| `ccc search <query>` | Semantic search across the codebase |
|
|
183
|
+
| `ccc grep <pattern> [path]` | Structural code search by example (no index needed) |
|
|
183
184
|
| `ccc status` | Show index stats (chunk count, file count, language breakdown) |
|
|
184
185
|
| `ccc mcp` | Run as MCP server in stdio mode |
|
|
185
186
|
| `ccc doctor` | Run diagnostics — checks settings, daemon, model, file matching, and index health |
|
|
@@ -200,6 +201,36 @@ ccc search --refresh database schema # update index first, then
|
|
|
200
201
|
|
|
201
202
|
By default, `ccc search` scopes results to your current working directory (relative to the project root). Use `--path` to override.
|
|
202
203
|
|
|
204
|
+
### Structural Search (`ccc grep`)
|
|
205
|
+
|
|
206
|
+
`ccc grep` finds code by **structure**, not text — you write a by-example pattern
|
|
207
|
+
and it matches the syntax tree (via cocoindex's `code_match`), so formatting,
|
|
208
|
+
whitespace, and intervening tokens don't matter. It runs entirely locally: no
|
|
209
|
+
index, daemon, or embeddings required.
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
ccc grep 'def \NAME(\(ARGS*\)):' # every Python function def under the cwd
|
|
213
|
+
ccc grep 'foo(\(ARGS*\))' src/ # calls to foo(...) anywhere under src/
|
|
214
|
+
ccc grep 'fn \NAME(\(A*\))' --lang rust # restrict to one language
|
|
215
|
+
ccc grep 'class \NAME:' --path 'tests/**' # restrict to a path glob
|
|
216
|
+
ccc grep 'TODO(\(A*\))' path/to/file.py # a single file
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Metavariables use the `\` sigil: `\NAME` captures one node, `\(NAME*\)` a run of
|
|
220
|
+
siblings, `\_`/`\*` match anonymously. The pattern is matched per language, so a
|
|
221
|
+
single invocation scans every supported source file (others are skipped). Inside
|
|
222
|
+
an initialized project, `ccc grep` honors the project's include/exclude patterns
|
|
223
|
+
and `.gitignore`; otherwise it scans all supported source files under the path.
|
|
224
|
+
|
|
225
|
+
Results stream to the terminal file-by-file as each match is found (in completion
|
|
226
|
+
order, since files are matched in parallel) rather than all at once at the end.
|
|
227
|
+
Each matching file shows its matched line range; under a TTY the path is colored,
|
|
228
|
+
line numbers are dimmed, and the unmatched context around a match is dimmed so the
|
|
229
|
+
match stands out.
|
|
230
|
+
|
|
231
|
+
> **Note:** `ccc grep` relies on cocoindex's structural `code_match` feature.
|
|
232
|
+
> Until it ships in a released cocoindex, run against a local cocoindex build.
|
|
233
|
+
|
|
203
234
|
## Docker
|
|
204
235
|
|
|
205
236
|
A Docker image is available for teams who want a reproducible, dependency-free
|
|
@@ -23,7 +23,7 @@ classifiers = [
|
|
|
23
23
|
|
|
24
24
|
dependencies = [
|
|
25
25
|
"mcp>=1.0.0",
|
|
26
|
-
"cocoindex[litellm]>=1.0.
|
|
26
|
+
"cocoindex[litellm]>=1.0.13,<1.1.0",
|
|
27
27
|
"sqlite-vec>=0.1.0",
|
|
28
28
|
"pydantic>=2.0.0",
|
|
29
29
|
"numpy>=1.24.0",
|
|
@@ -39,7 +39,7 @@ dependencies = [
|
|
|
39
39
|
# `embeddings-local` is the primary feature extra: it pulls in
|
|
40
40
|
# `sentence-transformers` (via cocoindex) so local embeddings work without
|
|
41
41
|
# an API key.
|
|
42
|
-
embeddings-local = ["cocoindex[sentence-transformers]>=1.0.
|
|
42
|
+
embeddings-local = ["cocoindex[sentence-transformers]>=1.0.13,<1.1.0"]
|
|
43
43
|
# `full` is the umbrella "batteries-included" alias. Today it's just
|
|
44
44
|
# `embeddings-local`, but we expect to bundle more optional niceties under
|
|
45
45
|
# it over time — users who want everything can keep using `[full]` and pick
|
|
@@ -47,7 +47,7 @@ embeddings-local = ["cocoindex[sentence-transformers]>=1.0.6,<1.1.0"]
|
|
|
47
47
|
# `:full` image variant for consistency across install paths. Contents are
|
|
48
48
|
# inlined rather than self-referencing `cocoindex-code[embeddings-local]`
|
|
49
49
|
# to avoid resolver edge cases with older pip.
|
|
50
|
-
full = ["cocoindex[sentence-transformers]>=1.0.
|
|
50
|
+
full = ["cocoindex[sentence-transformers]>=1.0.13,<1.1.0"]
|
|
51
51
|
dev = [
|
|
52
52
|
"pytest>=7.0.0",
|
|
53
53
|
"pytest-asyncio>=0.21.0",
|
|
@@ -55,7 +55,7 @@ dev = [
|
|
|
55
55
|
"ruff>=0.1.0",
|
|
56
56
|
"mypy>=1.0.0",
|
|
57
57
|
"prek>=0.1.0",
|
|
58
|
-
"cocoindex[sentence-transformers]>=1.0.
|
|
58
|
+
"cocoindex[sentence-transformers]>=1.0.13,<1.1.0",
|
|
59
59
|
]
|
|
60
60
|
|
|
61
61
|
[project.scripts]
|
|
@@ -89,7 +89,7 @@ dev = [
|
|
|
89
89
|
"mypy>=1.0.0",
|
|
90
90
|
"prek>=0.1.0",
|
|
91
91
|
"types-pyyaml>=6.0.12.20250915",
|
|
92
|
-
"cocoindex[sentence-transformers]>=1.0.
|
|
92
|
+
"cocoindex[sentence-transformers]>=1.0.13,<1.1.0",
|
|
93
93
|
]
|
|
94
94
|
|
|
95
95
|
[tool.ruff]
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.2.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 2,
|
|
21
|
+
__version__ = version = '0.2.37'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 37)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import functools
|
|
6
6
|
import os
|
|
7
7
|
import sys
|
|
8
|
+
import threading
|
|
8
9
|
from collections.abc import Callable
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
from typing import TYPE_CHECKING, TypeVar
|
|
@@ -12,6 +13,7 @@ from typing import TYPE_CHECKING, TypeVar
|
|
|
12
13
|
import typer as _typer
|
|
13
14
|
|
|
14
15
|
if TYPE_CHECKING:
|
|
16
|
+
from .grep import FileMatches, GrepWarning
|
|
15
17
|
from .protocol import (
|
|
16
18
|
DoctorCheckResult,
|
|
17
19
|
IndexingProgress,
|
|
@@ -657,6 +659,79 @@ def search(
|
|
|
657
659
|
print_search_results(resp)
|
|
658
660
|
|
|
659
661
|
|
|
662
|
+
@app.command()
|
|
663
|
+
def grep(
|
|
664
|
+
pattern: str = _typer.Argument(
|
|
665
|
+
...,
|
|
666
|
+
help=r"By-example structural pattern; use \ for metavariables, "
|
|
667
|
+
r"e.g. 'def \NAME(\(ARGS*\)):' or 'foo(\(ARGS*\))'.",
|
|
668
|
+
),
|
|
669
|
+
path: str = _typer.Argument(
|
|
670
|
+
".", help="File or directory to search (default: current directory)."
|
|
671
|
+
),
|
|
672
|
+
lang: list[str] = _typer.Option(
|
|
673
|
+
[], "--lang", help="Only match files of these languages (e.g. python, rust, cpp)."
|
|
674
|
+
),
|
|
675
|
+
path_glob: str | None = _typer.Option(
|
|
676
|
+
None, "--path", help="Only match files whose path matches this glob (globset syntax)."
|
|
677
|
+
),
|
|
678
|
+
no_color: bool = _typer.Option(False, "--no-color", help="Disable colored output."),
|
|
679
|
+
) -> None:
|
|
680
|
+
r"""Structurally grep code by example (no index or daemon required).
|
|
681
|
+
|
|
682
|
+
Compiles the pattern per language and matches every supported source file
|
|
683
|
+
under PATH in parallel. Inside an initialized project it honors the project's
|
|
684
|
+
include/exclude and .gitignore rules; otherwise it scans all supported source
|
|
685
|
+
files.
|
|
686
|
+
"""
|
|
687
|
+
from . import grep as _grep
|
|
688
|
+
|
|
689
|
+
target = Path(path)
|
|
690
|
+
if not target.exists():
|
|
691
|
+
_typer.echo(f"Error: path not found: {path}", err=True)
|
|
692
|
+
raise _typer.Exit(code=1)
|
|
693
|
+
|
|
694
|
+
req = _grep.GrepRequest(
|
|
695
|
+
pattern=pattern,
|
|
696
|
+
root=target,
|
|
697
|
+
languages=frozenset(lang_name.lower() for lang_name in lang) or None,
|
|
698
|
+
path_glob=path_glob,
|
|
699
|
+
)
|
|
700
|
+
use_color = not no_color and sys.stdout.isatty() and not os.environ.get("NO_COLOR")
|
|
701
|
+
grep_run = _grep.Grep(req)
|
|
702
|
+
matched = 0
|
|
703
|
+
# `run` calls `_emit` from several worker threads at once; the lock keeps one
|
|
704
|
+
# file's output (and the `matched` bookkeeping) from interleaving with another's.
|
|
705
|
+
output_lock = threading.Lock()
|
|
706
|
+
|
|
707
|
+
def _emit(item: FileMatches | GrepWarning) -> None:
|
|
708
|
+
nonlocal matched
|
|
709
|
+
if isinstance(item, _grep.GrepWarning):
|
|
710
|
+
with output_lock:
|
|
711
|
+
_typer.echo(f"warning: {item.message}", err=True)
|
|
712
|
+
return
|
|
713
|
+
block = _grep.render_file(item, color=use_color) # render outside the lock
|
|
714
|
+
with output_lock:
|
|
715
|
+
if matched:
|
|
716
|
+
_typer.echo() # blank line between files
|
|
717
|
+
_typer.echo(block)
|
|
718
|
+
matched += 1
|
|
719
|
+
|
|
720
|
+
grep_run.run(_emit)
|
|
721
|
+
|
|
722
|
+
# The "unusable everywhere" verdict needs the whole walk, so it's known only
|
|
723
|
+
# once the run is done — report it before exiting.
|
|
724
|
+
if grep_run.unusable:
|
|
725
|
+
langs = ", ".join(grep_run.failed_languages)
|
|
726
|
+
_typer.echo(
|
|
727
|
+
f"Error: the pattern did not compile for any of the languages found ({langs}).",
|
|
728
|
+
err=True,
|
|
729
|
+
)
|
|
730
|
+
raise _typer.Exit(code=1)
|
|
731
|
+
if matched == 0:
|
|
732
|
+
_typer.echo("No matches found.")
|
|
733
|
+
|
|
734
|
+
|
|
660
735
|
@app.command()
|
|
661
736
|
@_catch_daemon_start_error
|
|
662
737
|
def status() -> None:
|
|
@@ -372,10 +372,8 @@ async def _check_file_walk(project_root_str: str) -> DoctorCheckResult:
|
|
|
372
372
|
"""Walk project files and report counts + gitignore paths."""
|
|
373
373
|
from pathlib import PurePath
|
|
374
374
|
|
|
375
|
-
from
|
|
376
|
-
|
|
377
|
-
from .indexer import GitignoreAwareMatcher
|
|
378
|
-
from .settings import load_gitignore_spec, load_project_settings
|
|
375
|
+
from .file_walk import build_matcher
|
|
376
|
+
from .settings import load_project_settings
|
|
379
377
|
|
|
380
378
|
project_root = Path(project_root_str)
|
|
381
379
|
try:
|
|
@@ -383,12 +381,7 @@ async def _check_file_walk(project_root_str: str) -> DoctorCheckResult:
|
|
|
383
381
|
except FileNotFoundError as e:
|
|
384
382
|
return DoctorCheckResult(name="File Walk", ok=False, details=[], errors=[str(e)])
|
|
385
383
|
|
|
386
|
-
|
|
387
|
-
base_matcher = PatternFilePathMatcher(
|
|
388
|
-
included_patterns=ps.include_patterns,
|
|
389
|
-
excluded_patterns=ps.exclude_patterns,
|
|
390
|
-
)
|
|
391
|
-
matcher = GitignoreAwareMatcher(base_matcher, gitignore_spec, project_root)
|
|
384
|
+
matcher = build_matcher(project_root, ps.include_patterns, ps.exclude_patterns)
|
|
392
385
|
|
|
393
386
|
counts_by_ext: dict[str, int] = {}
|
|
394
387
|
gitignore_dirs: list[str] = []
|
|
@@ -1,36 +1,22 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Shared source-file walking: pattern + .gitignore matching, reused by the
|
|
2
|
+
indexer, the daemon's doctor file-walk, and ``ccc grep``.
|
|
3
|
+
|
|
4
|
+
The matcher (include/exclude globs + nested ``.gitignore`` awareness) is the
|
|
5
|
+
single source of truth for "which files count as part of the project". The
|
|
6
|
+
indexer feeds it to CocoIndex's incremental file source; the daemon and ``ccc
|
|
7
|
+
grep`` drive a plain :func:`os.walk` over it via :func:`iter_included_files`.
|
|
8
|
+
"""
|
|
2
9
|
|
|
3
10
|
from __future__ import annotations
|
|
4
11
|
|
|
5
|
-
|
|
12
|
+
import os
|
|
13
|
+
from collections.abc import Iterable, Iterator
|
|
6
14
|
from pathlib import Path, PurePath
|
|
7
15
|
|
|
8
|
-
import cocoindex as coco
|
|
9
|
-
from cocoindex.connectors import localfs, sqlite
|
|
10
|
-
from cocoindex.connectors.sqlite import Vec0TableDef
|
|
11
|
-
from cocoindex.ops.text import RecursiveSplitter, detect_code_language
|
|
12
|
-
from cocoindex.resources.chunk import Chunk
|
|
13
16
|
from cocoindex.resources.file import FilePathMatcher, PatternFilePathMatcher
|
|
14
|
-
from cocoindex.resources.id import IdGenerator
|
|
15
17
|
from pathspec import GitIgnoreSpec
|
|
16
18
|
|
|
17
|
-
from .
|
|
18
|
-
from .settings import load_gitignore_spec, load_project_settings
|
|
19
|
-
from .shared import (
|
|
20
|
-
CODEBASE_DIR,
|
|
21
|
-
EMBEDDER,
|
|
22
|
-
INDEXING_EMBED_PARAMS,
|
|
23
|
-
SQLITE_DB,
|
|
24
|
-
CodeChunk,
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
# Chunking configuration
|
|
28
|
-
CHUNK_SIZE = 1000
|
|
29
|
-
MIN_CHUNK_SIZE = 250
|
|
30
|
-
CHUNK_OVERLAP = 150
|
|
31
|
-
|
|
32
|
-
# Chunking splitter (stateless, can be module-level)
|
|
33
|
-
splitter = RecursiveSplitter()
|
|
19
|
+
from .settings import load_gitignore_spec
|
|
34
20
|
|
|
35
21
|
|
|
36
22
|
def _normalize_gitignore_lines(lines: Iterable[str], directory: PurePath) -> list[str]:
|
|
@@ -134,98 +120,57 @@ class GitignoreAwareMatcher(FilePathMatcher):
|
|
|
134
120
|
return self._delegate.is_file_included(path)
|
|
135
121
|
|
|
136
122
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
file
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
or detect_code_language(filename=file.file_path.path.name)
|
|
161
|
-
or "text"
|
|
162
|
-
)
|
|
163
|
-
|
|
164
|
-
chunker_registry = coco.use_context(CHUNKER_REGISTRY)
|
|
165
|
-
chunker = chunker_registry.get(suffix)
|
|
166
|
-
if chunker is not None:
|
|
167
|
-
language_override, chunks = chunker(Path(file.file_path.path), content)
|
|
168
|
-
if language_override is not None:
|
|
169
|
-
language = language_override
|
|
170
|
-
else:
|
|
171
|
-
chunks = splitter.split(
|
|
172
|
-
content,
|
|
173
|
-
chunk_size=CHUNK_SIZE,
|
|
174
|
-
min_chunk_size=MIN_CHUNK_SIZE,
|
|
175
|
-
chunk_overlap=CHUNK_OVERLAP,
|
|
176
|
-
language=language,
|
|
177
|
-
)
|
|
178
|
-
|
|
179
|
-
id_gen = IdGenerator()
|
|
180
|
-
|
|
181
|
-
async def process(chunk: Chunk) -> None:
|
|
182
|
-
table.declare_row(
|
|
183
|
-
row=CodeChunk(
|
|
184
|
-
id=await id_gen.next_id(chunk.text),
|
|
185
|
-
file_path=file.file_path.path.as_posix(),
|
|
186
|
-
language=language,
|
|
187
|
-
content=chunk.text,
|
|
188
|
-
start_line=chunk.start.line,
|
|
189
|
-
end_line=chunk.end.line,
|
|
190
|
-
embedding=await embedder.embed(chunk.text, **indexing_params),
|
|
191
|
-
)
|
|
192
|
-
)
|
|
193
|
-
|
|
194
|
-
await coco.map(process, chunks)
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
@coco.fn
|
|
198
|
-
async def indexer_main() -> None:
|
|
199
|
-
"""Main indexing function - walks files and processes each."""
|
|
200
|
-
project_root = coco.use_context(CODEBASE_DIR)
|
|
201
|
-
ps = load_project_settings(project_root)
|
|
202
|
-
gitignore_spec = load_gitignore_spec(project_root)
|
|
203
|
-
|
|
204
|
-
table = await sqlite.mount_table_target(
|
|
205
|
-
db=SQLITE_DB,
|
|
206
|
-
table_name="code_chunks_vec",
|
|
207
|
-
table_schema=await sqlite.TableSchema.from_class(
|
|
208
|
-
CodeChunk,
|
|
209
|
-
primary_key=["id"],
|
|
210
|
-
),
|
|
211
|
-
virtual_table_def=Vec0TableDef(
|
|
212
|
-
partition_key_columns=["language"],
|
|
213
|
-
auxiliary_columns=["file_path", "content", "start_line", "end_line"],
|
|
214
|
-
),
|
|
215
|
-
)
|
|
216
|
-
|
|
123
|
+
def find_git_root(start: Path) -> Path | None:
|
|
124
|
+
"""Walk up from ``start`` to the nearest directory holding a ``.git`` entry — a
|
|
125
|
+
directory for a normal repo, or a *file* for a submodule or linked worktree.
|
|
126
|
+
Returns that directory, or ``None`` if ``start`` is not inside a git repo.
|
|
127
|
+
|
|
128
|
+
Used to anchor ``.gitignore`` resolution at the real repo root when grepping a
|
|
129
|
+
subdirectory that isn't inside an initialized cocoindex project."""
|
|
130
|
+
current = start.resolve()
|
|
131
|
+
while True:
|
|
132
|
+
if (current / ".git").exists():
|
|
133
|
+
return current
|
|
134
|
+
if current.parent == current:
|
|
135
|
+
return None
|
|
136
|
+
current = current.parent
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def build_matcher(
|
|
140
|
+
project_root: Path,
|
|
141
|
+
included_patterns: list[str],
|
|
142
|
+
excluded_patterns: list[str],
|
|
143
|
+
) -> FilePathMatcher:
|
|
144
|
+
"""Build the project's file matcher: include/exclude globs plus nested
|
|
145
|
+
``.gitignore`` awareness anchored at ``project_root``."""
|
|
217
146
|
base_matcher = PatternFilePathMatcher(
|
|
218
|
-
included_patterns=
|
|
219
|
-
excluded_patterns=
|
|
220
|
-
)
|
|
221
|
-
matcher: FilePathMatcher = GitignoreAwareMatcher(base_matcher, gitignore_spec, project_root)
|
|
222
|
-
|
|
223
|
-
files = localfs.walk_dir(
|
|
224
|
-
CODEBASE_DIR,
|
|
225
|
-
recursive=True,
|
|
226
|
-
path_matcher=matcher,
|
|
227
|
-
)
|
|
228
|
-
|
|
229
|
-
await coco.mount_each(
|
|
230
|
-
coco.component_subpath(coco.Symbol("process_file")), process_file, files.items(), table
|
|
147
|
+
included_patterns=included_patterns,
|
|
148
|
+
excluded_patterns=excluded_patterns,
|
|
231
149
|
)
|
|
150
|
+
return GitignoreAwareMatcher(base_matcher, load_gitignore_spec(project_root), project_root)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def iter_included_files(
|
|
154
|
+
start: Path,
|
|
155
|
+
base: Path,
|
|
156
|
+
matcher: FilePathMatcher,
|
|
157
|
+
) -> Iterator[tuple[Path, PurePath]]:
|
|
158
|
+
"""Walk ``start`` recursively, yielding ``(absolute_path, path_relative_to_base)``
|
|
159
|
+
for every file ``matcher`` includes, pruning excluded directories.
|
|
160
|
+
|
|
161
|
+
``base`` anchors the relative paths the matcher sees (the project root, so
|
|
162
|
+
its patterns line up); ``start`` is where traversal begins and may be a
|
|
163
|
+
subdirectory of ``base``. Both must be absolute. Traversal is deterministic
|
|
164
|
+
(directories and files are visited in sorted order).
|
|
165
|
+
"""
|
|
166
|
+
for dirpath_str, dirnames, filenames in os.walk(start):
|
|
167
|
+
dirpath = Path(dirpath_str)
|
|
168
|
+
rel_dir = PurePath(dirpath.relative_to(base))
|
|
169
|
+
if rel_dir != PurePath(".") and not matcher.is_dir_included(rel_dir):
|
|
170
|
+
dirnames.clear()
|
|
171
|
+
continue
|
|
172
|
+
dirnames.sort()
|
|
173
|
+
for fname in sorted(filenames):
|
|
174
|
+
rel_path = rel_dir / fname if rel_dir != PurePath(".") else PurePath(fname)
|
|
175
|
+
if matcher.is_file_included(rel_path):
|
|
176
|
+
yield dirpath / fname, rel_path
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
r"""``ccc grep`` — by-example structural code search over files.
|
|
2
|
+
|
|
3
|
+
Unlike ``ccc search`` (semantic, needs the index + daemon + embeddings), ``grep``
|
|
4
|
+
runs entirely locally: it compiles a structural pattern (cocoindex ``code_match``)
|
|
5
|
+
once per language, walks the matching source files, and matches them in parallel.
|
|
6
|
+
No index or daemon is required.
|
|
7
|
+
|
|
8
|
+
Patterns use the ``\`` sigil for metavariables, e.g. ``def \NAME(\(ARGS*\)):`` or
|
|
9
|
+
``foo(\(ARGS*\))``. See the cocoindex code_match design for the full syntax.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import functools
|
|
15
|
+
from collections.abc import Callable, Iterator
|
|
16
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import click
|
|
21
|
+
from cocoindex.ops.code import CodeMatch, CodePattern
|
|
22
|
+
from cocoindex.ops.text import detect_code_language
|
|
23
|
+
from cocoindex.resources.file import PatternFilePathMatcher
|
|
24
|
+
|
|
25
|
+
from .file_walk import build_matcher, find_git_root, iter_included_files
|
|
26
|
+
from .settings import (
|
|
27
|
+
DEFAULT_EXCLUDED_PATTERNS,
|
|
28
|
+
DEFAULT_INCLUDED_PATTERNS,
|
|
29
|
+
find_project_root,
|
|
30
|
+
load_project_settings,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# A trivial, always-valid pattern (a bare identifier) used to probe whether a
|
|
34
|
+
# language is structurally matchable, independent of the user's pattern.
|
|
35
|
+
_PROBE_PATTERN = "x"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class GrepWarning:
|
|
40
|
+
"""A non-fatal problem surfaced during a grep — a file that couldn't be read,
|
|
41
|
+
or a supported language the pattern failed to compile for. grep keeps going;
|
|
42
|
+
the CLI prints these to stderr."""
|
|
43
|
+
|
|
44
|
+
message: str
|
|
45
|
+
|
|
46
|
+
language: str | None = None
|
|
47
|
+
"""Set when this is a per-language pattern-compile failure (so :class:`Grep` can
|
|
48
|
+
tell whether the pattern is unusable for *every* language it tried)."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class FileMatches:
|
|
53
|
+
"""Every match found in one file."""
|
|
54
|
+
|
|
55
|
+
path: str
|
|
56
|
+
"""Display path, mirroring the root passed to grep (e.g. ``src/a.py`` or
|
|
57
|
+
``/tmp/x/a.py``)."""
|
|
58
|
+
|
|
59
|
+
source: str
|
|
60
|
+
"""Full file content — kept so the renderer can show context around each match."""
|
|
61
|
+
|
|
62
|
+
matches: list[CodeMatch]
|
|
63
|
+
"""Matches in source order (at least one)."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class GrepRequest:
|
|
68
|
+
"""A grep invocation."""
|
|
69
|
+
|
|
70
|
+
pattern: str
|
|
71
|
+
root: Path
|
|
72
|
+
"""File or directory to search."""
|
|
73
|
+
|
|
74
|
+
languages: frozenset[str] | None = None
|
|
75
|
+
"""Restrict to these languages (lowercased canonical names); ``None`` = all."""
|
|
76
|
+
|
|
77
|
+
path_glob: str | None = None
|
|
78
|
+
"""Extra include glob (globset syntax) on the project-relative path; ``None`` = all."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True, slots=True)
|
|
82
|
+
class _Target:
|
|
83
|
+
path: Path
|
|
84
|
+
display: str
|
|
85
|
+
pattern: CodePattern
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Per-language pattern compilation
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@functools.cache
|
|
94
|
+
def _is_match_supported(language: str) -> bool:
|
|
95
|
+
"""Whether code_match can structurally match ``language``.
|
|
96
|
+
|
|
97
|
+
Probes with a trivial always-valid pattern so the answer doesn't depend on
|
|
98
|
+
the user's pattern. Cached across the process.
|
|
99
|
+
"""
|
|
100
|
+
try:
|
|
101
|
+
CodePattern(_PROBE_PATTERN, language=language)
|
|
102
|
+
return True
|
|
103
|
+
except ValueError:
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class _PatternCompiler:
|
|
108
|
+
r"""Compiles one pattern per language on demand, caching the result.
|
|
109
|
+
|
|
110
|
+
code_match patterns are language-bound (compiled against a grammar's token
|
|
111
|
+
table), so we keep one compiled :class:`CodePattern` per language and reuse it
|
|
112
|
+
across every file of that language. A language maps to ``None`` (its files are
|
|
113
|
+
skipped) when either code_match can't match it at all, or the pattern won't
|
|
114
|
+
compile for it — the latter records a :class:`GrepWarning` once for that
|
|
115
|
+
language, so the user learns why those files were skipped instead of the whole
|
|
116
|
+
run aborting.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(self, pattern: str) -> None:
|
|
120
|
+
self._pattern = pattern
|
|
121
|
+
self._cache: dict[str, CodePattern | None] = {}
|
|
122
|
+
self.warnings: list[GrepWarning] = []
|
|
123
|
+
|
|
124
|
+
def for_language(self, language: str) -> CodePattern | None:
|
|
125
|
+
if language not in self._cache:
|
|
126
|
+
self._cache[language] = self._compile(language)
|
|
127
|
+
return self._cache[language]
|
|
128
|
+
|
|
129
|
+
def _compile(self, language: str) -> CodePattern | None:
|
|
130
|
+
try:
|
|
131
|
+
return CodePattern(self._pattern, language=language)
|
|
132
|
+
except ValueError as e:
|
|
133
|
+
# A *supported* language that won't compile the pattern is a real
|
|
134
|
+
# problem to surface (once per language); an *unsupported* language is
|
|
135
|
+
# an expected silent skip. `_is_match_supported` tells them apart,
|
|
136
|
+
# independent of the user's pattern.
|
|
137
|
+
if _is_match_supported(language):
|
|
138
|
+
self.warnings.append(
|
|
139
|
+
GrepWarning(f"pattern invalid for {language}: {e}", language=language)
|
|
140
|
+
)
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# Target collection (which files to match, with which compiled pattern)
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _detect_language(path: Path, ext_overrides: dict[str, str]) -> str | None:
|
|
150
|
+
"""Language for ``path``: project extension override first, then auto-detect."""
|
|
151
|
+
return ext_overrides.get(path.suffix) or detect_code_language(filename=path.name)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _ext_overrides(project_root: Path | None) -> dict[str, str]:
|
|
155
|
+
if project_root is None:
|
|
156
|
+
return {}
|
|
157
|
+
ps = load_project_settings(project_root)
|
|
158
|
+
return {f".{lo.ext}": lo.lang for lo in ps.language_overrides}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _target_for_file(
|
|
162
|
+
abs_path: Path,
|
|
163
|
+
display: str,
|
|
164
|
+
ext_overrides: dict[str, str],
|
|
165
|
+
req: GrepRequest,
|
|
166
|
+
compiler: _PatternCompiler,
|
|
167
|
+
) -> _Target | None:
|
|
168
|
+
language = _detect_language(abs_path, ext_overrides)
|
|
169
|
+
if language is None:
|
|
170
|
+
return None
|
|
171
|
+
if req.languages is not None and language.lower() not in req.languages:
|
|
172
|
+
return None
|
|
173
|
+
cp = compiler.for_language(language)
|
|
174
|
+
if cp is None:
|
|
175
|
+
return None
|
|
176
|
+
return _Target(path=abs_path, display=display, pattern=cp)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _resolve_file(
|
|
180
|
+
abs_path: Path,
|
|
181
|
+
display: str,
|
|
182
|
+
ext_overrides: dict[str, str],
|
|
183
|
+
req: GrepRequest,
|
|
184
|
+
compiler: _PatternCompiler,
|
|
185
|
+
) -> Iterator[_Target | GrepWarning]:
|
|
186
|
+
"""Yield any compile warning newly raised for this file's language, then the
|
|
187
|
+
file's target if the pattern compiled for that language."""
|
|
188
|
+
before = len(compiler.warnings)
|
|
189
|
+
target = _target_for_file(abs_path, display, ext_overrides, req, compiler)
|
|
190
|
+
yield from compiler.warnings[before:]
|
|
191
|
+
if target is not None:
|
|
192
|
+
yield target
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _iter_targets(req: GrepRequest, compiler: _PatternCompiler) -> Iterator[_Target | GrepWarning]:
|
|
196
|
+
"""Lazily resolve a request into the files to match, yielded as they're
|
|
197
|
+
discovered (so matching can begin before the walk finishes), interleaved with
|
|
198
|
+
each new per-language compile warning.
|
|
199
|
+
|
|
200
|
+
Single source of truth with the indexer: the same include/exclude patterns and
|
|
201
|
+
``.gitignore`` rules decide which files belong to the project. Outside a project
|
|
202
|
+
we fall back to the default source-file patterns.
|
|
203
|
+
"""
|
|
204
|
+
root = req.root.resolve()
|
|
205
|
+
|
|
206
|
+
if root.is_file():
|
|
207
|
+
project_root = find_project_root(root.parent)
|
|
208
|
+
yield from _resolve_file(
|
|
209
|
+
root, req.root.as_posix(), _ext_overrides(project_root), req, compiler
|
|
210
|
+
)
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
project_root = find_project_root(root)
|
|
214
|
+
if project_root is not None:
|
|
215
|
+
ps = load_project_settings(project_root)
|
|
216
|
+
included, excluded = ps.include_patterns, ps.exclude_patterns
|
|
217
|
+
ext_overrides = {f".{lo.ext}": lo.lang for lo in ps.language_overrides}
|
|
218
|
+
base = project_root
|
|
219
|
+
else:
|
|
220
|
+
included = list(DEFAULT_INCLUDED_PATTERNS)
|
|
221
|
+
excluded = list(DEFAULT_EXCLUDED_PATTERNS)
|
|
222
|
+
ext_overrides = {}
|
|
223
|
+
# Anchor at the enclosing git repo so grepping a subdirectory still honors the
|
|
224
|
+
# repo-root (and intervening) .gitignore; fall back to the target dir itself.
|
|
225
|
+
base = find_git_root(root) or root
|
|
226
|
+
|
|
227
|
+
matcher = build_matcher(base, included, excluded)
|
|
228
|
+
path_filter = (
|
|
229
|
+
PatternFilePathMatcher(included_patterns=[req.path_glob]) if req.path_glob else None
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
for abs_path, rel in iter_included_files(root, base, matcher):
|
|
233
|
+
if path_filter is not None and not path_filter.is_file_included(rel):
|
|
234
|
+
continue
|
|
235
|
+
# Display paths mirror the root the user gave (e.g. "src/a.py", "/tmp/x/a.py"),
|
|
236
|
+
# rather than always being cwd-relative.
|
|
237
|
+
display = (req.root / abs_path.relative_to(root)).as_posix()
|
|
238
|
+
yield from _resolve_file(abs_path, display, ext_overrides, req, compiler)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
# Matching
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _match_file(target: _Target) -> FileMatches | GrepWarning | None:
|
|
247
|
+
"""Match one file via code_match's ``match_file``: it reads the file and runs
|
|
248
|
+
the prefilter in Rust — skipping the parse (and the Python-side read) for files
|
|
249
|
+
that can't match — all with the GIL released, so a worker-thread pool scans many
|
|
250
|
+
files truly in parallel.
|
|
251
|
+
|
|
252
|
+
Returns ``None`` for a file that's binary, prefilter-rejected, or has no matches
|
|
253
|
+
(a silent skip, like ``grep -I``); a :class:`GrepWarning` for an unreadable file.
|
|
254
|
+
"""
|
|
255
|
+
try:
|
|
256
|
+
fm = target.pattern.match_file(str(target.path))
|
|
257
|
+
except OSError as e:
|
|
258
|
+
return GrepWarning(f"cannot read {target.display}: {e}")
|
|
259
|
+
if fm is None:
|
|
260
|
+
return None # binary, prefilter-rejected, or no matches
|
|
261
|
+
matches = sorted(fm.matches, key=lambda m: m.chunks[0].start.byte_offset if m.chunks else 0)
|
|
262
|
+
return FileMatches(path=target.display, source=fm.ast.source, matches=matches)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class Grep:
|
|
266
|
+
"""A single grep run. :meth:`run` matches each file as it's listed and streams
|
|
267
|
+
results as they complete. After ``run`` is exhausted, :attr:`unusable` /
|
|
268
|
+
:attr:`failed_languages` report the compile verdict."""
|
|
269
|
+
|
|
270
|
+
def __init__(self, req: GrepRequest) -> None:
|
|
271
|
+
self._req = req
|
|
272
|
+
self._compiler = _PatternCompiler(req.pattern)
|
|
273
|
+
self._target_count = 0
|
|
274
|
+
|
|
275
|
+
def run(self, emit: Callable[[FileMatches | GrepWarning], object]) -> None:
|
|
276
|
+
"""Match the request, calling ``emit`` with each file's matches and each
|
|
277
|
+
compile warning the moment it's ready — *while the walk is still running*.
|
|
278
|
+
|
|
279
|
+
Fully synchronous, no event loop: the walk runs on the calling thread and
|
|
280
|
+
submits each file's match to a thread pool. ``match_file`` reads + prefilters
|
|
281
|
+
+ parses + matches in Rust with the GIL released, so the pool threads run
|
|
282
|
+
truly in parallel — with the walk and with each other — and hand each result
|
|
283
|
+
straight to ``emit`` as it finishes. ``run`` returns once the walk and every
|
|
284
|
+
match are done; afterwards :attr:`unusable` / :attr:`failed_languages` hold the
|
|
285
|
+
compile verdict.
|
|
286
|
+
|
|
287
|
+
``emit`` is called concurrently from the pool's worker threads (plus this
|
|
288
|
+
thread, for warnings), so a consumer that does I/O must serialize it itself.
|
|
289
|
+
"""
|
|
290
|
+
|
|
291
|
+
def _match(item: _Target) -> None:
|
|
292
|
+
result = _match_file(item)
|
|
293
|
+
if result is not None: # None = binary / prefiltered / no match (skip)
|
|
294
|
+
emit(result)
|
|
295
|
+
|
|
296
|
+
with ThreadPoolExecutor() as pool:
|
|
297
|
+
for item in _iter_targets(self._req, self._compiler):
|
|
298
|
+
if isinstance(item, GrepWarning):
|
|
299
|
+
emit(item)
|
|
300
|
+
else:
|
|
301
|
+
self._target_count += 1
|
|
302
|
+
pool.submit(_match, item)
|
|
303
|
+
# ThreadPoolExecutor.__exit__ waits for every submitted match to finish.
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def failed_languages(self) -> list[str]:
|
|
307
|
+
"""Supported languages the pattern would not compile for (valid once
|
|
308
|
+
:meth:`run` is exhausted)."""
|
|
309
|
+
return [w.language for w in self._compiler.warnings if w.language is not None]
|
|
310
|
+
|
|
311
|
+
@property
|
|
312
|
+
def unusable(self) -> bool:
|
|
313
|
+
"""The pattern compiled for *none* of the languages actually encountered — a
|
|
314
|
+
supported language was found but every one rejected the pattern, so there was
|
|
315
|
+
nothing to match. A target exists only for a file whose language compiled, so
|
|
316
|
+
zero targets plus ≥1 failed language means unusable everywhere it was tried —
|
|
317
|
+
distinct from "no matchable files found" (no failures). Valid once
|
|
318
|
+
:meth:`run` is exhausted."""
|
|
319
|
+
return bool(self.failed_languages) and self._target_count == 0
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ---------------------------------------------------------------------------
|
|
323
|
+
# Rendering
|
|
324
|
+
# ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _line_char_offsets(source: str) -> list[int]:
|
|
328
|
+
"""Char offset of the start of each line (index ``line - 1``)."""
|
|
329
|
+
offsets = [0]
|
|
330
|
+
for i, ch in enumerate(source):
|
|
331
|
+
if ch == "\n":
|
|
332
|
+
offsets.append(i + 1)
|
|
333
|
+
return offsets
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _paint(text: str, color: bool, **style: object) -> str:
|
|
337
|
+
if not color or not text:
|
|
338
|
+
return text
|
|
339
|
+
return click.style(text, **style) # type: ignore[arg-type]
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _render_code_line(
|
|
343
|
+
line_no: int,
|
|
344
|
+
text: str,
|
|
345
|
+
dim_pre_end: int,
|
|
346
|
+
dim_post_start: int,
|
|
347
|
+
width: int,
|
|
348
|
+
color: bool,
|
|
349
|
+
) -> str:
|
|
350
|
+
"""One code line: dimmed ``line_no| `` gutter, then the text with the leading
|
|
351
|
+
(before ``dim_pre_end``) and trailing (from ``dim_post_start``) context dimmed
|
|
352
|
+
and the matched span shown normally."""
|
|
353
|
+
pre = max(0, min(dim_pre_end, len(text)))
|
|
354
|
+
post = max(pre, min(dim_post_start, len(text)))
|
|
355
|
+
gutter = _paint(f"{line_no:>{width}}| ", color, fg="bright_black")
|
|
356
|
+
if not color:
|
|
357
|
+
return f"{gutter}{text}"
|
|
358
|
+
before = _paint(text[:pre], color, dim=True)
|
|
359
|
+
matched = text[pre:post]
|
|
360
|
+
after = _paint(text[post:], color, dim=True)
|
|
361
|
+
return f"{gutter}{before}{matched}{after}"
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _render_match(
|
|
365
|
+
src_lines: list[str],
|
|
366
|
+
offsets: list[int],
|
|
367
|
+
match: CodeMatch,
|
|
368
|
+
width: int,
|
|
369
|
+
color: bool,
|
|
370
|
+
) -> list[str]:
|
|
371
|
+
chunk = match.chunks[0]
|
|
372
|
+
s_off, e_off = chunk.start.char_offset, chunk.end.char_offset
|
|
373
|
+
s_line, e_line = chunk.start.line, chunk.end.line
|
|
374
|
+
out: list[str] = []
|
|
375
|
+
for line_no in range(s_line, e_line + 1):
|
|
376
|
+
idx = line_no - 1
|
|
377
|
+
text = src_lines[idx] if 0 <= idx < len(src_lines) else ""
|
|
378
|
+
line_start = offsets[idx] if 0 <= idx < len(offsets) else 0
|
|
379
|
+
dim_pre_end = (s_off - line_start) if line_no == s_line else 0
|
|
380
|
+
dim_post_start = (e_off - line_start) if line_no == e_line else len(text)
|
|
381
|
+
out.append(_render_code_line(line_no, text, dim_pre_end, dim_post_start, width, color))
|
|
382
|
+
return out
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def render_file(fm: FileMatches, *, color: bool) -> str:
|
|
386
|
+
"""Render one file's matches: the path, then each match's line range, with
|
|
387
|
+
matches separated by a ``---`` line."""
|
|
388
|
+
# Split on "\n" to keep line numbers aligned with the offsets below (which
|
|
389
|
+
# count "\n"), then drop the trailing "\r" that CRLF files leave on each line.
|
|
390
|
+
src_lines = [line.rstrip("\r") for line in fm.source.split("\n")]
|
|
391
|
+
offsets = _line_char_offsets(fm.source)
|
|
392
|
+
max_line = max((m.chunks[0].end.line for m in fm.matches if m.chunks), default=1)
|
|
393
|
+
width = len(str(max_line))
|
|
394
|
+
|
|
395
|
+
parts = [_paint(fm.path, color, fg="magenta", bold=True)]
|
|
396
|
+
emitted = False
|
|
397
|
+
for match in fm.matches:
|
|
398
|
+
if not match.chunks:
|
|
399
|
+
continue
|
|
400
|
+
if emitted:
|
|
401
|
+
parts.append(_paint("---", color, fg="bright_black"))
|
|
402
|
+
parts.extend(_render_match(src_lines, offsets, match, width, color))
|
|
403
|
+
emitted = True
|
|
404
|
+
return "\n".join(parts)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def render_results(results: list[FileMatches], *, color: bool) -> str:
|
|
408
|
+
"""Render a list of per-file matches in the ``ccc grep`` output format, files
|
|
409
|
+
separated by a blank line. The CLI streams with :func:`render_file` instead;
|
|
410
|
+
this is the batch form (used in tests)."""
|
|
411
|
+
return "\n\n".join(render_file(fm, color=color) for fm in results)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""CocoIndex app for indexing codebases."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import cocoindex as coco
|
|
8
|
+
from cocoindex.connectors import localfs, sqlite
|
|
9
|
+
from cocoindex.connectors.sqlite import Vec0TableDef
|
|
10
|
+
from cocoindex.ops.text import RecursiveSplitter, detect_code_language
|
|
11
|
+
from cocoindex.resources.chunk import Chunk
|
|
12
|
+
from cocoindex.resources.id import IdGenerator
|
|
13
|
+
|
|
14
|
+
from .chunking import CHUNKER_REGISTRY
|
|
15
|
+
from .file_walk import build_matcher
|
|
16
|
+
from .settings import load_project_settings
|
|
17
|
+
from .shared import (
|
|
18
|
+
CODEBASE_DIR,
|
|
19
|
+
EMBEDDER,
|
|
20
|
+
INDEXING_EMBED_PARAMS,
|
|
21
|
+
SQLITE_DB,
|
|
22
|
+
CodeChunk,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Chunking configuration
|
|
26
|
+
CHUNK_SIZE = 1000
|
|
27
|
+
MIN_CHUNK_SIZE = 250
|
|
28
|
+
CHUNK_OVERLAP = 150
|
|
29
|
+
|
|
30
|
+
# Chunking splitter (stateless, can be module-level)
|
|
31
|
+
splitter = RecursiveSplitter()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@coco.fn(memo=True)
|
|
35
|
+
async def process_file(
|
|
36
|
+
file: localfs.File,
|
|
37
|
+
table: sqlite.TableTarget[CodeChunk],
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Process a single file: chunk, embed, and store."""
|
|
40
|
+
embedder = coco.use_context(EMBEDDER)
|
|
41
|
+
indexing_params = coco.use_context(INDEXING_EMBED_PARAMS)
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
content = await file.read_text()
|
|
45
|
+
except UnicodeDecodeError:
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
if not content.strip():
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
suffix = file.file_path.path.suffix
|
|
52
|
+
project_root = coco.use_context(CODEBASE_DIR)
|
|
53
|
+
ps = load_project_settings(project_root)
|
|
54
|
+
ext_lang_map = {f".{lo.ext}": lo.lang for lo in ps.language_overrides}
|
|
55
|
+
language = (
|
|
56
|
+
ext_lang_map.get(suffix)
|
|
57
|
+
or detect_code_language(filename=file.file_path.path.name)
|
|
58
|
+
or "text"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
chunker_registry = coco.use_context(CHUNKER_REGISTRY)
|
|
62
|
+
chunker = chunker_registry.get(suffix)
|
|
63
|
+
if chunker is not None:
|
|
64
|
+
language_override, chunks = chunker(Path(file.file_path.path), content)
|
|
65
|
+
if language_override is not None:
|
|
66
|
+
language = language_override
|
|
67
|
+
else:
|
|
68
|
+
chunks = splitter.split(
|
|
69
|
+
content,
|
|
70
|
+
chunk_size=CHUNK_SIZE,
|
|
71
|
+
min_chunk_size=MIN_CHUNK_SIZE,
|
|
72
|
+
chunk_overlap=CHUNK_OVERLAP,
|
|
73
|
+
language=language,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
id_gen = IdGenerator()
|
|
77
|
+
|
|
78
|
+
async def process(chunk: Chunk) -> None:
|
|
79
|
+
table.declare_row(
|
|
80
|
+
row=CodeChunk(
|
|
81
|
+
id=await id_gen.next_id(chunk.text),
|
|
82
|
+
file_path=file.file_path.path.as_posix(),
|
|
83
|
+
language=language,
|
|
84
|
+
content=chunk.text,
|
|
85
|
+
start_line=chunk.start.line,
|
|
86
|
+
end_line=chunk.end.line,
|
|
87
|
+
embedding=await embedder.embed(chunk.text, **indexing_params),
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
await coco.map(process, chunks)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@coco.fn
|
|
95
|
+
async def indexer_main() -> None:
|
|
96
|
+
"""Main indexing function - walks files and processes each."""
|
|
97
|
+
project_root = coco.use_context(CODEBASE_DIR)
|
|
98
|
+
ps = load_project_settings(project_root)
|
|
99
|
+
|
|
100
|
+
table = await sqlite.mount_table_target(
|
|
101
|
+
db=SQLITE_DB,
|
|
102
|
+
table_name="code_chunks_vec",
|
|
103
|
+
table_schema=await sqlite.TableSchema.from_class(
|
|
104
|
+
CodeChunk,
|
|
105
|
+
primary_key=["id"],
|
|
106
|
+
),
|
|
107
|
+
virtual_table_def=Vec0TableDef(
|
|
108
|
+
partition_key_columns=["language"],
|
|
109
|
+
auxiliary_columns=["file_path", "content", "start_line", "end_line"],
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
matcher = build_matcher(project_root, ps.include_patterns, ps.exclude_patterns)
|
|
114
|
+
|
|
115
|
+
files = localfs.walk_dir(
|
|
116
|
+
CODEBASE_DIR,
|
|
117
|
+
recursive=True,
|
|
118
|
+
path_matcher=matcher,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
await coco.mount_each(
|
|
122
|
+
coco.component_subpath(coco.Symbol("process_file")), process_file, files.items(), table
|
|
123
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|